diff --git a/public/components/admin.html b/public/components/admin.html index d6025a9b..bb2ab01e 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -223,12 +223,12 @@
+ 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. -->
@@ -248,7 +248,7 @@ -

Speech and transcription are not on this roster. There is one voice and one model for each, chosen with Make default under Discover & test.

+

Speech models

+

Added with + Add under Discover & test. Users choose a voice from every model here. Pick a voice on a row and press Make default for the pair Read Aloud uses when nobody chose.

+
+

Loading...

+
+ +

Transcription is not on this roster. There is one model, chosen with Make default under Discover & test.

diff --git a/public/js/admin.js b/public/js/admin.js index adcf0b47..9191d75a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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 + ? '' + : ''; + } + + // 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 = '

Error: ' + esc(data.error || 'Unknown') + '

'; return; } - var items = data.voices || []; + var items = data.models || []; if (items.length === 0) { - container.innerHTML = '

No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; + container.innerHTML = '

No speech models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; return; } - container.innerHTML = '

Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')

' + - 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 ? 'MODEL' : 'VOICE'; - return '
' + - '' + - '' + esc(v.name) + badge + '' + - '' + esc(v.source || '') + '' + + container.innerHTML = '

Found ' + data.count + ' speech model' + (data.count === 1 ? '' : 's') + ' (provider: ' + esc(data.provider) + ')

' + + 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 '
' + + '' + + '' + esc(m.name) + + '' + esc(voiceNote) + '' + + '' + esc(m.source || '') + '' + + ttsAddButton(m.id) + '
'; }).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, ' Synthesize & Play', false); diff --git a/public/js/voicePreferences.js b/public/js/voicePreferences.js index 1e7e2037..f4c22fcc 100644 --- a/public/js/voicePreferences.js +++ b/public/js/voicePreferences.js @@ -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 = ''; + 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); diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 890e27db..d83dfdc4 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -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)); diff --git a/src/routes/tts.js b/src/routes/tts.js index 4bf3e009..f468837c 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -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)); diff --git a/src/routes/userPreferences.js b/src/routes/userPreferences.js index 7c2b0a47..6b489537 100644 --- a/src/routes/userPreferences.js +++ b/src/routes/userPreferences.js @@ -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, diff --git a/src/utils/openapiRoutes.js b/src/utils/openapiRoutes.js index b17faecd..bac1fa2b 100644 --- a/src/utils/openapiRoutes.js +++ b/src/utils/openapiRoutes.js @@ -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', diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js index bf07de06..e7204e87 100644 --- a/src/utils/ttsProvider.js +++ b/src/utils/ttsProvider.js @@ -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, diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js index 03e8789f..e40b916f 100644 --- a/test/tts-provider.test.js +++ b/test/tts-provider.test.js @@ -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', () => {