+ 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. -->
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 = '
';
return;
}
- var items = data.voices || [];
+ var items = data.models || [];
if (items.length === 0) {
- container.innerHTML = '
' +
- 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 ? '
' +
- '
Make default ' +
- '
' + 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 '
' +
+ 'Test ' +
+ '' + 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 = '
Server default (' + data.ttsProvider + ') ';
+ 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', () => {