546 lines
23 KiB
JavaScript
546 lines
23 KiB
JavaScript
// ── 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);
|
||
// 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 = `<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').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');
|
||
}
|
||
});
|
||
|