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>
<div class="admin-card-body">
<!-- One search for every kind of model. The kind decides which
gateway list is asked and what the row buttons do: chat and
image models are added to a roster; speech, transcription and
transcription models are single defaults, so their button says
Make default rather than Add: there is one of each, and it does
not join the Roster below. "Set" read like "add to the list",
which is the one thing it does not do. -->
gateway list is asked and what the row buttons do: chat, image
and speech models are added to a roster; transcription is a
single default, so its button says Make default rather than
Add: there is one, and it does not join the Roster below. "Set"
read like "add to the list", which is the one thing it does not
do. -->
<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-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>
<span id="admin-tts-provider-badge" class="admin-badge">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 class="admin-kind-panel admin-kind-status" data-kind="stt" hidden>
<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>
<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;">
<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>
<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>
@ -338,7 +341,13 @@
<p class="admin-note">Loading...</p>
</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>
</details>

View file

@ -1280,19 +1280,38 @@ initImageSettings();
// ============================================================
// 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) {
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
});
// Catch-up for a tab that is already active and loaded at module init.
if (adminTabActive()) loadTTSConfig();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-test-tts')) testTTS();
if (e.target.closest('.admin-tts-set-btn')) {
var btn = e.target.closest('.admin-tts-set-btn');
setTTSDefault(btn.dataset.id, btn.dataset.type, btn);
if (e.target.closest('#btn-test-tts')) { testTTS(); return; }
var toggle = e.target.closest('.admin-tts-add-btn, .admin-tts-remove-btn');
if (toggle) { toggleTTSRoster(toggle.dataset.id, toggle); return; }
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) {
if (e.detail && e.detail.kind === 'tts') discoverTTS();
});
@ -1304,6 +1323,8 @@ initImageSettings();
.then(function(r) { return r.json(); })
.then(function(data) {
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');
if (badge) {
badge.textContent = (data.provider || 'none').toUpperCase();
@ -1314,32 +1335,144 @@ initImageSettings();
if (info) {
var parts = [];
if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider);
if (data.dbVoice) parts.push('DB voice: ' + data.dbVoice);
else if (data.envVoice) parts.push('Env voice: ' + data.envVoice);
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
else if (data.envModel) parts.push('Env model: ' + data.envModel);
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);
});
parts.push(ttsRoster.length + ' model' + (ttsRoster.length === 1 ? '' : 's') + ' on the roster');
if (data.currentModel) parts.push('Default: ' + data.currentModel + (data.currentVoice ? ' / ' + data.currentVoice : '') + (data.dbModel ? '' : ' (from env)'));
else parts.push('No default chosen');
info.textContent = parts.join(' · ');
}
renderTTSRoster();
fillTTSTestModels();
syncTTSRows();
})
.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() {
var search = (document.getElementById('admin-discover-search') || {}).value || '';
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>';
return;
}
var items = data.voices || [];
var items = data.models || [];
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;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
items.slice(0, 100).map(function(v) {
var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
var setType = isModel ? 'model' : 'voice';
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;">' +
'<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;">' + esc(v.name) + badge + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
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.map(function(m) {
var voices = Array.isArray(m.voices) ? m.voices : [];
var voiceNote = voices.length ? voices.length + ' voice' + (voices.length === 1 ? '' : 's') + ': ' + voices.join(', ') : 'no voice list — the model chooses';
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;">' +
'<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>' +
'<span style="flex:1;min-width:160px;overflow-wrap:anywhere;">' + esc(m.name) +
'<span style="display:block;font-size:11px;color:var(--g500);">' + esc(voiceNote) + '</span></span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
ttsAddButton(m.id) +
'</div>';
}).join('');
})
@ -1377,46 +1511,69 @@ initImageSettings();
});
}
function setTTSDefault(id, type, btn) {
var key = type === 'model' ? 'tts.model' : 'tts.voice';
var origText = btn ? btn.textContent : '';
adminSetButtonText(btn, '...', true);
// ── Test ─────────────────────────────────────────────────────
// The model list is the roster. Test on a discovered model that is not on
// it yet adds a temporary entry, so a model can be heard before it is added.
function fillTTSTestModels(keep) {
var sel = document.getElementById('admin-tts-model');
if (!sel) return;
var current = keep || sel.value || ttsDefault.model;
sel.replaceChildren();
ttsRoster.forEach(function(entry) {
var opt = document.createElement('option');
opt.value = entry.model;
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);
}
if (current && Array.from(sel.options).some(function(o) { return o.value === current; })) sel.value = current;
fillTTSTestVoices();
}
fetch('/api/admin/config/' + encodeURIComponent(key), {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ value: id })
})
.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');
opt.value = id; opt.textContent = id + ' (active)';
voiceSel.insertBefore(opt, voiceSel.firstChild);
}
voiceSel.value = id;
}
loadTTSConfig();
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() {
adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error');
function pickTTSModelForTest(id, voices) {
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;
fillTTSTestVoices();
var panel = document.getElementById('admin-tts-test-text');
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() {
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 btn = document.getElementById('btn-test-tts');
var resultEl = document.getElementById('admin-tts-result');
@ -1428,7 +1585,7 @@ initImageSettings();
fetch('/api/admin/config/tts/test', {
method: 'POST',
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(data) {
@ -1443,7 +1600,7 @@ initImageSettings();
audioEl.style.display = 'inline-block';
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) {
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');
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
var groups = {};
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');
opt.value = voice.value;
opt.textContent = voice.label;
ttsSelect.appendChild(opt);
parent.appendChild(opt);
});
}
})
@ -159,7 +171,7 @@
var voice = ttsSelect ? ttsSelect.value : null;
// 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 btnPreview = document.getElementById('btn-preview-voice');
@ -191,7 +203,7 @@
var audio = new Audio(url);
audio.onended = function() { URL.revokeObjectURL(url); };
audio.play();
showToast('Preview: ' + voice, 'success');
showToast('Preview: ' + displayVoice, 'success');
})
.catch(function(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 logger = require('../utils/logger');
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 { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
@ -640,62 +640,92 @@ 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) {
try {
var envProvider = getTTSEnvProvider();
var activeProvider = getTTSProvider();
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);
}) || '';
var state = await readTTSState();
var chosen = chooseTTS({ roster: state.roster, defaultModel: state.defaultModel, defaultVoice: state.dbVoice, envVoice: state.envVoice });
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentVoice: currentVoice,
currentModel: currentModel,
dbVoice: dbVoice,
dbModel: dbModel,
envVoice: envVoice,
envModel: envModel,
provider: getTTSProvider(),
envProvider: getTTSEnvProvider(),
currentVoice: chosen.voice,
currentModel: chosen.model,
dbVoice: state.dbVoice,
dbModel: state.dbModel,
envVoice: state.envVoice,
envModel: state.envModel,
roster: rosterRows(state),
configured: {
litellm: !!process.env.LITELLM_API_BASE
},
voices: {
litellm: voices
litellm: voicesForModel(chosen.model)
}
});
} 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) {
try {
var search = (req.query.q || '').toLowerCase().trim();
var axios = require('axios');
var discovered = [];
var provider = getTTSProvider();
if (provider === 'litellm' && process.env.LITELLM_API_BASE) {
var dbVoice = await db.getSetting('tts.voice') || '';
var dbModel = await db.getSetting('tts.model') || '';
var currentVoice = dbVoice || process.env.LITELLM_TTS_VOICE || '';
var currentModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var state = await readTTSState();
var modelInfo = [];
try {
var lResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 });
modelInfo = lResp.data && lResp.data.data ? lResp.data.data : [];
} catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); }
getLiteLLMTTSDiscoveryItems(modelInfo, { currentModel: currentModel, currentVoice: currentVoice }).forEach(function(item) {
discovered.push(item);
});
discovered = getLiteLLMTTSDiscoveryItems(modelInfo, { currentModel: state.defaultModel, roster: state.roster })
.map(function(item) {
return Object.assign({}, item, { added: state.roster.indexOf(item.id) !== -1, isDefault: item.id === state.defaultModel });
});
}
if (search) {
@ -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;
});
}
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' }); }
});
// ── 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) {
try {
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 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 (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || '';
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);
}) || '';
var state = await readTTSState();
var ttsModel = String(req.body.model || '').trim() || state.defaultModel;
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 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 }
);
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) {
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))
@ -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' });
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));

