feat: speech models have a roster; each brings its voices, users choose across all of them
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s

Discover lists speech models with the voices each accepts and a + Add that
puts the model on tts.roster. The Roster card lists every model with a voice
picker, Test, Make default and Remove. Test on any row (or a discovered model
not yet added) fills the test panel's voice list with that model's voices, so
Orpheus and Kokoro can be heard one voice at a time before either is chosen.

The default is a pair — PUT /config/tts/default sets tts.model and tts.voice
together and refuses a voice the model does not accept, naming the ones it
does. The generic setter no longer takes tts.model/tts.voice one at a time,
which is how a Kokoro voice got paired with Orpheus. A default that leaves
the roster stops being the default.

Users pick from the voices of every roster model, grouped by model in
Settings; the stored value is "model|voice" so read-aloud sends the voice to
the model that accepts it. A bare voice saved before there was a roster is
read as a voice of the default model. chooseTTS is the one place the pair is
decided, shared by read-aloud, the admin test and the settings options.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 04:49:24 +02:00
parent 8a6a4df121
commit 07161c6fa8
9 changed files with 534 additions and 191 deletions

View file

@ -223,12 +223,12 @@
</summary> </summary>
<div class="admin-card-body"> <div class="admin-card-body">
<!-- One search for every kind of model. The kind decides which <!-- One search for every kind of model. The kind decides which
gateway list is asked and what the row buttons do: chat and gateway list is asked and what the row buttons do: chat, image
image models are added to a roster; speech, transcription and and speech models are added to a roster; transcription is a
transcription models are single defaults, so their button says single default, so its button says Make default rather than
Make default rather than Add: there is one of each, and it does Add: there is one, and it does not join the Roster below. "Set"
not join the Roster below. "Set" read like "add to the list", read like "add to the list", which is the one thing it does not
which is the one thing it does not do. --> do. -->
<div class="admin-kind-switch" role="group" aria-label="Kind of model"> <div class="admin-kind-switch" role="group" aria-label="Kind of model">
<button type="button" class="admin-discover-kind" id="admin-discover-kind-chat" data-kind="chat" aria-pressed="true"><i class="fas fa-comments"></i> Chat</button> <button type="button" class="admin-discover-kind" id="admin-discover-kind-chat" data-kind="chat" aria-pressed="true"><i class="fas fa-comments"></i> Chat</button>
<button type="button" class="admin-discover-kind" id="admin-discover-kind-image" data-kind="image" aria-pressed="false"><i class="fas fa-image"></i> Image</button> <button type="button" class="admin-discover-kind" id="admin-discover-kind-image" data-kind="image" aria-pressed="false"><i class="fas fa-image"></i> Image</button>
@ -248,7 +248,7 @@
<div class="admin-kind-panel admin-kind-status" data-kind="tts" hidden> <div class="admin-kind-panel admin-kind-status" data-kind="tts" hidden>
<span id="admin-tts-provider-badge" class="admin-badge">Loading...</span> <span id="admin-tts-provider-badge" class="admin-badge">Loading...</span>
<span id="admin-tts-info" class="admin-note">Loading...</span> <span id="admin-tts-info" class="admin-note">Loading...</span>
<span class="admin-note">There is one voice and one model for Read Aloud. <strong>Make default</strong> chooses them; nothing is added to a list.</span> <span class="admin-note">Each speech model brings its own voices. <strong>+ Add</strong> puts a model on the speech roster; users then choose from the voices of every model on it. The default pair is chosen on the roster below.</span>
</div> </div>
<div class="admin-kind-panel admin-kind-status" data-kind="stt" hidden> <div class="admin-kind-panel admin-kind-status" data-kind="stt" hidden>
<span id="admin-stt-provider-badge" class="admin-badge">Loading...</span> <span id="admin-stt-provider-badge" class="admin-badge">Loading...</span>
@ -285,7 +285,10 @@
<div id="admin-image-test-result" role="status" class="admin-note" style="margin-top:8px;"></div> <div id="admin-image-test-result" role="status" class="admin-note" style="margin-top:8px;"></div>
</div> </div>
<div class="admin-kind-panel" data-kind="tts" hidden> <div class="admin-kind-panel" data-kind="tts" hidden>
<p class="admin-note" style="margin-bottom:8px;">Press Test on a model above or on the roster; its voices fill the list. Try them one by one.</p>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px;"> <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
<label for="admin-tts-model" style="font-size:13px;font-weight:600;min-width:80px;">Model:</label>
<select id="admin-tts-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select>
<label for="admin-tts-voice" style="font-size:13px;font-weight:600;min-width:80px;">Voice:</label> <label for="admin-tts-voice" style="font-size:13px;font-weight:600;min-width:80px;">Voice:</label>
<select id="admin-tts-voice" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select> <select id="admin-tts-voice" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select>
</div> </div>
@ -338,7 +341,13 @@
<p class="admin-note">Loading...</p> <p class="admin-note">Loading...</p>
</div> </div>
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Speech and transcription are not on this roster. There is one voice and one model for each, chosen with <strong>Make default</strong> under Discover &amp; test.</p> <h4>Speech models</h4>
<p class="admin-note">Added with <strong>+ Add</strong> under Discover &amp; test. Users choose a voice from every model here. Pick a voice on a row and press <strong>Make default</strong> for the pair Read Aloud uses when nobody chose.</p>
<div id="admin-tts-roster" style="display:flex;flex-direction:column;gap:4px;">
<p class="admin-note">Loading...</p>
</div>
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Transcription is not on this roster. There is one model, chosen with <strong>Make default</strong> under Discover &amp; test.</p>
</div> </div>
</details> </details>

View file

