tts-voice-creator-clone-and.../static/js/routing.js
mARTin-B78 a0cd7b867f feat(voices): show audio problems in the voice library
Single-voice health endpoint (GET /api/voices/{id}/health), problems stored in
meta on import, shown in the Try-out voice panel; architecture note and docs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:35:10 +02:00

677 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── TTS routing ──────────────────────────────────────────────────────────
(function (root) {
let _ttsRoutes = [];
// Any / Auto first (they are the routing-specific values), then every language
// from the shared list in utils.js — the old hardcoded eight could not express
// a route for anything else (reported live: a Filipino route had to be left on
// "Auto"). Kept as a <select> rather than the free-text combobox used for voice
// naming, deliberately: a route's language is matched EXACTLY against the
// detected code, so a typo here would not look broken — the rule would simply
// never match and traffic would fall through to another route. That is the same
// class of silent misrouting this page already had to have fixed once.
const ROUTE_LANGS = [
['*', 'Any'],
['AUTO', 'Auto'],
...(typeof LANG_CODE_OPTIONS !== 'undefined' && Array.isArray(LANG_CODE_OPTIONS)
? LANG_CODE_OPTIONS
: [['EN','English'],['DE','German'],['FR','French'],['ES','Spanish'],
['IT','Italian'],['PT','Portuguese'],['NL','Dutch'],['PL','Polish']]),
];
// Status is shown in two places (inside the collapsible card, and in the page
// header) so a pending/unsaved state stays visible when the card is closed.
function _routingSetStatus(text) {
['routing-status', 'routing-status-head'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = text;
});
}
function routeSelectOptions(value) {
return ROUTE_LANGS.map(([code, label]) =>
`<option value="${code}" ${code === value ? 'selected' : ''}>${escHtml(label)}</option>`
).join('');
}
// Upgrade every route-language <select> into the searchable flag dropdown.
// Called after each re-render because the rows are rebuilt via innerHTML, which
// discards the enhanced wrappers along with them.
function enhanceRouteLangSelects() {
if (typeof enhanceLangSelect !== 'function') return; // utils.js unavailable -> plain selects still work
document.querySelectorAll('#routing-list .route-lang').forEach(enhanceLangSelect);
}
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 = ['<option value="">Browse sounds</option>'];
if (current && !listed.has(current)) options.push(`<option value="${escHtml(current)}" selected>${escHtml(current)}</option>`);
_routeSounds.forEach(sound => {
const path = String(sound.path || '');
if (!path) return;
const duration = sound.duration != null ? ` · ${Number(sound.duration).toFixed(1)}s` : '';
options.push(`<option value="${escHtml(path)}" ${path === current ? 'selected' : ''}>${escHtml(path + duration)}</option>`);
});
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 = '<div class="routing-log-empty">No sounds found yet. Upload one in a route row; only the selected file is imported.</div>';
return;
}
if (!sounds.length) {
list.innerHTML = '<div class="routing-log-empty">No sounds match this search.</div>';
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 `
<div class="routing-sound-item" data-path="${escHtml(path)}">
<button class="btn-secondary sound-play" type="button" title="Preview sound">▶</button>
<div class="sound-path" title="${escHtml(path)}">${escHtml(path)}</div>
<div class="sound-meta">${escHtml(duration || '-')} ${type ? '· ' + escHtml(type) : ''}</div>
<button class="btn-secondary sound-use-current" type="button">${escHtml(targetLabel)}</button>
</div>`;
}).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
// <audio> element's own playbackRate for an immediate preview — the actual
// routed traffic gets a proper pitch-preserving ffmpeg tempo change
// server-side (core/audio.py:_change_tempo), which is slower but higher
// quality than just is appropriate for a quick "does this sound right" check.
async function previewRouteSpeed(row, btn) {
const outputVoice = row.querySelector('.route-output')?.value.trim();
if (!outputVoice) { toast('Set an output voice first', 'error'); return; }
const backend = row.querySelector('.route-backend')?.value || 'voice_clone';
const speed = Math.max(0.5, Math.min(2, parseFloat(row.querySelector('.route-speed')?.value) || 1));
const lang = (row.querySelector('.route-lang')?.value || 'EN').toUpperCase();
const text = ROUTE_SPEED_PREVIEW_TEXT[lang] || ROUTE_SPEED_PREVIEW_TEXT.EN;
const audio = $('routing-sound-preview');
if (!audio) return;
if (_routeSoundPlayingButton && _routeSoundPlayingButton !== btn) _routeSoundPlayingButton.textContent = '▶';
if (_routeSpeedPreviewButton && _routeSpeedPreviewButton !== btn) _routeSpeedPreviewButton.querySelector('.mdi')?.classList.replace('mdi-pause', 'mdi-play');
_routeSpeedPreviewButton = btn;
const icon = btn.querySelector('.mdi');
btn.disabled = true;
try {
const blob = await fetchTtsPreviewBlob(outputVoice, text, 'wav', '', backend);
audio.hidden = false;
audio.src = URL.createObjectURL(blob);
audio.playbackRate = speed;
icon?.classList.replace('mdi-play', 'mdi-pause');
audio.onended = () => icon?.classList.replace('mdi-pause', 'mdi-play');
audio.onpause = () => { if (_routeSpeedPreviewButton === btn) icon?.classList.replace('mdi-pause', 'mdi-play'); };
await audio.play();
} catch (e) {
icon?.classList.replace('mdi-pause', 'mdi-play');
toast('Preview failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
let _routeSpeedPreviewButton = null;
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]) =>
`<option value="${code}" ${code === current ? 'selected' : ''}>${escHtml(label)}</option>`
).join('');
}
function refreshRoutingVoiceOptions() {
const dl = $('routing-voice-options');
if (dl) {
const ids = [...activeVoiceIds(), ...virtualDesignVoiceIds()];
dl.innerHTML = [...new Set(ids)].map(id => `<option value="${escHtml(id)}"></option>`).join('');
}
const soundsDl = $('routing-sound-options');
if (soundsDl) soundsDl.innerHTML = _routeSounds.map(sound => `<option value="${escHtml(sound.path || '')}"></option>`).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();
_routingSetStatus(_ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.');
if (!_ttsRoutes.length) {
$('routing-list').innerHTML = '<div class="note" style="padding:10px">No routing rules yet. Add a route or add the Open WebUI default examples.</div>';
return;
}
$('routing-list').innerHTML = _ttsRoutes.map((r, i) => `
<div class="routing-grid routing-row" data-index="${i}">
<label class="toggle" title="Enable route">
<input type="checkbox" class="route-enabled" aria-label="Enable route" ${r.enabled !== false ? 'checked' : ''}>
<span class="t-slider"></span>
</label>
<input class="route-app" aria-label="App / client" value="${escHtml(r.app || 'Open WebUI')}" placeholder="Open WebUI, SillyTavern, Home Assistant, or *" title="Matches app/client JSON, X-TTS-App header, or detected client name. Use * for any app.">
<input class="route-input" aria-label="Input voice" value="${escHtml(r.input_voice || 'default')}" placeholder="default">
<select class="route-lang" aria-label="Route language">${routeSelectOptions(String(r.language || '*').toUpperCase())}</select>
<select class="route-backend" aria-label="Route backend" title="Voice Clone uses the normal TTS URL, Streaming uses the streaming URL, Voice Design uses vd_ presets, NVIDIA Magpie uses fixed NVIDIA voices, NVIDIA Zeroshot/Flow use saved library WAVs as audio prompts.">${routeBackendOptions(r.backend || 'voice_clone')}</select>
<span class="route-output-wrap"><input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset"></span>
<div class="route-speed-cell">
<input class="route-speed" type="number" min="0.5" max="2" step="0.05" value="${Number(r.speed) > 0 ? r.speed : 1}" title="Playback speed multiplier (0.5x-2x). 1 = normal speed.">
<button type="button" class="btn-secondary route-speed-preview" title="Preview how this voice sounds at this speed"><span class="mdi mdi-play"></span></button>
</div>
<div class="route-sound-cell">
<input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options">
<button class="btn-secondary route-sound-pick" data-target="before" title="Browse and preview uploaded sounds">Pick</button>
<button class="btn-secondary route-sound-upload" data-target="before" title="Upload one before sound">Upload</button>
</div>
<div class="route-sound-cell">
<input class="route-after-sound" value="${escHtml(r.after_sound || '')}" placeholder="sounds/end.wav" list="routing-sound-options">
<button class="btn-secondary route-sound-pick" data-target="after" title="Browse and preview uploaded sounds">Pick</button>
<button class="btn-secondary route-sound-upload" data-target="after" title="Upload one after sound">Upload</button>
</div>
<button class="btn-secondary routing-delete" title="Delete route">×</button>
</div>
`).join('');
enhanceRouteLangSelects();
// Same searchable avatar dropdown used for voice selection elsewhere in the
// app — the field itself stays a free-text input (routes can also target
// vd_ Voice Design presets, which aren't part of the voice library).
if (typeof VoicePicker !== 'undefined') {
document.querySelectorAll('.route-output').forEach(inp => VoicePicker.attachTextPicker(inp));
}
}
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(),
speed: Math.max(0.5, Math.min(2, parseFloat(row.querySelector('.route-speed').value) || 1)),
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();
_routingSetStatus('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) {
_routingSetStatus('Load failed');
$('routing-list').innerHTML = `<div style="color:var(--red);padding:10px">Failed to load routes: ${escHtml(e.message)}</div>`;
status('Routing load failed');
} finally {
setBusyButton('routing-refresh-btn', false);
}
}
async function saveRoutingTab() {
readRoutingForm();
['routing-save-btn','routing-save-btn-head'].forEach(id => { const b = $(id); if (b) b.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','routing-save-btn-head'].forEach(id => { const b = $(id); if (b) b.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 problems = Array.isArray(health.problems) ? health.problems : [];
if (problems.length) {
parts.push('Audio problems: ' + escHtml(fmtAudioProblems(problems)));
}
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 = `<div class="routing-log-empty">${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.'}</div>`;
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 `
<div class="routing-log-entry status-${escHtml(status)}">
<div class="log-time">${escHtml(routingLogTime(item.ts))}</div>
<div class="routing-log-badge">${escHtml(routingLogBadge(item))}</div>
<div class="log-app" title="${escHtml(item.app || '-')}">${escHtml(item.app || '-')}</div>
<div class="log-voice" title="${escHtml(voice)}">${escHtml(voice)}</div>
<div class="log-meta" title="${escHtml(routingLogMeta(item))}">${escHtml(routingLogMeta(item) || '-')}</div>
<div class="log-text" title="${escHtml(text)}">${escHtml(text || '-')}</div>
</div>`;
}).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 = `<div class="routing-log-empty" style="color:var(--red)">Routing log unavailable: ${escHtml(e.message)}</div>`;
}
}
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);
// The card holding the original toolbar is collapsible, so Add/Save are
// duplicated into the page header where they are always reachable. Both copies
// share one handler rather than each having their own, so they can never drift.
$('routing-save-btn-head')?.addEventListener('click', saveRoutingTab);
$('routing-add-btn-head')?.addEventListener('click', () => $('routing-add-btn')?.click());
$('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 speedPreviewBtn = e.target.closest('.route-speed-preview');
if (speedPreviewBtn) {
previewRouteSpeed(speedPreviewBtn.closest('.routing-row'), speedPreviewBtn);
return;
}
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');
}
});
Object.assign(root, {
ROUTE_LANGS, _routingSetStatus, routeSelectOptions, enhanceRouteLangSelects,
ROUTE_BACKENDS, _routeSounds, routeSoundOptions, loadRouteSounds,
_routeSoundPickerTarget, _routeSoundPlayingButton, routeSoundUrl,
setRouteSoundField, renderRouteSoundBrowser, openRouteSoundBrowser,
closeRouteSoundBrowser, playRouteSound, ROUTE_SPEED_PREVIEW_TEXT,
previewRouteSpeed, _routeSpeedPreviewButton, useRouteSound, routeBackendOptions,
refreshRoutingVoiceOptions, newRoute, renderRoutingList, readRoutingForm,
loadRoutingTab, saveRoutingTab, renderRouteTestResult, routingLogTime,
routingLogBadge, routingLogMeta, _routingLogItems, _routingLogFilter,
routingLogPassesFilter, renderCurrentRoutingLog, renderRoutingLog, loadRoutingLog,
clearRoutingLog, testRouting, uploadRouteSoundForRow,
});
Object.defineProperty(root, '_ttsRoutes', { configurable: true, get: () => _ttsRoutes, set: v => { _ttsRoutes = v; } });
root.TTVC = root.TTVC || {};
root.TTVC.routing = root.TTVC.routing || {};
Object.assign(root.TTVC.routing, {
get routes() { return _ttsRoutes; },
get sounds() { return _routeSounds; },
load: loadRoutingTab,
save: saveRoutingTab,
test: testRouting,
render: renderRoutingList,
});
})(window);