feat: speech models have a roster; each brings its voices, users choose across all of them
Discover lists speech models with the voices each accepts and a + Add that puts the model on tts.roster. The Roster card lists every model with a voice picker, Test, Make default and Remove. Test on any row (or a discovered model not yet added) fills the test panel's voice list with that model's voices, so Orpheus and Kokoro can be heard one voice at a time before either is chosen. The default is a pair — PUT /config/tts/default sets tts.model and tts.voice together and refuses a voice the model does not accept, naming the ones it does. The generic setter no longer takes tts.model/tts.voice one at a time, which is how a Kokoro voice got paired with Orpheus. A default that leaves the roster stops being the default. Users pick from the voices of every roster model, grouped by model in Settings; the stored value is "model|voice" so read-aloud sends the voice to the model that accepts it. A bare voice saved before there was a roster is read as a voice of the default model. chooseTTS is the one place the pair is decided, shared by read-aloud, the admin test and the settings options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
8a6a4df121
commit
07161c6fa8
9 changed files with 534 additions and 191 deletions
|
|
@ -223,12 +223,12 @@
|
|||
</summary>
|
||||
<div class="admin-card-body">
|
||||
<!-- One search for every kind of model. The kind decides which
|
||||
gateway list is asked and what the row buttons do: chat and
|
||||
image models are added to a roster; speech, transcription and
|
||||
transcription models are single defaults, so their button says
|
||||
Make default rather than Add: there is one of each, and it does
|
||||
not join the Roster below. "Set" read like "add to the list",
|
||||
which is the one thing it does not do. -->
|
||||
gateway list is asked and what the row buttons do: chat, image
|
||||
and speech models are added to a roster; transcription is a
|
||||
single default, so its button says Make default rather than
|
||||
Add: there is one, and it does not join the Roster below. "Set"
|
||||
read like "add to the list", which is the one thing it does not
|
||||
do. -->
|
||||
<div class="admin-kind-switch" role="group" aria-label="Kind of model">
|
||||
<button type="button" class="admin-discover-kind" id="admin-discover-kind-chat" data-kind="chat" aria-pressed="true"><i class="fas fa-comments"></i> Chat</button>
|
||||
<button type="button" class="admin-discover-kind" id="admin-discover-kind-image" data-kind="image" aria-pressed="false"><i class="fas fa-image"></i> Image</button>
|
||||
|
|
@ -248,7 +248,7 @@
|
|||
<div class="admin-kind-panel admin-kind-status" data-kind="tts" hidden>
|
||||
<span id="admin-tts-provider-badge" class="admin-badge">Loading...</span>
|
||||
<span id="admin-tts-info" class="admin-note">Loading...</span>
|
||||
<span class="admin-note">There is one voice and one model for Read Aloud. <strong>Make default</strong> chooses them; nothing is added to a list.</span>
|
||||
<span class="admin-note">Each speech model brings its own voices. <strong>+ Add</strong> puts a model on the speech roster; users then choose from the voices of every model on it. The default pair is chosen on the roster below.</span>
|
||||
</div>
|
||||
<div class="admin-kind-panel admin-kind-status" data-kind="stt" hidden>
|
||||
<span id="admin-stt-provider-badge" class="admin-badge">Loading...</span>
|
||||
|
|
@ -285,7 +285,10 @@
|
|||
<div id="admin-image-test-result" role="status" class="admin-note" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
<div class="admin-kind-panel" data-kind="tts" hidden>
|
||||
<p class="admin-note" style="margin-bottom:8px;">Press Test on a model above or on the roster; its voices fill the list. Try them one by one.</p>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
|
||||
<label for="admin-tts-model" style="font-size:13px;font-weight:600;min-width:80px;">Model:</label>
|
||||
<select id="admin-tts-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select>
|
||||
<label for="admin-tts-voice" style="font-size:13px;font-weight:600;min-width:80px;">Voice:</label>
|
||||
<select id="admin-tts-voice" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:280px;"></select>
|
||||
</div>
|
||||
|
|
@ -338,7 +341,13 @@
|
|||
<p class="admin-note">Loading...</p>
|
||||
</div>
|
||||
|
||||
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Speech and transcription are not on this roster. There is one voice and one model for each, chosen with <strong>Make default</strong> under Discover & test.</p>
|
||||
<h4>Speech models</h4>
|
||||
<p class="admin-note">Added with <strong>+ Add</strong> under Discover & test. Users choose a voice from every model here. Pick a voice on a row and press <strong>Make default</strong> for the pair Read Aloud uses when nobody chose.</p>
|
||||
<div id="admin-tts-roster" style="display:flex;flex-direction:column;gap:4px;">
|
||||
<p class="admin-note">Loading...</p>
|
||||
</div>
|
||||
|
||||
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Transcription is not on this roster. There is one model, chosen with <strong>Make default</strong> under Discover & test.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -1280,19 +1280,38 @@ initImageSettings();
|
|||
// ============================================================
|
||||
// ADMIN TTS MANAGEMENT
|
||||
// ============================================================
|
||||
// Speech models have a roster of their own, like image models: + Add under
|
||||
// Discover puts one on it (tts.roster), the Roster card lists each with its
|
||||
// voices, and Make default on a row chooses the pair Read Aloud uses when a
|
||||
// user has not picked. Users pick from the voices of every roster model. A
|
||||
// voice only ever appears under the model that accepts it — the flat list of
|
||||
// "Make default" voices this replaced offered Orpheus voices for Kokoro and
|
||||
// every one of those was a 500.
|
||||
{
|
||||
var ttsRoster = []; // [{ model, voices, isDefault, defaultVoice }]
|
||||
var ttsDefault = { model: '', voice: '' };
|
||||
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
|
||||
});
|
||||
// Catch-up for a tab that is already active and loaded at module init.
|
||||
if (adminTabActive()) loadTTSConfig();
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('#btn-test-tts')) testTTS();
|
||||
if (e.target.closest('.admin-tts-set-btn')) {
|
||||
var btn = e.target.closest('.admin-tts-set-btn');
|
||||
setTTSDefault(btn.dataset.id, btn.dataset.type, btn);
|
||||
if (e.target.closest('#btn-test-tts')) { testTTS(); return; }
|
||||
var toggle = e.target.closest('.admin-tts-add-btn, .admin-tts-remove-btn');
|
||||
if (toggle) { toggleTTSRoster(toggle.dataset.id, toggle); return; }
|
||||
var pick = e.target.closest('.admin-tts-test-btn');
|
||||
if (pick) { pickTTSModelForTest(pick.dataset.id, (pick.dataset.voices || '').split(',').filter(Boolean)); return; }
|
||||
var def = e.target.closest('.admin-tts-default-btn');
|
||||
if (def) {
|
||||
var row = def.closest('[data-model]');
|
||||
var voiceSel = row && row.querySelector('select');
|
||||
setTTSDefault(def.dataset.id, voiceSel ? voiceSel.value : '', def);
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.id === 'admin-tts-model') fillTTSTestVoices();
|
||||
});
|
||||
document.addEventListener('admin-discover', function(e) {
|
||||
if (e.detail && e.detail.kind === 'tts') discoverTTS();
|
||||
});
|
||||
|
|
@ -1304,6 +1323,8 @@ initImageSettings();
|
|||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
ttsRoster = Array.isArray(data.roster) ? data.roster : [];
|
||||
ttsDefault = { model: data.currentModel || '', voice: data.currentVoice || '' };
|
||||
var badge = document.getElementById('admin-tts-provider-badge');
|
||||
if (badge) {
|
||||
badge.textContent = (data.provider || 'none').toUpperCase();
|
||||
|
|
@ -1314,32 +1335,144 @@ initImageSettings();
|
|||
if (info) {
|
||||
var parts = [];
|
||||
if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider);
|
||||
if (data.dbVoice) parts.push('DB voice: ' + data.dbVoice);
|
||||
else if (data.envVoice) parts.push('Env voice: ' + data.envVoice);
|
||||
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
|
||||
else if (data.envModel) parts.push('Env model: ' + data.envModel);
|
||||
var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; });
|
||||
if (configured.length) parts.push('Configured: ' + configured.join(', '));
|
||||
info.textContent = parts.join(' · ') || 'Auto-detected from env';
|
||||
}
|
||||
var voiceSel = document.getElementById('admin-tts-voice');
|
||||
if (voiceSel) {
|
||||
voiceSel.innerHTML = '';
|
||||
var voices = (data.voices && data.voices[data.provider]) || [];
|
||||
if (data.currentVoice && voices.indexOf(data.currentVoice) === -1) voices = [data.currentVoice].concat(voices);
|
||||
if (voices.length === 0) voices = ['default'];
|
||||
voices.forEach(function(v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = v + (v === data.currentVoice ? ' (active)' : '');
|
||||
if (v === data.currentVoice) opt.selected = true;
|
||||
voiceSel.appendChild(opt);
|
||||
});
|
||||
parts.push(ttsRoster.length + ' model' + (ttsRoster.length === 1 ? '' : 's') + ' on the roster');
|
||||
if (data.currentModel) parts.push('Default: ' + data.currentModel + (data.currentVoice ? ' / ' + data.currentVoice : '') + (data.dbModel ? '' : ' (from env)'));
|
||||
else parts.push('No default chosen');
|
||||
info.textContent = parts.join(' · ');
|
||||
}
|
||||
renderTTSRoster();
|
||||
fillTTSTestModels();
|
||||
syncTTSRows();
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
// ── Roster card ──────────────────────────────────────────────
|
||||
function renderTTSRoster() {
|
||||
var container = document.getElementById('admin-tts-roster');
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
if (!ttsRoster.length) {
|
||||
var empty = document.createElement('p');
|
||||
empty.className = 'admin-note';
|
||||
empty.textContent = 'No speech models added yet. Search for one under Discover & test and press + Add.';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
ttsRoster.forEach(function(entry) {
|
||||
var row = document.createElement('div');
|
||||
row.dataset.model = entry.model;
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;';
|
||||
var name = document.createElement('span');
|
||||
name.style.cssText = 'flex:1;min-width:120px;overflow-wrap:anywhere;';
|
||||
name.textContent = entry.model;
|
||||
if (entry.isDefault) {
|
||||
var badge = document.createElement('span');
|
||||
badge.style.cssText = 'font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:6px;vertical-align:middle;';
|
||||
badge.textContent = 'DEFAULT';
|
||||
name.appendChild(badge);
|
||||
}
|
||||
var voiceSel = document.createElement('select');
|
||||
voiceSel.setAttribute('aria-label', 'Default voice for ' + entry.model);
|
||||
voiceSel.style.cssText = 'font-size:12px;padding:3px 6px;border:1px solid var(--g300);border-radius:6px;max-width:220px;';
|
||||
var voices = entry.voices && entry.voices.length ? entry.voices : [];
|
||||
if (!voices.length) {
|
||||
var none = document.createElement('option');
|
||||
none.value = ''; none.textContent = 'no voice list — model default';
|
||||
voiceSel.appendChild(none);
|
||||
}
|
||||
voices.forEach(function(v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v; opt.textContent = v;
|
||||
if (entry.isDefault && v === entry.defaultVoice) opt.selected = true;
|
||||
voiceSel.appendChild(opt);
|
||||
});
|
||||
var test = document.createElement('button');
|
||||
test.type = 'button';
|
||||
test.className = 'btn-sm btn-ghost admin-tts-test-btn';
|
||||
test.dataset.id = entry.model;
|
||||
test.dataset.voices = voices.join(',');
|
||||
test.style.cssText = 'padding:2px 8px;font-size:11px;';
|
||||
test.textContent = 'Test';
|
||||
var def = document.createElement('button');
|
||||
def.type = 'button';
|
||||
def.className = 'btn-sm btn-primary admin-tts-default-btn';
|
||||
def.dataset.id = entry.model;
|
||||
def.style.cssText = 'padding:2px 8px;font-size:11px;white-space:nowrap;';
|
||||
def.textContent = 'Make default';
|
||||
var remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'btn-sm admin-tts-remove-btn';
|
||||
remove.dataset.id = entry.model;
|
||||
remove.style.cssText = 'padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;';
|
||||
remove.textContent = 'Remove';
|
||||
row.appendChild(name);
|
||||
row.appendChild(voiceSel);
|
||||
row.appendChild(test);
|
||||
row.appendChild(def);
|
||||
row.appendChild(remove);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function rosterIds() { return ttsRoster.map(function(e) { return e.model; }); }
|
||||
|
||||
function toggleTTSRoster(id, btn) {
|
||||
if (!id) return;
|
||||
var ids = rosterIds();
|
||||
var added = ids.indexOf(id) !== -1;
|
||||
var next = added ? ids.filter(function(x) { return x !== id; }) : ids.concat([id]);
|
||||
adminSetButtonText(btn, '...', true);
|
||||
fetch('/api/admin/config/' + encodeURIComponent('tts.roster'), {
|
||||
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not update the speech model list');
|
||||
showToast(added ? id + ' removed from the speech roster' + (id === ttsDefault.model ? '. It was the default — choose another on the roster.' : '')
|
||||
: id + ' added to the roster. Its voices are now offered to users; press Test to hear them.', 'success');
|
||||
loadTTSConfig();
|
||||
})
|
||||
.catch(function(err) { loadTTSConfig(); showToast(err.message || 'Request failed', 'error'); });
|
||||
}
|
||||
|
||||
function setTTSDefault(model, voice, btn) {
|
||||
var origText = btn ? btn.textContent : '';
|
||||
adminSetButtonText(btn, '...', true);
|
||||
fetch('/api/admin/config/tts/default', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ model: model, voice: voice })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
adminSetButtonText(btn, origText, false);
|
||||
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
|
||||
adminFlashButtonBackground(btn, 'var(--green)');
|
||||
showToast('Read Aloud default: ' + data.model + ' / ' + (data.voice || 'model default'), 'success');
|
||||
loadTTSConfig();
|
||||
})
|
||||
.catch(function() {
|
||||
adminSetButtonText(btn, origText, false);
|
||||
showToast('Request failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Discover ─────────────────────────────────────────────────
|
||||
function ttsAddButton(id) {
|
||||
var added = rosterIds().indexOf(id) !== -1;
|
||||
return added
|
||||
? '<button class="btn-sm btn-ghost admin-tts-add-btn" type="button" data-id="' + esc(id) + '" title="On the speech roster. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
|
||||
: '<button class="btn-sm btn-primary admin-tts-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the speech roster" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
|
||||
}
|
||||
|
||||
// Rows rendered before the roster loaded (or after it changed) catch up here.
|
||||
function syncTTSRows() {
|
||||
var container = document.getElementById('admin-discover-results');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.admin-tts-add-btn').forEach(function(btn) { btn.outerHTML = ttsAddButton(btn.dataset.id); });
|
||||
}
|
||||
|
||||
function discoverTTS() {
|
||||
var search = (document.getElementById('admin-discover-search') || {}).value || '';
|
||||
var container = document.getElementById('admin-discover-results');
|
||||
|
|
@ -1355,20 +1488,21 @@ initImageSettings();
|
|||
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
|
||||
return;
|
||||
}
|
||||
var items = data.voices || [];
|
||||
var items = data.models || [];
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No speech models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
|
||||
items.slice(0, 100).map(function(v) {
|
||||
var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
|
||||
var setType = isModel ? 'model' : 'voice';
|
||||
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Make default</button>' +
|
||||
'<span style="flex:1;">' + esc(v.name) + badge + '</span>' +
|
||||
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
|
||||
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' speech model' + (data.count === 1 ? '' : 's') + ' (provider: ' + esc(data.provider) + ')</p>' +
|
||||
items.map(function(m) {
|
||||
var voices = Array.isArray(m.voices) ? m.voices : [];
|
||||
var voiceNote = voices.length ? voices.length + ' voice' + (voices.length === 1 ? '' : 's') + ': ' + voices.join(', ') : 'no voice list — the model chooses';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;">' +
|
||||
'<button class="btn-sm btn-ghost admin-tts-test-btn" type="button" data-id="' + esc(m.id) + '" data-voices="' + esc(voices.join(',')) + '" style="padding:2px 8px;font-size:11px;">Test</button>' +
|
||||
'<span style="flex:1;min-width:160px;overflow-wrap:anywhere;">' + esc(m.name) +
|
||||
'<span style="display:block;font-size:11px;color:var(--g500);">' + esc(voiceNote) + '</span></span>' +
|
||||
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
|
||||
ttsAddButton(m.id) +
|
||||
'</div>';
|
||||
}).join('');
|
||||
})
|
||||
|
|
@ -1377,46 +1511,69 @@ initImageSettings();
|
|||
});
|
||||
}
|
||||
|
||||
function setTTSDefault(id, type, btn) {
|
||||
var key = type === 'model' ? 'tts.model' : 'tts.voice';
|
||||
var origText = btn ? btn.textContent : '';
|
||||
adminSetButtonText(btn, '...', true);
|
||||
// ── Test ─────────────────────────────────────────────────────
|
||||
// The model list is the roster. Test on a discovered model that is not on
|
||||
// it yet adds a temporary entry, so a model can be heard before it is added.
|
||||
function fillTTSTestModels(keep) {
|
||||
var sel = document.getElementById('admin-tts-model');
|
||||
if (!sel) return;
|
||||
var current = keep || sel.value || ttsDefault.model;
|
||||
sel.replaceChildren();
|
||||
ttsRoster.forEach(function(entry) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = entry.model;
|
||||
opt.textContent = entry.model + (entry.isDefault ? ' (default)' : '');
|
||||
opt.dataset.voices = (entry.voices || []).join(',');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!sel.options.length) {
|
||||
var none = document.createElement('option');
|
||||
none.value = ''; none.textContent = 'No speech models on the roster';
|
||||
sel.appendChild(none);
|
||||
}
|
||||
if (current && Array.from(sel.options).some(function(o) { return o.value === current; })) sel.value = current;
|
||||
fillTTSTestVoices();
|
||||
}
|
||||
|
||||
fetch('/api/admin/config/' + encodeURIComponent(key), {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ value: id })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
adminSetButtonText(btn, 'Set', false);
|
||||
adminFlashButtonBackground(btn, 'var(--green)');
|
||||
if (data.success) {
|
||||
showToast('TTS ' + type + ' set to: ' + id, 'success');
|
||||
// Update voice selector
|
||||
var voiceSel = document.getElementById('admin-tts-voice');
|
||||
if (voiceSel && type === 'voice') {
|
||||
var found = Array.from(voiceSel.options).find(function(o) { return o.value === id; });
|
||||
if (!found) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = id + ' (active)';
|
||||
voiceSel.insertBefore(opt, voiceSel.firstChild);
|
||||
}
|
||||
voiceSel.value = id;
|
||||
}
|
||||
loadTTSConfig();
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
adminSetButtonText(btn, origText, false);
|
||||
showToast('Request failed', 'error');
|
||||
function pickTTSModelForTest(id, voices) {
|
||||
var sel = document.getElementById('admin-tts-model');
|
||||
if (!sel || !id) return;
|
||||
if (!Array.from(sel.options).some(function(o) { return o.value === id; })) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = id; opt.textContent = id + ' (not on the roster)';
|
||||
opt.dataset.voices = (voices || []).join(',');
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = id;
|
||||
fillTTSTestVoices();
|
||||
var panel = document.getElementById('admin-tts-test-text');
|
||||
if (panel && panel.scrollIntoView) panel.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function fillTTSTestVoices() {
|
||||
var modelSel = document.getElementById('admin-tts-model');
|
||||
var voiceSel = document.getElementById('admin-tts-voice');
|
||||
if (!modelSel || !voiceSel) return;
|
||||
var opt = modelSel.options[modelSel.selectedIndex];
|
||||
var voices = opt && opt.dataset.voices ? opt.dataset.voices.split(',').filter(Boolean) : [];
|
||||
voiceSel.replaceChildren();
|
||||
if (!voices.length) {
|
||||
var none = document.createElement('option');
|
||||
none.value = ''; none.textContent = 'model default (no voice list)';
|
||||
voiceSel.appendChild(none);
|
||||
}
|
||||
voices.forEach(function(v) {
|
||||
var o = document.createElement('option');
|
||||
o.value = v;
|
||||
o.textContent = v + (modelSel.value === ttsDefault.model && v === ttsDefault.voice ? ' (default)' : '');
|
||||
if (modelSel.value === ttsDefault.model && v === ttsDefault.voice) o.selected = true;
|
||||
voiceSel.appendChild(o);
|
||||
});
|
||||
}
|
||||
|
||||
function testTTS() {
|
||||
var text = (document.getElementById('admin-tts-test-text') || {}).value || 'Hello.';
|
||||
var model = (document.getElementById('admin-tts-model') || {}).value || '';
|
||||
var voice = (document.getElementById('admin-tts-voice') || {}).value || '';
|
||||
var btn = document.getElementById('btn-test-tts');
|
||||
var resultEl = document.getElementById('admin-tts-result');
|
||||
|
|
@ -1428,7 +1585,7 @@ initImageSettings();
|
|||
fetch('/api/admin/config/tts/test', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ text: text, voice: voice })
|
||||
body: JSON.stringify({ text: text, model: model, voice: voice })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
|
|
@ -1443,7 +1600,7 @@ initImageSettings();
|
|||
audioEl.style.display = 'inline-block';
|
||||
audioEl.play();
|
||||
}
|
||||
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?');
|
||||
if (resultEl) resultEl.textContent = 'Model: ' + (data.model || '?') + ' · Voice: ' + (data.voice || 'model default');
|
||||
})
|
||||
.catch(function(err) {
|
||||
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize & Play', false);
|
||||
|
|
|
|||
|
|
@ -86,15 +86,27 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Populate TTS voices
|
||||
// Populate TTS voices, grouped by the model each belongs to. A voice's
|
||||
// value is "model|voice": the server needs both to send the request to
|
||||
// the model that accepts that voice.
|
||||
var ttsSelect = document.getElementById('tts-voice-select');
|
||||
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
|
||||
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
|
||||
var groups = {};
|
||||
data.ttsVoices.forEach(function(voice) {
|
||||
var parent = ttsSelect;
|
||||
if (voice.model) {
|
||||
if (!groups[voice.model]) {
|
||||
groups[voice.model] = document.createElement('optgroup');
|
||||
groups[voice.model].label = voice.model;
|
||||
ttsSelect.appendChild(groups[voice.model]);
|
||||
}
|
||||
parent = groups[voice.model];
|
||||
}
|
||||
var opt = document.createElement('option');
|
||||
opt.value = voice.value;
|
||||
opt.textContent = voice.label;
|
||||
ttsSelect.appendChild(opt);
|
||||
parent.appendChild(opt);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
|
@ -159,7 +171,7 @@
|
|||
var voice = ttsSelect ? ttsSelect.value : null;
|
||||
|
||||
// Allow "Server default" (empty value) to preview
|
||||
var displayVoice = voice || 'server default';
|
||||
var displayVoice = voice ? voice.split('|').pop() : 'server default';
|
||||
var text = 'Hello, this is a preview of the ' + displayVoice + ' voice. This is how your read-aloud feature will sound.';
|
||||
|
||||
var btnPreview = document.getElementById('btn-preview-voice');
|
||||
|
|
@ -191,7 +203,7 @@
|
|||
var audio = new Audio(url);
|
||||
audio.onended = function() { URL.revokeObjectURL(url); };
|
||||
audio.play();
|
||||
showToast('Preview: ' + voice, 'success');
|
||||
showToast('Preview: ' + displayVoice, 'success');
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[VoicePrefs] Preview error:', err);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue