// ── 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.playbackRate = 1; // reset — the route-speed preview below reuses this same element 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'); }); } // Short, natural-sounding test lines per language — same idea as the fixed // German phrase in the "Test route" box above the table, just localized so a // route's own language column previews sensibly instead of always reading // English/German regardless of what the route actually routes. const ROUTE_SPEED_PREVIEW_TEXT = { EN: 'This is a quick playback speed test.', DE: 'Das ist ein kurzer Test der Wiedergabegeschwindigkeit.', FR: "Ceci est un test rapide de la vitesse de lecture.", ES: 'Esta es una prueba rápida de la velocidad de reproducción.', IT: 'Questo è un rapido test della velocità di riproduzione.', PT: 'Este é um teste rápido da velocidade de reprodução.', NL: 'Dit is een snelle test van de afspeelsnelheid.', PL: 'To jest krótki test szybkości odtwarzania.', }; // Synthesizes the row's output voice directly (bypassing the app/voice // routing-table lookup that /v1/audio/speech would do) so speed changes are // audible instantly without saving the route first. Applies speed via the //