View file

@ -3,7 +3,7 @@ const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
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');
// 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);
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 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) {
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
}
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(ttsModel, voice);
}) || '';
var chosen = chooseTTS({
roster: String(await db.getSetting('tts.roster') || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean),
defaultModel: await db.getSetting('tts.model') || process.env.LITELLM_TTS_MODEL || '',
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.' });
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 { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider, discoverSTTModels } = require('../utils/sttProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
var { rosterVoices, chooseTTS, voiceRef, getTTSProvider } = require('../utils/ttsProvider');
router.use(authMiddleware);
@ -54,9 +54,17 @@ router.get('/preferences/options', async function(req, res) {
var provider = getSTTProvider();
var ttsProvider = getTTSProvider();
var dbModel = await db.getSetting('tts.model') || '';
var dbVoice = await db.getSetting('tts.voice') || '';
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
// Every voice of every roster model. A voice is offered together with its
// model ("model|voice") so read-aloud knows which model to send it to.
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:
// 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
@ -67,7 +75,10 @@ router.get('/preferences/options', async function(req, res) {
var sttModels = sttIds.map(function(model) {
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({
success: true,

View file

@ -24,6 +24,15 @@ var parameters = {
};
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 ─────────────────────────────────────────────────────────
'POST /api/auth/login': {
summary: 'Sign in with a password',

View file

@ -154,38 +154,101 @@ function getLiteLLMTTSModels(models) {
function pushUniqueTTSItem(items, item) {
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);
}
/**
* 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) {
opts = opts || {};
var items = [];
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;
}
// ── 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 = {
voicesForModel,
MODEL_VOICES,
voiceRef,
parseVoiceRef,
rosterVoices,
chooseTTS,
getTTSEnvProvider,
getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders,

View file

@ -77,54 +77,56 @@ test('LiteLLM TTS model extraction filters gateway model objects', () => {
]), ['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');
withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } },
{ model_name: 'not-tts-by-name-only' },
{ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }
], {
currentModel: 'local-kokoro-tts',
currentVoice: 'sherpa/kokoro:am_adam'
});
assert.deepEqual(items.slice(0, 3), [
{ id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model' },
{ id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' },
// 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' }
]);
{ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
{ model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } }
], { currentModel: 'local-kokoro-tts', roster: ['local-kokoro-tts'] });
// 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.map(i => i.kind), ['model', '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'] });
assert.deepEqual(items[1].voices, ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
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', () => {
// 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.
test('LiteLLM TTS discovery still lists the default and the roster if the metadata lookup fails', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({}, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } }
], {});
assert.equal(items.some(function(item) { return item.id === 'groq-orpheus-english' && item.kind === 'model'; }), true);
assert.equal(items.some(function(item) { return item.id === 'hannah' && item.kind === 'voice'; }), true);
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
currentModel: 'local-kokoro-tts',
roster: ['local-kokoro-tts', 'openrouter-fish-s2.1-pro-tts']
}).map(i => [i.id, i.source, i.voices.length]), [
['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', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
currentModel: 'local-kokoro-tts',
currentVoice: 'sherpa/kokoro:am_adam'
}).slice(0, 2), [
{ id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'configured-model', kind: 'model' },
{ id: 'sherpa/kokoro:am_adam', name: 'sherpa/kokoro:am_adam', source: 'configured-voice', kind: 'voice' }
]);
test('chooseTTS sends a voice only to a roster model that accepts it, else the default pair', () => {
const { chooseTTS, voiceRef, parseVoiceRef, rosterVoices } = require('../src/utils/ttsProvider');
withEnv({}, () => {
const base = { roster: ['local-kokoro-tts', 'groq-orpheus-english'], defaultModel: 'local-kokoro-tts', defaultVoice: 'sherpa/kokoro:af_bella' };
// A user's pick names its model, so Orpheus gets an Orpheus voice.
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'groq-orpheus-english|hannah' })), { model: 'groq-orpheus-english', voice: 'hannah' });
// A voice the named model refuses, or a model that left the roster, falls back to the default pair.
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', () => {