// ── 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]) => `` ).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 = ['']; if (current && !listed.has(current)) options.push(``); _routeSounds.forEach(sound => { const path = String(sound.path || ''); if (!path) return; const duration = sound.duration != null ? ` · ${Number(sound.duration).toFixed(1)}s` : ''; options.push(``); }); 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) : ''}
`; }).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]) => `` ).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); // Load voices + sounds in parallel (non-fatal — routing still works without them) await Promise.allSettled([ _voices.length ? Promise.resolve() : loadVoiceLibrary().catch(() => {}), loadRouteSounds(), ]); try { 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: ${escHtml(e.message)}
`; 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'); } });