@ -1280,19 +1280,38 @@ initImageSettings();
// ============================================================ // ============================================================
// ADMIN TTS MANAGEMENT // ADMIN TTS MANAGEMENT
// ============================================================ // ============================================================
// Speech models have a roster of their own, like image models: + Add under
// Discover puts one on it (tts.roster), the Roster card lists each with its
// voices, and Make default on a row chooses the pair Read Aloud uses when a
// user has not picked. Users pick from the voices of every roster model. A
// voice only ever appears under the model that accepts it — the flat list of
// "Make default" voices this replaced offered Orpheus voices for Kokoro and
// every one of those was a 500.
{ {
var ttsRoster = []; // [{ model, voices, isDefault, defaultVoice }]
var ttsDefault = { model: '', voice: '' };
document.addEventListener('tabChanged', function(e) { document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadTTSConfig(); if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
}); });
// Catch-up for a tab that is already active and loaded at module init. // Catch-up for a tab that is already active and loaded at module init.
if (adminTabActive()) loadTTSConfig(); if (adminTabActive()) loadTTSConfig();
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.closest('#btn-test-tts')) testTTS(); if (e.target.closest('#btn-test-tts')) { testTTS(); return; }
if (e.target.closest('.admin-tts-set-btn')) { var toggle = e.target.closest('.admin-tts-add-btn, .admin-tts-remove-btn');
var btn = e.target.closest('.admin-tts-set-btn'); if (toggle) { toggleTTSRoster(toggle.dataset.id, toggle); return; }
setTTSDefault(btn.dataset.id, btn.dataset.type, btn); var pick = e.target.closest('.admin-tts-test-btn');
if (pick) { pickTTSModelForTest(pick.dataset.id, (pick.dataset.voices || '').split(',').filter(Boolean)); return; }
var def = e.target.closest('.admin-tts-default-btn');
if (def) {
var row = def.closest('[data-model]');
var voiceSel = row && row.querySelector('select');
setTTSDefault(def.dataset.id, voiceSel ? voiceSel.value : '', def);
} }
}); });
document.addEventListener('change', function(e) {
if (e.target.id === 'admin-tts-model') fillTTSTestVoices();
});
document.addEventListener('admin-discover', function(e) { document.addEventListener('admin-discover', function(e) {
if (e.detail && e.detail.kind === 'tts') discoverTTS(); if (e.detail && e.detail.kind === 'tts') discoverTTS();
}); });
@ -1304,6 +1323,8 @@ initImageSettings();
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (!data.success) return; if (!data.success) return;
ttsRoster = Array.isArray(data.roster) ? data.roster : [];
ttsDefault = { model: data.currentModel || '', voice: data.currentVoice || '' };
var badge = document.getElementById('admin-tts-provider-badge'); var badge = document.getElementById('admin-tts-provider-badge');
if (badge) { if (badge) {
badge.textContent = (data.provider || 'none').toUpperCase(); badge.textContent = (data.provider || 'none').toUpperCase();
@ -1314,32 +1335,144 @@ initImageSettings();
if (info) { if (info) {
var parts = []; var parts = [];
if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider); if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider);
if (data.dbVoice) parts.push('DB voice: ' + data.dbVoice); parts.push(ttsRoster.length + ' model' + (ttsRoster.length === 1 ? '' : 's') + ' on the roster');
else if (data.envVoice) parts.push('Env voice: ' + data.envVoice); if (data.currentModel) parts.push('Default: ' + data.currentModel + (data.currentVoice ? ' / ' + data.currentVoice : '') + (data.dbModel ? '' : ' (from env)'));
if (data.dbModel) parts.push('DB model: ' + data.dbModel); else parts.push('No default chosen');
else if (data.envModel) parts.push('Env model: ' + data.envModel); info.textContent = parts.join(' · ');
var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; });
if (configured.length) parts.push('Configured: ' + configured.join(', '));
info.textContent = parts.join(' · ') || 'Auto-detected from env';
}
var voiceSel = document.getElementById('admin-tts-voice');
if (voiceSel) {
voiceSel.innerHTML = '';
var voices = (data.voices && data.voices[data.provider]) || [];
if (data.currentVoice && voices.indexOf(data.currentVoice) === -1) voices = [data.currentVoice].concat(voices);
if (voices.length === 0) voices = ['default'];
voices.forEach(function(v) {
var opt = document.createElement('option');
opt.value = v;
opt.textContent = v + (v === data.currentVoice ? ' (active)' : '');
if (v === data.currentVoice) opt.selected = true;
voiceSel.appendChild(opt);
});
} }
renderTTSRoster();
fillTTSTestModels();
syncTTSRows();
}) })
.catch(function() {}); .catch(function() {});
} }
// ── Roster card ──────────────────────────────────────────────
function renderTTSRoster() {
var container = document.getElementById('admin-tts-roster');
if (!container) return;
container.replaceChildren();
if (!ttsRoster.length) {
var empty = document.createElement('p');
empty.className = 'admin-note';
empty.textContent = 'No speech models added yet. Search for one under Discover & test and press + Add.';
container.appendChild(empty);
return;
}
ttsRoster.forEach(function(entry) {
var row = document.createElement('div');
row.dataset.model = entry.model;
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;';
var name = document.createElement('span');
name.style.cssText = 'flex:1;min-width:120px;overflow-wrap:anywhere;';
name.textContent = entry.model;
if (entry.isDefault) {
var badge = document.createElement('span');
badge.style.cssText = 'font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:6px;vertical-align:middle;';
badge.textContent = 'DEFAULT';
name.appendChild(badge);
}
var voiceSel = document.createElement('select');
voiceSel.setAttribute('aria-label', 'Default voice for ' + entry.model);
voiceSel.style.cssText = 'font-size:12px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;max-width:220px;';
var voices = entry.voices && entry.voices.length ? entry.voices : [];
if (!voices.length) {
var none = document.createElement('option');
none.value = ''; none.textContent = 'no voice list — model default';
voiceSel.appendChild(none);
}
voices.forEach(function(v) {
var opt = document.createElement('option');
opt.value = v; opt.textContent = v;
if (entry.isDefault && v === entry.defaultVoice) opt.selected = true;
voiceSel.appendChild(opt);
});
var test = document.createElement('button');
test.type = 'button';
test.className = 'btn-sm btn-ghost admin-tts-test-btn';
test.dataset.id = entry.model;
test.dataset.voices = voices.join(',');
test.style.cssText = 'padding:2px 8px;font-size:11px;';
test.textContent = 'Test';
var def = document.createElement('button');
def.type = 'button';
def.className = 'btn-sm btn-primary admin-tts-default-btn';
def.dataset.id = entry.model;
def.style.cssText = 'padding:2px 8px;font-size:11px;white-space:nowrap;';
def.textContent = 'Make default';
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-sm admin-tts-remove-btn';
remove.dataset.id = entry.model;
remove.style.cssText = 'padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;';
remove.textContent = 'Remove';
row.appendChild(name);
row.appendChild(voiceSel);
row.appendChild(test);
row.appendChild(def);
row.appendChild(remove);
container.appendChild(row);
});
}
function rosterIds() { return ttsRoster.map(function(e) { return e.model; }); }
function toggleTTSRoster(id, btn) {
if (!id) return;
var ids = rosterIds();
var added = ids.indexOf(id) !== -1;
var next = added ? ids.filter(function(x) { return x !== id; }) : ids.concat([id]);
adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent('tts.roster'), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not update the speech model list');
showToast(added ? id + ' removed from the speech roster' + (id === ttsDefault.model ? '. It was the default — choose another on the roster.' : '')
: id + ' added to the roster. Its voices are now offered to users; press Test to hear them.', 'success');
loadTTSConfig();
})
.catch(function(err) { loadTTSConfig(); showToast(err.message || 'Request failed', 'error'); });
}
function setTTSDefault(model, voice, btn) {
var origText = btn ? btn.textContent : '';
adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/tts/default', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ model: model, voice: voice })
})
.then(function(r) { return r.json(); })
.then(function(data) {
adminSetButtonText(btn, origText, false);
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
adminFlashButtonBackground(btn, 'var(--green)');
showToast('Read Aloud default: ' + data.model + ' / ' + (data.voice || 'model default'), 'success');
loadTTSConfig();
})
.catch(function() {
adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error');
});
}
// ── Discover ─────────────────────────────────────────────────
function ttsAddButton(id) {
var added = rosterIds().indexOf(id) !== -1;
return added
? '<button class="btn-sm btn-ghost admin-tts-add-btn" type="button" data-id="' + esc(id) + '" title="On the speech roster. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
: '<button class="btn-sm btn-primary admin-tts-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the speech roster" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
}
// Rows rendered before the roster loaded (or after it changed) catch up here.
function syncTTSRows() {
var container = document.getElementById('admin-discover-results');
if (!container) return;
container.querySelectorAll('.admin-tts-add-btn').forEach(function(btn) { btn.outerHTML = ttsAddButton(btn.dataset.id); });
}
function discoverTTS() { function discoverTTS() {
var search = (document.getElementById('admin-discover-search') || {}).value || ''; var search = (document.getElementById('admin-discover-search') || {}).value || '';
var container = document.getElementById('admin-discover-results'); var container = document.getElementById('admin-discover-results');
@ -1355,20 +1488,21 @@ initImageSettings();
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>'; container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return; return;
} }
var items = data.voices || []; var items = data.models || [];
if (items.length === 0) { if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>'; container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No speech models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return; return;
} }
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' + container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' speech model' + (data.count === 1 ? '' : 's') + ' (provider: ' + esc(data.provider) + ')</p>' +
items.slice(0, 100).map(function(v) { items.map(function(m) {
var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1; var voices = Array.isArray(m.voices) ? m.voices : [];
var setType = isModel ? 'model' : 'voice'; var voiceNote = voices.length ? voices.length + ' voice' + (voices.length === 1 ? '' : 's') + ': ' + voices.join(', ') : 'no voice list — the model chooses';
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>'; return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;">' +
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' + '<button class="btn-sm btn-ghost admin-tts-test-btn" type="button" data-id="' + esc(m.id) + '" data-voices="' + esc(voices.join(',')) + '" style="padding:2px 8px;font-size:11px;">Test</button>' +
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Make default</button>' + '<span style="flex:1;min-width:160px;overflow-wrap:anywhere;">' + esc(m.name) +
'<span style="flex:1;">' + esc(v.name) + badge + '</span>' + '<span style="display:block;font-size:11px;color:var(--g500);">' + esc(voiceNote) + '</span></span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' + '<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
ttsAddButton(m.id) +
'</div>'; '</div>';
}).join(''); }).join('');
}) })
@ -1377,46 +1511,69 @@ initImageSettings();
}); });
} }
function setTTSDefault(id, type, btn) { // ── Test ─────────────────────────────────────────────────────
var key = type === 'model' ? 'tts.model' : 'tts.voice'; // The model list is the roster. Test on a discovered model that is not on
var origText = btn ? btn.textContent : ''; // it yet adds a temporary entry, so a model can be heard before it is added.
adminSetButtonText(btn, '...', true); function fillTTSTestModels(keep) {
var sel = document.getElementById('admin-tts-model');
fetch('/api/admin/config/' + encodeURIComponent(key), { if (!sel) return;
method: 'PUT', var current = keep || sel.value || ttsDefault.model;
headers: getAuthHeaders(), sel.replaceChildren();
body: JSON.stringify({ value: id }) ttsRoster.forEach(function(entry) {
})
.then(function(r) { return r.json(); })
.then(function(data) {
adminSetButtonText(btn, 'Set', false);
adminFlashButtonBackground(btn, 'var(--green)');
if (data.success) {
showToast('TTS ' + type + ' set to: ' + id, 'success');
// Update voice selector
var voiceSel = document.getElementById('admin-tts-voice');
if (voiceSel && type === 'voice') {
var found = Array.from(voiceSel.options).find(function(o) { return o.value === id; });
if (!found) {
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = id; opt.textContent = id + ' (active)'; opt.value = entry.model;
voiceSel.insertBefore(opt, voiceSel.firstChild); opt.textContent = entry.model + (entry.isDefault ? ' (default)' : '');
opt.dataset.voices = (entry.voices || []).join(',');
sel.appendChild(opt);
});
if (!sel.options.length) {
var none = document.createElement('option');
none.value = ''; none.textContent = 'No speech models on the roster';
sel.appendChild(none);
} }
voiceSel.value = id; if (current && Array.from(sel.options).some(function(o) { return o.value === current; })) sel.value = current;
fillTTSTestVoices();
} }
loadTTSConfig();
} else { function pickTTSModelForTest(id, voices) {
showToast(data.error || 'Failed', 'error'); var sel = document.getElementById('admin-tts-model');
if (!sel || !id) return;
if (!Array.from(sel.options).some(function(o) { return o.value === id; })) {
var opt = document.createElement('option');
opt.value = id; opt.textContent = id + ' (not on the roster)';
opt.dataset.voices = (voices || []).join(',');
sel.appendChild(opt);
} }
}) sel.value = id;
.catch(function() { fillTTSTestVoices();
adminSetButtonText(btn, origText, false); var panel = document.getElementById('admin-tts-test-text');
showToast('Request failed', 'error'); if (panel && panel.scrollIntoView) panel.scrollIntoView({ block: 'nearest' });
}
function fillTTSTestVoices() {
var modelSel = document.getElementById('admin-tts-model');
var voiceSel = document.getElementById('admin-tts-voice');
if (!modelSel || !voiceSel) return;
var opt = modelSel.options[modelSel.selectedIndex];
var voices = opt && opt.dataset.voices ? opt.dataset.voices.split(',').filter(Boolean) : [];
voiceSel.replaceChildren();
if (!voices.length) {
var none = document.createElement('option');
none.value = ''; none.textContent = 'model default (no voice list)';
voiceSel.appendChild(none);
}
voices.forEach(function(v) {
var o = document.createElement('option');
o.value = v;
o.textContent = v + (modelSel.value === ttsDefault.model && v === ttsDefault.voice ? ' (default)' : '');
if (modelSel.value === ttsDefault.model && v === ttsDefault.voice) o.selected = true;
voiceSel.appendChild(o);
}); });
} }
function testTTS() { function testTTS() {
var text = (document.getElementById('admin-tts-test-text') || {}).value || 'Hello.'; var text = (document.getElementById('admin-tts-test-text') || {}).value || 'Hello.';
var model = (document.getElementById('admin-tts-model') || {}).value || '';
var voice = (document.getElementById('admin-tts-voice') || {}).value || ''; var voice = (document.getElementById('admin-tts-voice') || {}).value || '';
var btn = document.getElementById('btn-test-tts'); var btn = document.getElementById('btn-test-tts');
var resultEl = document.getElementById('admin-tts-result'); var resultEl = document.getElementById('admin-tts-result');
@ -1428,7 +1585,7 @@ initImageSettings();
fetch('/api/admin/config/tts/test', { fetch('/api/admin/config/tts/test', {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ text: text, voice: voice }) body: JSON.stringify({ text: text, model: model, voice: voice })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@ -1443,7 +1600,7 @@ initImageSettings();
audioEl.style.display = 'inline-block'; audioEl.style.display = 'inline-block';
audioEl.play(); audioEl.play();
} }
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?'); if (resultEl) resultEl.textContent = 'Model: ' + (data.model || '?') + ' · Voice: ' + (data.voice || 'model default');
}) })
.catch(function(err) { .catch(function(err) {
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; Play', false); adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; Play', false);

View file

@ -86,15 +86,27 @@
}); });
} }
// Populate TTS voices // Populate TTS voices, grouped by the model each belongs to. A voice's
// value is "model|voice": the server needs both to send the request to
// the model that accepts that voice.
var ttsSelect = document.getElementById('tts-voice-select'); var ttsSelect = document.getElementById('tts-voice-select');
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) { if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>'; ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
var groups = {};
data.ttsVoices.forEach(function(voice) { data.ttsVoices.forEach(function(voice) {
var parent = ttsSelect;
if (voice.model) {
if (!groups[voice.model]) {
groups[voice.model] = document.createElement('optgroup');
groups[voice.model].label = voice.model;
ttsSelect.appendChild(groups[voice.model]);
}
parent = groups[voice.model];
}
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = voice.value; opt.value = voice.value;
opt.textContent = voice.label; opt.textContent = voice.label;
ttsSelect.appendChild(opt); parent.appendChild(opt);
}); });
} }
}) })
@ -159,7 +171,7 @@
var voice = ttsSelect ? ttsSelect.value : null; var voice = ttsSelect ? ttsSelect.value : null;
// Allow "Server default" (empty value) to preview // Allow "Server default" (empty value) to preview
var displayVoice = voice || 'server default'; var displayVoice = voice ? voice.split('|').pop() : 'server default';
var text = 'Hello, this is a preview of the ' + displayVoice + ' voice. This is how your read-aloud feature will sound.'; var text = 'Hello, this is a preview of the ' + displayVoice + ' voice. This is how your read-aloud feature will sound.';
var btnPreview = document.getElementById('btn-preview-voice'); var btnPreview = document.getElementById('btn-preview-voice');
@ -191,7 +203,7 @@
var audio = new Audio(url); var audio = new Audio(url);
audio.onended = function() { URL.revokeObjectURL(url); }; audio.onended = function() { URL.revokeObjectURL(url); };
audio.play(); audio.play();
showToast('Preview: ' + voice, 'success'); showToast('Preview: ' + displayVoice, 'success');
}) })
.catch(function(err) { .catch(function(err) {
console.error('[VoicePrefs] Preview error:', err); console.error('[VoicePrefs] Preview error:', err);

View file

@ -12,7 +12,7 @@ var promptRevisions = require('../utils/promptRevisions');
var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation'); var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl, serverError } = require('../utils/errors'); var { gatewayUrl, serverError } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider, voicesForModel, chooseTTS } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
@ -640,61 +640,91 @@ router.post('/config/image-models/test', async function(req, res) {
} }
}); });
// ── GET TTS provider status, voice list, and DB overrides ──────────────── // ── Speech (TTS) ─────────────────────────────────────────────────────────
// Speech models sit on a roster of their own (tts.roster), and the default is
// a model *and* a voice (tts.model, tts.voice) because a voice means nothing
// without the model that accepts it. Users pick from the voices of every
// roster model; read-aloud sends whatever pair they chose and falls back to
// the default pair — see chooseTTS, which is the one place that decision is
// made.
function parseRoster(value) {
return String(value || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
async function readTTSState() {
var roster = parseRoster(await db.getSetting('tts.roster'));
var dbModel = await db.getSetting('tts.model') || '';
var envModel = process.env.LITELLM_TTS_MODEL || '';
var defaultModel = dbModel || envModel;
// A default that predates the roster is on it implicitly; showing it there
// is the honest picture and gives it a Remove button like everything else.
if (defaultModel && roster.indexOf(defaultModel) === -1) roster.unshift(defaultModel);
return {
roster: roster,
defaultModel: defaultModel,
dbModel: dbModel,
envModel: envModel,
dbVoice: await db.getSetting('tts.voice') || '',
envVoice: process.env.LITELLM_TTS_VOICE || ''
};
}
function rosterRows(state) {
var chosen = chooseTTS({ roster: state.roster, defaultModel: state.defaultModel, defaultVoice: state.dbVoice, envVoice: state.envVoice });
return state.roster.map(function(model) {
return {
model: model,
voices: voicesForModel(model),
isDefault: model === chosen.model,
defaultVoice: model === chosen.model ? chosen.voice : ''
};
});
}
router.get('/config/tts', async function(req, res) { router.get('/config/tts', async function(req, res) {
try { try {
var envProvider = getTTSEnvProvider(); var state = await readTTSState();
var activeProvider = getTTSProvider(); var chosen = chooseTTS({ roster: state.roster, defaultModel: state.defaultModel, defaultVoice: state.dbVoice, envVoice: state.envVoice });
var dbVoice = await db.getSetting('tts.voice') || '';
var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || '';
var currentModel = dbModel || envModel;
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
}) || '';
res.json({ res.json({
success: true, success: true,
provider: activeProvider, provider: getTTSProvider(),
envProvider: envProvider, envProvider: getTTSEnvProvider(),
currentVoice: currentVoice, currentVoice: chosen.voice,
currentModel: currentModel, currentModel: chosen.model,
dbVoice: dbVoice, dbVoice: state.dbVoice,
dbModel: dbModel, dbModel: state.dbModel,
envVoice: envVoice, envVoice: state.envVoice,
envModel: envModel, envModel: state.envModel,
roster: rosterRows(state),
configured: { configured: {
litellm: !!process.env.LITELLM_API_BASE litellm: !!process.env.LITELLM_API_BASE
}, },
voices: { voices: {
litellm: voices litellm: voicesForModel(chosen.model)
} }
}); });
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
// ── GET discover TTS voices from provider ──────────────────────────────── // Every speech model the gateway offers, each with its voices and whether it
// is already on the roster.
router.get('/config/tts/discover', async function(req, res) { router.get('/config/tts/discover', async function(req, res) {
try { try {
var search = (req.query.q || '').toLowerCase().trim(); var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios'); var axios = require('axios');
var discovered = []; var discovered = [];
var provider = getTTSProvider(); var provider = getTTSProvider();
if (provider === 'litellm' && process.env.LITELLM_API_BASE) { if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
var dbVoice = await db.getSetting('tts.voice') || ''; var state = await readTTSState();
var dbModel = await db.getSetting('tts.model') || '';
var currentVoice = dbVoice || process.env.LITELLM_TTS_VOICE || '';
var currentModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var modelInfo = []; var modelInfo = [];
try { try {
var lResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 }); var lResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
modelInfo = lResp.data && lResp.data.data ? lResp.data.data : []; modelInfo = lResp.data && lResp.data.data ? lResp.data.data : [];
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); } } catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
getLiteLLMTTSDiscoveryItems(modelInfo, { currentModel: currentModel, currentVoice: currentVoice }).forEach(function(item) { discovered = getLiteLLMTTSDiscoveryItems(modelInfo, { currentModel: state.defaultModel, roster: state.roster })
discovered.push(item); .map(function(item) {
return Object.assign({}, item, { added: state.roster.indexOf(item.id) !== -1, isDefault: item.id === state.defaultModel });
}); });
} }
@ -703,15 +733,41 @@ router.get('/config/tts/discover', async function(req, res) {
return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1; return d.id.toLowerCase().indexOf(search) !== -1 || d.name.toLowerCase().indexOf(search) !== -1;
}); });
} }
res.json({ success: true, provider: provider, voices: discovered, count: discovered.length }); res.json({ success: true, provider: provider, models: discovered, count: discovered.length });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
// The default pair, set together. Setting them one at a time left a window
// where the old voice was paired with the new model — and a voice the model
// refuses is the whole "these settings don't work" bug.
router.put('/config/tts/default', async function(req, res) {
try {
var model = String(req.body.model || '').trim();
var voice = String(req.body.voice || '').trim();
if (!model || model.length > 200 || /[\s<>"'`|,]/.test(model)) return res.status(400).json({ error: 'model is required' });
var known = voicesForModel(model);
if (!voice) voice = known[0] || '';
if (voice && !isLiteLLMTTSVoiceCompatible(model, voice)) {
return res.status(400).json({ error: model + ' does not accept the voice ' + voice + (known.length ? '. It accepts: ' + known.join(', ') : '') });
}
if (voice.length > 200 || /[\s<>"'`|,]/.test(voice)) return res.status(400).json({ error: 'Invalid voice' });
var roster = parseRoster(await db.getSetting('tts.roster'));
if (roster.indexOf(model) === -1) roster.push(model);
await db.setSetting('tts.roster', roster.join(','));
await db.setSetting('tts.model', model);
await db.setSetting('tts.voice', voice);
logger.audit(req.user.id, 'admin_config_update', 'TTS default: ' + model + ' / ' + voice, req, { category: 'admin' });
res.json({ success: true, model: model, voice: voice, roster: rosterRows(await readTTSState()) });
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
// ── POST test TTS — returns base64 audio ───────────────────────────────── // ── POST test TTS — returns base64 audio ─────────────────────────────────
// The admin's test says exactly what it will send. It does not quietly swap in
// a compatible voice: being told "this model refuses that voice" is the
// answer the test exists to give.
router.post('/config/tts/test', async function(req, res) { router.post('/config/tts/test', async function(req, res) {
try { try {
var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500); var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500);
var voice = req.body.voice;
var axios = require('axios'); var axios = require('axios');
var provider = getTTSProvider(); var provider = getTTSProvider();
@ -719,14 +775,16 @@ router.post('/config/tts/test', async function(req, res) {
if (provider !== 'litellm') return res.json({ success: false, error: 'TTS is configured for LiteLLM only' }); if (provider !== 'litellm') return res.json({ success: false, error: 'TTS is configured for LiteLLM only' });
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var adminModel = await db.getSetting('tts.model') || ''; var state = await readTTSState();
var adminVoice = await db.getSetting('tts.voice') || ''; var ttsModel = String(req.body.model || '').trim() || state.defaultModel;
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
}) || '';
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' }); if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
var usedVoice = String(req.body.voice || '').trim();
if (!usedVoice) {
usedVoice = chooseTTS({ roster: state.roster, defaultModel: ttsModel, defaultVoice: ttsModel === state.defaultModel ? state.dbVoice : '', envVoice: state.envVoice }).voice;
} else if (!isLiteLLMTTSVoiceCompatible(ttsModel, usedVoice)) {
var accepts = voicesForModel(ttsModel);
return res.json({ success: false, error: ttsModel + ' does not accept the voice ' + usedVoice + (accepts.length ? '. It accepts: ' + accepts.join(', ') : '') });
}
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel)); var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await axios.post(gatewayUrl('/audio/speech'), var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
@ -734,7 +792,7 @@ router.post('/config/tts/test', async function(req, res) {
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 } { headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
); );
var buffer = Buffer.from(ttsResp.data); var buffer = Buffer.from(ttsResp.data);
res.json({ success: true, audio: buffer.toString('base64'), provider: provider, voice: usedVoice }); res.json({ success: true, audio: buffer.toString('base64'), provider: provider, model: ttsModel, voice: usedVoice });
} catch (e) { } catch (e) {
var detail = e.response && e.response.data var detail = e.response && e.response.data
? (Buffer.isBuffer(e.response.data) ? e.response.data.toString('utf8').substring(0, 300) : JSON.stringify(e.response.data).substring(0, 300)) ? (Buffer.isBuffer(e.response.data) ? e.response.data.toString('utf8').substring(0, 300) : JSON.stringify(e.response.data).substring(0, 300))
@ -988,6 +1046,25 @@ router.put('/config/:key(*)', async function(req, res) {
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' }); if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
return changePrompt(req, res, 'save', key); return changePrompt(req, res, 'save', key);
} }
// The speech roster: comma-separated gateway model ids. tts.model and
// tts.voice are set together through /config/tts/default, never here, so
// a model cannot be paired with a voice it refuses.
if (key === 'tts.model' || key === 'tts.voice') {
return res.status(400).json({ error: 'Set the speech default with PUT /api/admin/config/tts/default' });
}
if (key === 'tts.roster') {
var speechIds = parseRoster(value);
if (speechIds.length > 100 || speechIds.some(function(id) { return id.length > 200 || /[\s<>"'`|]/.test(id); })) {
return res.status(400).json({ error: 'Speech model list must be up to 100 model ids' });
}
// A default that leaves the roster stops being the default, the same
// way an image model leaving its roster leaves every list naming it.
var defaultModel = await db.getSetting('tts.model') || '';
if (defaultModel && speechIds.indexOf(defaultModel) === -1) {
await db.setSetting('tts.model', '');
await db.setSetting('tts.voice', '');
}
}
await db.setSetting(key, String(value)); await db.setSetting(key, String(value));

View file

@ -3,7 +3,7 @@ const router = express.Router();
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getLiteLLMTTSRequestOptions, chooseTTS, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders } = require('../utils/litellm');
// TTS is intentionally routed only through LiteLLM. Provider-specific voice // TTS is intentionally routed only through LiteLLM. Provider-specific voice
@ -16,21 +16,24 @@ router.post('/text-to-speech', authMiddleware, require('../utils/policy').requir
var text = (req.body.text || '').substring(0, 5000); var text = (req.body.text || '').substring(0, 5000);
if (!text) return res.status(400).json({ error: 'No text provided' }); if (!text) return res.status(400).json({ error: 'No text provided' });
// Get user's preferred TTS voice (if set), then fall back to DB admin default, then env // The user's choice is "model|voice" from the roster; a bare voice is a
// choice saved before there was a roster. Either way chooseTTS decides,
// and it is the same decision the admin test and the settings page make.
var db = require('../db/database'); var db = require('../db/database');
var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]); var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]);
var userVoice = userPrefs?.tts_voice;
var adminVoice = await db.getSetting('tts.voice') || '';
var adminModel = await db.getSetting('tts.model') || '';
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) { if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' }); return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
} }
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || ''; var chosen = chooseTTS({
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice }); roster: String(await db.getSetting('tts.roster') || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean),
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) { defaultModel: await db.getSetting('tts.model') || process.env.LITELLM_TTS_MODEL || '',
return isLiteLLMTTSVoiceCompatible(ttsModel, voice); defaultVoice: await db.getSetting('tts.voice') || '',
}) || ''; envVoice: process.env.LITELLM_TTS_VOICE || '',
preferred: userPrefs?.tts_voice
});
var ttsModel = chosen.model;
var ttsVoice = chosen.voice;
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' }); if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel)); var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel));

View file

@ -7,7 +7,7 @@ var router = express.Router();
var db = require('../db/database'); var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth'); var { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider, discoverSTTModels } = require('../utils/sttProvider'); var { getSTTModelLists, getSTTProvider, discoverSTTModels } = require('../utils/sttProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider'); var { rosterVoices, chooseTTS, voiceRef, getTTSProvider } = require('../utils/ttsProvider');
router.use(authMiddleware); router.use(authMiddleware);
@ -54,9 +54,17 @@ router.get('/preferences/options', async function(req, res) {
var provider = getSTTProvider(); var provider = getSTTProvider();
var ttsProvider = getTTSProvider(); var ttsProvider = getTTSProvider();
var dbModel = await db.getSetting('tts.model') || ''; // Every voice of every roster model. A voice is offered together with its
var dbVoice = await db.getSetting('tts.voice') || ''; // model ("model|voice") so read-aloud knows which model to send it to.
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || ''; var ttsRoster = String(await db.getSetting('tts.roster') || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
var ttsDefault = chooseTTS({
roster: ttsRoster,
defaultModel: await db.getSetting('tts.model') || process.env.LITELLM_TTS_MODEL || '',
defaultVoice: await db.getSetting('tts.voice') || '',
envVoice: process.env.LITELLM_TTS_VOICE || ''
});
if (ttsDefault.model && ttsRoster.indexOf(ttsDefault.model) === -1) ttsRoster.unshift(ttsDefault.model);
var ttsModel = ttsDefault.model;
// Offer what the gateway really has. The built-in list is a last resort: // Offer what the gateway really has. The built-in list is a last resort:
// its ids do not resolve on every deployment, and a user who picked one got // its ids do not resolve on every deployment, and a user who picked one got
// "Invalid model name" on every recording, because the user's choice wins // "Invalid model name" on every recording, because the user's choice wins
@ -67,7 +75,10 @@ router.get('/preferences/options', async function(req, res) {
var sttModels = sttIds.map(function(model) { var sttModels = sttIds.map(function(model) {
return { value: model, label: model + (model === adminSttModel ? ' (default)' : '') }; return { value: model, label: model + (model === adminSttModel ? ' (default)' : '') };
}); });
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; }); var defaultRef = voiceRef(ttsDefault.model, ttsDefault.voice);
var ttsVoices = rosterVoices(ttsRoster).map(function(entry) {
return { value: entry.value, label: entry.voice + (entry.value === defaultRef ? ' (default)' : ''), model: entry.model };
});
res.json({ res.json({
success: true, success: true,

View file

@ -24,6 +24,15 @@ var parameters = {
}; };
var operations = { var operations = {
// ── Speech ──────────────────────────────────────────────────────────
'PUT /api/admin/config/tts/default': {
summary: 'Choose the default speech model and voice',
description: 'Sets tts.model and tts.voice together and puts the model on the speech roster (tts.roster) if it is not there. Refused when the model does not accept the voice; the error lists the voices it does accept.',
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['model'], properties: {
model: { type: 'string', description: 'Gateway model id, for example local-kokoro-tts.' },
voice: { type: 'string', description: 'A voice of that model. Omitted: its first voice.' }
} } } } }
},
// ── Session ───────────────────────────────────────────────────────── // ── Session ─────────────────────────────────────────────────────────
'POST /api/auth/login': { 'POST /api/auth/login': {
summary: 'Sign in with a password', summary: 'Sign in with a password',

View file

@ -154,38 +154,101 @@ function getLiteLLMTTSModels(models) {
function pushUniqueTTSItem(items, item) { function pushUniqueTTSItem(items, item) {
if (!item || !item.id) return; if (!item || !item.id) return;
if (items.some(function(existing) { return existing.id === item.id && existing.kind === item.kind; })) return; if (items.some(function(existing) { return existing.id === item.id; })) return;
items.push(item); items.push(item);
} }
/**
* The speech models on offer, each carrying the voices it accepts.
*
* Discovery used to list models and voices side by side in one flat list
* twelve Orpheus voices, six Kokoro ones and a "configured-voice" row that
* had lost the model it belonged to, all with a Make default button. A voice
* is a property of a model, so it is listed under one: the screen adds a
* model to the roster and the voices come with it.
*/
function getLiteLLMTTSDiscoveryItems(models, opts) { function getLiteLLMTTSDiscoveryItems(models, opts) {
opts = opts || {}; opts = opts || {};
var items = []; var items = [];
getLiteLLMTTSModels(models).forEach(function(id) { getLiteLLMTTSModels(models).forEach(function(id) {
pushUniqueTTSItem(items, { id: id, name: id, source: 'gateway-api', kind: 'model' }); pushUniqueTTSItem(items, { id: id, name: id, source: 'gateway-api', kind: 'model', voices: voicesForModel(id) });
});
if (opts.currentModel) {
pushUniqueTTSItem(items, { id: opts.currentModel, name: opts.currentModel, source: 'configured-model', kind: 'model' });
}
if (opts.currentVoice) {
pushUniqueTTSItem(items, { id: opts.currentVoice, name: opts.currentVoice, source: 'configured-voice', kind: 'voice' });
}
// Every voice now says which model it belongs to. They used to be pushed
// into one flat list, so the screen offered twelve Orpheus voices and six
// Kokoro ones together with no way to tell which model would accept which.
var offered = getLiteLLMTTSModels(models).slice();
if (opts.currentModel && offered.indexOf(opts.currentModel) === -1) offered.push(opts.currentModel);
offered.forEach(function(model) {
voicesForModel(model).forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: voice, source: model, kind: 'voice', model: model });
}); });
// The default and everything on the roster are still models even when the
// gateway's metadata call failed, or when the gateway stopped advertising
// one that is still routable.
[opts.currentModel].concat(opts.roster || []).forEach(function(id) {
if (!id) return;
pushUniqueTTSItem(items, { id: id, name: id, source: 'configured-model', kind: 'model', voices: voicesForModel(id) });
}); });
return items; return items;
} }
// ── Roster ───────────────────────────────────────────────────────────────
// A voice is only meaningful together with its model, so a stored choice —
// the admin default, a user's preference — names both. The pair travels as
// "model|voice": "|" appears in no gateway id and no voice name, unlike ":"
// which Kokoro uses (sherpa/kokoro:af_bella) and "/" which every upstream id
// does. A bare voice with no "|" is a value saved before there was a roster,
// and is read as a voice of the default model.
function voiceRef(model, voice) {
if (!model || !voice) return '';
return String(model) + '|' + String(voice);
}
function parseVoiceRef(value) {
var text = typeof value === 'string' ? value.trim() : '';
if (!text) return { model: '', voice: '' };
var at = text.indexOf('|');
if (at === -1) return { model: '', voice: text };
return { model: text.slice(0, at).trim(), voice: text.slice(at + 1).trim() };
}
/** Every voice of every roster model, in roster order, as picker options. */
function rosterVoices(roster) {
var out = [];
uniqueList(roster).forEach(function(model) {
voicesForModel(model).forEach(function(voice) {
out.push({ model: model, voice: voice, value: voiceRef(model, voice) });
});
});
return out;
}
/**
* The model and voice a request will use.
*
* One decision for the read-aloud route, the admin test and the settings
* page, so they cannot disagree. `preferred` is a voice ref (or a bare legacy
* voice) and wins when it names a roster model and a voice that model accepts;
* anything else falls through to the default pair, and the default's voice
* falls through to the first voice its model has. The one thing this never
* does is send a voice to a model that will refuse it.
*/
function chooseTTS(opts) {
opts = opts || {};
var roster = uniqueList([opts.defaultModel].concat(opts.roster || []));
var want = parseVoiceRef(opts.preferred);
if (want.voice) {
var model = want.model || opts.defaultModel || '';
if (roster.indexOf(model) !== -1 && isLiteLLMTTSVoiceCompatible(model, want.voice)) {
return { model: model, voice: want.voice };
}
}
var fallback = opts.defaultModel || '';
var voice = [opts.defaultVoice, opts.envVoice].concat(voicesForModel(fallback)).find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(fallback, candidate);
}) || '';
return { model: fallback, voice: voice };
}
module.exports = { module.exports = {
voicesForModel, voicesForModel,
MODEL_VOICES, MODEL_VOICES,
voiceRef,
parseVoiceRef,
rosterVoices,
chooseTTS,
getTTSEnvProvider, getTTSEnvProvider,
getLiteLLMTTSDiscoveryItems, getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders, getLiteLLMHeaders,

View file

@ -77,54 +77,56 @@ test('LiteLLM TTS model extraction filters gateway model objects', () => {
]), ['local-chatterbox-turbo', 'custom-provider-model']); ]), ['local-chatterbox-turbo', 'custom-provider-model']);
}); });
test('LiteLLM TTS discovery includes metadata models and configured fallbacks', () => { test('LiteLLM TTS discovery lists models, each carrying its own voices', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => { withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([ const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } }, { model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } },
{ model_name: 'not-tts-by-name-only' }, { model_name: 'not-tts-by-name-only' },
{ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } } { model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
], { { model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } }
currentModel: 'local-kokoro-tts', ], { currentModel: 'local-kokoro-tts', roster: ['local-kokoro-tts'] });
currentVoice: 'sherpa/kokoro:am_adam' // No flat voice rows: a voice appears only under the model that accepts
}); // it. The screen adds a model and its voices come with it.
assert.deepEqual(items.slice(0, 3), [ assert.deepEqual(items.map(i => i.kind), ['model', 'model']);
{ id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model' }, assert.deepEqual(items[0], { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model', voices: ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella'] });
{ id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' }, assert.deepEqual(items[1].voices, ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
// A voice now says which model it belongs to. It used to say
// "configured-voice-list", which named the file it came from and not the
// model that would accept it — the distinction the screen was missing.
{ id: 'sherpa/kokoro:af_bella', name: 'sherpa/kokoro:af_bella', source: 'local-kokoro-tts', kind: 'voice', model: 'local-kokoro-tts' }
]);
assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false); assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false);
}); });
}); });
test('LiteLLM TTS discovery expands the voices of a model it can see', () => { test('LiteLLM TTS discovery still lists the default and the roster if the metadata lookup fails', () => {
// Models are discovered from /model/info; voices are not. No TTS provider
// exposes its voice list consistently, so each family's voices are curated
// here — a list guessed from a model id is how a picker ends up offering a
// voice the provider rejects. Kitten and Supertonic were retired from the
// gateway in favour of Kokoro, and their lists went with them.
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
withEnv({}, () => { withEnv({}, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([ assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
{ model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } } currentModel: 'local-kokoro-tts',
], {}); roster: ['local-kokoro-tts', 'openrouter-fish-s2.1-pro-tts']
assert.equal(items.some(function(item) { return item.id === 'groq-orpheus-english' && item.kind === 'model'; }), true); }).map(i => [i.id, i.source, i.voices.length]), [
assert.equal(items.some(function(item) { return item.id === 'hannah' && item.kind === 'voice'; }), true); ['local-kokoro-tts', 'configured-model', 6],
['openrouter-fish-s2.1-pro-tts', 'configured-model', 1]
]);
}); });
}); });
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => { test('chooseTTS sends a voice only to a roster model that accepts it, else the default pair', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const { chooseTTS, voiceRef, parseVoiceRef, rosterVoices } = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], { withEnv({}, () => {
currentModel: 'local-kokoro-tts', const base = { roster: ['local-kokoro-tts', 'groq-orpheus-english'], defaultModel: 'local-kokoro-tts', defaultVoice: 'sherpa/kokoro:af_bella' };
currentVoice: 'sherpa/kokoro:am_adam' // A user's pick names its model, so Orpheus gets an Orpheus voice.
}).slice(0, 2), [ assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'groq-orpheus-english|hannah' })), { model: 'groq-orpheus-english', voice: 'hannah' });
{ id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'configured-model', kind: 'model' }, // A voice the named model refuses, or a model that left the roster, falls back to the default pair.
{ id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' } assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'groq-orpheus-english|sherpa/kokoro:af_bella' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
]); assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'openrouter-fish-s2.1-pro-tts|alloy' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
// A bare voice saved before there was a roster is a voice of the default model.
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'sherpa/kokoro:am_adam' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:am_adam' });
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'hannah' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
// A default voice the default model refuses is not sent either.
assert.deepEqual(chooseTTS({ roster: [], defaultModel: 'groq-orpheus-english', defaultVoice: 'sherpa/kokoro:af_bella' }), { model: 'groq-orpheus-english', voice: 'autumn' });
assert.deepEqual(chooseTTS({ roster: [], defaultModel: '' }), { model: '', voice: '' });
// The reference survives Kokoro's colon and every upstream slash.
assert.deepEqual(parseVoiceRef(voiceRef('local-kokoro-tts', 'sherpa/kokoro:af_bella')), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
assert.deepEqual(rosterVoices(['openrouter-fish-s2.1-pro-tts', 'openrouter-fish-s2.1-pro-tts']), [{ model: 'openrouter-fish-s2.1-pro-tts', voice: 'alloy', value: 'openrouter-fish-s2.1-pro-tts|alloy' }]);
});
}); });
test('LiteLLM TTS voices are scoped to the active local model', () => { test('LiteLLM TTS voices are scoped to the active local model', () => {