tts-voice-creator-clone-and.../static/js/routing.js
mARTin-B78 40e42590cc Release v1.6.0: a11y (WCAG AA), i18n (DE), PWA, perf, tests, Cast UX
Cast: card/list views, sort & filter, online voice picker, "Hear a line"
sample button, AI character notes, import auto-save.

Platform: WCAG 2.1 AA accessibility pass; German UI translation + language
picker; installable PWA with offline shell; GZip + content-visibility
virtualization + lazy images + Rehearser PCM memory cap (mobile stability);
Playwright suite (desktop + iPhone); opt-in minified bundle build.

Fixes: screenplay parser false characters; Fish-Speech inline-tag tones;
narrator/voice pickers list full library; clone GUI rework; fish.audio
import dedup; voice-ID rename; bulk-delete modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:23:35 +02:00

543 lines
23 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 ──────────────────────────────────────────────────────────
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]) =>
`<option value="${code}" ${code === value ? 'selected' : ''}>${escHtml(label)}</option>`
).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 = ['<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.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]) =>
`<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();
$('routing-status').textContent = _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>
<input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset">
<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('');
}
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 = '<div style="color:var(--red);padding:10px">Failed to load routes</div>';
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 = `<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);
$('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');
}
});