// ── TTS routing ──────────────────────────────────────────────────────────
let _ttsRoutes = [];
const ROUTE_LANGS = [
['*', 'Any'],
['AUTO', 'Auto'],
['EN', 'English'],
['DE', 'German'],
['FR', 'French'],
['ES', 'Spanish'],
['IT', 'Italian'],
['PT', 'Portuguese'],
['NL', 'Dutch'],
['PL', 'Polish'],
];
function routeSelectOptions(value) {
return ROUTE_LANGS.map(([code, label]) =>
`${escHtml(label)} `
).join('');
}
const ROUTE_BACKENDS = [
['voice_clone', 'Voice Clone'],
['streaming', 'Streaming'],
['voice_design', 'Voice Design'],
['nvidia_magpie', 'NVIDIA Magpie'],
['nvidia_zeroshot', 'NVIDIA Zeroshot'],
['nvidia_flow', 'NVIDIA Flow'],
];
let _routeSounds = [];
function routeSoundOptions(value = '') {
const current = String(value || '');
const listed = new Set(_routeSounds.map(s => String(s.path || '')));
const options = ['Browse sounds '];
if (current && !listed.has(current)) options.push(`${escHtml(current)} `);
_routeSounds.forEach(sound => {
const path = String(sound.path || '');
if (!path) return;
const duration = sound.duration != null ? ` · ${Number(sound.duration).toFixed(1)}s` : '';
options.push(`${escHtml(path + duration)} `);
});
return options.join('');
}
async function loadRouteSounds() {
try {
const r = await fetch('/api/route-sounds');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_routeSounds = Array.isArray(d.sounds) ? d.sounds : [];
} catch (_) {
_routeSounds = [];
}
}
let _routeSoundPickerTarget = null;
let _routeSoundPlayingButton = null;
function routeSoundUrl(path) {
return '/api/route-sounds/file/' + String(path || '').split('/').map(encodeURIComponent).join('/');
}
function setRouteSoundField(row, target, path) {
const field = row?.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound');
if (!field) return;
field.value = path || '';
readRoutingForm();
}
function renderRouteSoundBrowser() {
const list = $('routing-sound-list');
if (!list) return;
const query = String($('routing-sound-search')?.value || '').trim().toLowerCase();
const sounds = query
? _routeSounds.filter(sound => String(sound.path || '').toLowerCase().includes(query) || String(sound.name || '').toLowerCase().includes(query))
: _routeSounds;
if (!_routeSounds.length) {
list.innerHTML = '
No sounds found yet. Upload one in a route row; only the selected file is imported.
';
return;
}
if (!sounds.length) {
list.innerHTML = 'No sounds match this search.
';
return;
}
list.innerHTML = sounds.map(sound => {
const path = String(sound.path || '');
const size = sound.size != null ? Math.max(1, Math.round(Number(sound.size) / 1024)) + ' KB' : '';
const duration = sound.duration != null ? Number(sound.duration).toFixed(1) + 's' : size;
const type = sound.type ? String(sound.type).toUpperCase() : '';
const targetLabel = _routeSoundPickerTarget?.target === 'after' ? 'Use after' : 'Use before';
return `
▶
${escHtml(path)}
${escHtml(duration || '-')} ${type ? '· ' + escHtml(type) : ''}
${escHtml(targetLabel)}
`;
}).join('');
}
async function openRouteSoundBrowser(row, target) {
_routeSoundPickerTarget = {row, target};
await loadRouteSounds();
renderRouteSoundBrowser();
const panel = $('routing-sound-browser');
if (panel) {
panel.hidden = false;
panel.scrollIntoView({block:'nearest', behavior:'smooth'});
}
const label = target === 'before' ? 'before sound' : 'after sound';
const note = $('routing-sound-browser-note');
if (note) note.textContent = `Preview uploaded route sounds, then choose one for this ${label}. Upload imports only the selected file; this list also shows sounds already present in the sounds folders. ${_routeSounds.length} sounds available.`;
}
function closeRouteSoundBrowser() {
const panel = $('routing-sound-browser');
if (panel) panel.hidden = true;
const audio = $('routing-sound-preview');
if (audio) { audio.pause(); audio.hidden = true; audio.removeAttribute('src'); }
if (_routeSoundPlayingButton) _routeSoundPlayingButton.textContent = '▶';
_routeSoundPlayingButton = null;
_routeSoundPickerTarget = null;
}
function playRouteSound(path, btn) {
const audio = $('routing-sound-preview');
if (!audio || !path) return;
if (_routeSoundPlayingButton && _routeSoundPlayingButton !== btn) _routeSoundPlayingButton.textContent = '▶';
_routeSoundPlayingButton = btn;
btn.textContent = '❚❚';
audio.hidden = false;
audio.src = routeSoundUrl(path);
audio.onended = () => { btn.textContent = '▶'; };
audio.onpause = () => { if (_routeSoundPlayingButton === btn) btn.textContent = '▶'; };
audio.onplay = () => { btn.textContent = '❚❚'; };
audio.play().catch(e => {
btn.textContent = '▶';
toast('Sound preview failed: ' + e.message, 'error');
});
}
function useRouteSound(path, target) {
const selected = _routeSoundPickerTarget || {};
const row = selected.row || document.querySelector('.routing-row');
const useTarget = target || selected.target || 'before';
setRouteSoundField(row, useTarget, path);
toast(`${useTarget === 'before' ? 'Before' : 'After'} sound selected`, 'success');
}
function routeBackendOptions(value) {
const current = value || 'voice_clone';
return ROUTE_BACKENDS.map(([code, label]) =>
`${escHtml(label)} `
).join('');
}
function refreshRoutingVoiceOptions() {
const dl = $('routing-voice-options');
if (dl) {
const ids = [...activeVoiceIds(), ...virtualDesignVoiceIds()];
dl.innerHTML = [...new Set(ids)].map(id => ` `).join('');
}
const soundsDl = $('routing-sound-options');
if (soundsDl) soundsDl.innerHTML = _routeSounds.map(sound => ` `).join('');
}
function newRoute(app = 'Open WebUI', inputVoice = 'default', language = '*', outputVoice = '') {
return {
id: 'route_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6),
enabled: true,
app,
input_voice: inputVoice,
language,
backend: 'voice_clone',
output_voice: outputVoice,
before_sound: '',
after_sound: '',
};
}
function renderRoutingList() {
if (!$('routing-list')) return;
refreshRoutingVoiceOptions();
$('routing-proxy-url').textContent = getCreatorV1Url();
updateCreatorUrlHints();
$('routing-status').textContent = _ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.';
if (!_ttsRoutes.length) {
$('routing-list').innerHTML = 'No routing rules yet. Add a route or add the Open WebUI default examples.
';
return;
}
$('routing-list').innerHTML = _ttsRoutes.map((r, i) => `
`).join('');
}
function readRoutingForm() {
_ttsRoutes = [...document.querySelectorAll('.routing-row')].map((row, i) => {
const existing = _ttsRoutes[Number(row.dataset.index)] || {};
return {
id: existing.id || `route_${i+1}`,
enabled: row.querySelector('.route-enabled').checked,
app: row.querySelector('.route-app').value.trim() || '*',
input_voice: row.querySelector('.route-input').value.trim() || 'default',
language: row.querySelector('.route-lang').value || '*',
backend: row.querySelector('.route-backend').value || 'voice_clone',
output_voice: row.querySelector('.route-output').value.trim(),
before_sound: row.querySelector('.route-before-sound').value.trim(),
after_sound: row.querySelector('.route-after-sound').value.trim(),
};
});
}
async function loadRoutingTab() {
if (!$('routing-list')) return;
$('routing-proxy-url').textContent = getCreatorV1Url();
updateCreatorUrlHints();
$('routing-status').textContent = 'Loading routing…';
$('routing-list').innerHTML = loadingMarkup('Loading routing', 'Loading active voices and routing rules for the proxy.', 5);
setBusyButton('routing-refresh-btn', true);
try {
if (!_voices.length) await loadVoiceLibrary();
await loadRouteSounds();
const r = await fetch('/api/tts-routes');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_ttsRoutes = Array.isArray(d.routes) ? d.routes : [];
renderRoutingList();
status('Routing loaded');
loadRoutingLog();
} catch(e) {
$('routing-status').textContent = 'Load failed';
$('routing-list').innerHTML = 'Failed to load routes
';
status('Routing load failed');
} finally {
setBusyButton('routing-refresh-btn', false);
}
}
async function saveRoutingTab() {
readRoutingForm();
$('routing-save-btn').disabled = true;
try {
const r = await fetch('/api/tts-routes', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({routes:_ttsRoutes}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_ttsRoutes = d.routes || _ttsRoutes;
renderRoutingList();
toast('Routing saved', 'success');
status('TTS routing saved');
} catch(e) {
toast('Save routes failed: ' + e.message, 'error');
} finally {
$('routing-save-btn').disabled = false;
}
}
function renderRouteTestResult(d) {
const el = $('routing-test-result');
if (!el) return;
const health = d.voice_health || {};
const warnings = Array.isArray(health.warnings) ? health.warnings : [];
el.className = 'routing-test-result ' + (warnings.length ? 'warn' : 'ok');
const parts = [
`${escHtml(d.requested_voice || '')} → ${escHtml(d.routed_voice || '')}`,
`app ${escHtml(d.app || '-')}`,
`backend ${escHtml(d.backend || 'voice_clone')}`,
`language ${escHtml(d.detected_language || '-')}`,
d.matched ? 'matched route' : 'no route matched',
];
if (health.duration) parts.push(`reference ${health.duration}s`);
if (health.word_count != null) parts.push(`${health.word_count} words`);
if (health.words_per_sec) parts.push(`${health.words_per_sec} words/s`);
if (warnings.length) {
parts.push('Warning: ' + warnings.map(escHtml).join('; '));
}
const sounds = d.sounds || {};
for (const [key, sound] of Object.entries(sounds)) {
const label = key === 'before_sound' ? 'before sound' : 'after sound';
parts.push(sound.ok ? `${label} OK` : `${label}: ${escHtml(sound.error || 'not found')}`);
if (!sound.ok) el.className = 'routing-test-result warn';
}
el.innerHTML = parts.join(' · ');
}
function routingLogTime(ts) {
if (!ts) return '--:--:--';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return String(ts).slice(11, 19) || '--:--:--';
return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
}
function routingLogBadge(item) {
const status = String(item.status || item.kind || 'log');
if (item.kind === 'test' && status === 'matched') return 'test ok';
if (item.kind === 'test' && status === 'no_match') return 'test miss';
return status.replace(/_/g, ' ');
}
function routingLogMeta(item) {
const parts = [];
if (item.backend) parts.push(item.backend);
if (item.language) parts.push('lang ' + item.language);
if (item.response_format) parts.push(item.response_format);
if (item.duration != null) parts.push(Number(item.duration).toFixed(2) + 's');
if (item.bytes != null) parts.push(Math.round(Number(item.bytes) / 1024) + ' KB');
if (item.sounds && Array.isArray(item.sounds) && item.sounds.length) parts.push('sounds ' + item.sounds.join(','));
if (item.route_id) parts.push('route ' + item.route_id);
if (item.client) parts.push(item.client);
return parts.join(' · ');
}
let _routingLogItems = [];
let _routingLogFilter = 'all';
function routingLogPassesFilter(item) {
const status = String(item?.status || '').toLowerCase();
const isError = status === 'error' || Boolean(item?.error);
const isNoMatch = status === 'no_match' || item?.matched === false;
if (_routingLogFilter === 'error') return isError;
if (_routingLogFilter === 'no_match') return isNoMatch;
if (_routingLogFilter === 'attention') return isError || isNoMatch;
return true;
}
function renderCurrentRoutingLog() {
renderRoutingLog(_routingLogItems.filter(routingLogPassesFilter));
}
function renderRoutingLog(items = []) {
const el = $('routing-log-list');
if (!el) return;
if (!items.length) {
const filtered = _routingLogItems.length && _routingLogFilter !== 'all';
el.innerHTML = `${filtered ? 'No routing log entries match this filter.' : 'No routing log entries yet. Test a route or send a TTS request through the Creator proxy.'}
`;
return;
}
el.innerHTML = items.map(item => {
const status = String(item.status || 'log').replace(/[^a-z0-9_-]/gi, '_');
const requested = item.requested_voice || '-';
const routed = item.routed_voice || '-';
const voice = requested === routed ? requested : `${requested} → ${routed}`;
const text = item.error ? `Error: ${item.error}` : (item.text_preview || '');
return `
${escHtml(routingLogTime(item.ts))}
${escHtml(routingLogBadge(item))}
${escHtml(item.app || '-')}
${escHtml(voice)}
${escHtml(routingLogMeta(item) || '-')}
${escHtml(text || '-')}
`;
}).join('');
}
async function loadRoutingLog() {
const el = $('routing-log-list');
if (!el) return;
try {
const r = await fetch('/api/tts-routing-log?limit=80');
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
_routingLogItems = Array.isArray(d.items) ? d.items : [];
renderCurrentRoutingLog();
} catch(e) {
el.innerHTML = `Routing log unavailable: ${escHtml(e.message)}
`;
}
}
async function clearRoutingLog() {
const btn = $('routing-log-clear-btn');
if (btn) btn.disabled = true;
try {
const r = await fetch('/api/tts-routing-log', {method:'DELETE'});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
_routingLogItems = [];
renderRoutingLog([]);
toast('Routing log cleared', 'success');
} catch(e) {
toast('Clear log failed: ' + e.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function testRouting() {
readRoutingForm();
const btn = $('routing-test-btn');
const el = $('routing-test-result');
btn.disabled = true;
el.className = 'routing-test-result';
el.textContent = 'Testing route...';
try {
const r = await fetch('/api/tts-route-test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
app: $('routing-test-app').value.trim() || 'Open WebUI',
voice: $('routing-test-voice').value.trim() || 'default',
input: $('routing-test-text').value.trim(),
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
renderRouteTestResult(await r.json());
loadRoutingLog();
} catch(e) {
el.className = 'routing-test-result warn';
el.textContent = 'Route test failed: ' + e.message;
} finally {
btn.disabled = false;
}
}
async function uploadRouteSoundForRow(row, target) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.multiple = false;
input.onchange = async () => {
if (!input.files || !input.files.length) return;
const btn = row.querySelector(`.route-sound-upload[data-target="${target}"]`);
const field = row.querySelector(target === 'before' ? '.route-before-sound' : '.route-after-sound');
if (btn) btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', input.files[0]);
const r = await fetch('/api/route-sounds/upload', { method:'POST', body:fd });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
field.value = d.path || '';
await loadRouteSounds();
readRoutingForm();
renderRoutingList();
toast(`${target === 'before' ? 'Before' : 'After'} sound uploaded`, 'success');
status(`Uploaded route sound: ${d.path}`);
} catch(e) {
toast('Sound upload failed: ' + e.message, 'error');
status('Sound upload failed');
} finally {
if (btn) btn.disabled = false;
}
};
input.click();
}
$('routing-refresh-btn')?.addEventListener('click', loadRoutingTab);
$('routing-add-btn')?.addEventListener('click', () => {
readRoutingForm();
_ttsRoutes.push(newRoute());
renderRoutingList();
});
$('routing-add-openwebui-btn')?.addEventListener('click', () => {
readRoutingForm();
const voices = activeVoiceIds();
const firstByLang = lang => voices.find(v => v.toUpperCase().startsWith(lang + '_')) || '';
_ttsRoutes.push(newRoute('Open WebUI', 'default', 'EN', firstByLang('EN')));
_ttsRoutes.push(newRoute('Open WebUI', 'default', 'DE', firstByLang('DE')));
renderRoutingList();
});
$('routing-save-btn')?.addEventListener('click', saveRoutingTab);
$('routing-test-btn')?.addEventListener('click', testRouting);
$('routing-log-refresh-btn')?.addEventListener('click', loadRoutingLog);
$('routing-log-clear-btn')?.addEventListener('click', clearRoutingLog);
$('routing-log-filter')?.addEventListener('change', (e) => {
_routingLogFilter = e.target.value || 'all';
renderCurrentRoutingLog();
});
$('routing-list')?.addEventListener('change', e => {
const picker = e.target.closest('.route-sound-picker');
if (!picker) return;
const row = picker.closest('.routing-row');
const field = row.querySelector(picker.dataset.target === 'before' ? '.route-before-sound' : '.route-after-sound');
if (field) field.value = picker.value || '';
readRoutingForm();
});
$('routing-list')?.addEventListener('click', e => {
const pickBtn = e.target.closest('.route-sound-pick');
if (pickBtn) {
const row = pickBtn.closest('.routing-row');
openRouteSoundBrowser(row, pickBtn.dataset.target);
return;
}
const uploadBtn = e.target.closest('.route-sound-upload');
if (uploadBtn) {
const row = uploadBtn.closest('.routing-row');
uploadRouteSoundForRow(row, uploadBtn.dataset.target);
return;
}
const btn = e.target.closest('.routing-delete');
if (!btn) return;
readRoutingForm();
const row = btn.closest('.routing-row');
_ttsRoutes.splice(Number(row.dataset.index), 1);
renderRoutingList();
});
$('routing-sound-search')?.addEventListener('input', debounce(renderRouteSoundBrowser, 120));
$('routing-sound-refresh-btn')?.addEventListener('click', async () => {
await loadRouteSounds();
renderRouteSoundBrowser();
});
$('routing-sound-close-btn')?.addEventListener('click', closeRouteSoundBrowser);
$('routing-sound-list')?.addEventListener('click', e => {
const item = e.target.closest('.routing-sound-item');
if (!item) return;
const path = item.dataset.path || '';
const playBtn = e.target.closest('.sound-play');
if (playBtn) {
playRouteSound(path, playBtn);
return;
}
if (e.target.closest('.sound-use-current')) {
useRouteSound(path, _routeSoundPickerTarget?.target || 'before');
}
});