tts-voice-creator-clone-and.../static/js/routing.js
mARTin-B78 bfd1bfb939 Add routing voice-picker, restore voice filters, fix pedalboard (v1.14.3)
- App Routing's output-voice field gets the same searchable
  avatar-thumbnail dropdown used elsewhere, as a browse button
  alongside the existing free-text input (which must stay editable to
  target vd_ Voice Design presets not in the voice library).

- My Voices table: Gender and Rating filter dropdowns existed in the
  JS (populateLibraryFilters, libraryFilterMatch) but their <select>
  elements had been dropped from the visible layout after an earlier
  redesign, replaced with hidden dead placeholders just to keep the
  code from erroring - and since populateLibraryFilters() early-returns
  if any of the three elements are missing, this silently broke the
  already-visible Language/Type dropdowns too. Restored the real
  elements and removed the hidden scaffold; added new Tag and Group
  dropdowns wired to the same filter state the sidebar chips use.

- Audio effects failing with "pedalboard is not installed" despite
  requirements.txt listing it: the package WAS installed, but its
  native extension (pedalboard_native) links against libatomic.so.1,
  an OS-level shared library missing from the python:3.11-slim-bookworm
  base image. Added libatomic1 to the Dockerfile and rebuilt - verified
  `import pedalboard` now succeeds in the running container.

- Relabeled "edit ID"/"copy ID" to "rename filename"/"copy filename"
  in the voice inspector - the feature already renamed the underlying
  .wav/.meta.json/.reference.txt/picture files via the existing
  /api/voice/rename endpoint, it just wasn't obvious "ID" meant
  "filename."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 21:05:45 +02:00

552 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>
<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-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('');
// 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(),
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');
}
});