// ── Batch benchmark ─────────────────────────────────────────────────────── (function initBatchBenchmark() { const batchBackendSel = $('batch-backend-select'); const batchRunsSel = $('batch-runs'); const batchLoadBtn = $('batch-load-voices-btn'); const batchSelectAllBtn = $('batch-select-all-btn'); const batchSelectNoneBtn= $('batch-select-none-btn'); const batchVoiceList = $('batch-voice-list'); const batchSelCount = $('batch-selected-count'); const batchRunBtn = $('batch-run-btn'); const batchStopBtn = $('batch-stop-btn'); const batchProgress = $('batch-progress'); const batchProgLabel = $('batch-progress-label'); const batchProgCount = $('batch-progress-count'); const batchProgBar = $('batch-progress-bar'); const batchResultsCard = $('batch-results-card'); const batchResultsLabel = $('batch-results-label'); const batchTbody = $('batch-tbody'); if (!batchRunBtn) return; let batchStopped = false; let batchResults = []; function populateBatchBackends() { if (!batchBackendSel) return; const cur = batchBackendSel.value; batchBackendSel.innerHTML = availableTtsBackends().map(b => `` ).join('') || ''; } populateBatchBackends(); function updateSelCount() { if (!batchVoiceList || !batchSelCount) return; const total = batchVoiceList.querySelectorAll('.batch-voice-cb').length; const checked = batchVoiceList.querySelectorAll('.batch-voice-cb:checked').length; batchSelCount.textContent = total ? `${checked} of ${total} selected` : ''; batchRunBtn.disabled = checked === 0; } function buildVoiceList(voices) { const activeIds = new Set(activeVoiceIds()); if (!voices.length) { batchVoiceList.innerHTML = '
No voices found.
'; updateSelCount(); return; } batchVoiceList.innerHTML = voices.map(v => { const isActive = activeIds.has(v.id); return ``; }).join(''); batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', updateSelCount)); updateSelCount(); } // Pre-populate from My Voices library on init function populateFromLibrary() { const voices = (_voices || []) .filter(v => v.enabled !== false) .map(v => ({ id: v.id, label: v.display_name || v.name || v.id })) .sort((a, b) => a.label.localeCompare(b.label)); buildVoiceList(voices); } populateFromLibrary(); batchLoadBtn.addEventListener('click', async () => { const backend = batchBackendSel.value; if (!backend) { toast('Select a backend first', 'error'); return; } batchLoadBtn.disabled = true; batchVoiceList.innerHTML = '
Loading from backend…
'; try { const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); const voices = raw.map(v => ({ id: backendVoiceId(v), label: backendVoiceId(v) })) .sort((a, b) => a.label.localeCompare(b.label)); buildVoiceList(voices); } catch(e) { batchVoiceList.innerHTML = `
Failed: ${escHtml(e.message)}
`; } finally { batchLoadBtn.disabled = false; } }); batchSelectAllBtn.addEventListener('click', () => { batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = true); updateSelCount(); }); batchSelectNoneBtn.addEventListener('click', () => { batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = false); updateSelCount(); }); function renderBatchResults() { if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; } batchResultsCard.style.display = ''; const sorted = batchResults.slice().sort((a, b) => { if (a.ok && !b.ok) return -1; if (!a.ok && b.ok) return 1; return (a.avgRtf || 999) - (b.avgRtf || 999); }); const okCount = batchResults.filter(r => r.ok).length; if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`; batchTbody.innerHTML = sorted.map(r => { const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : ''; const trend = r.trend ? `${r.trend.label}` : ''; return ` ${escHtml(r.voice)} ${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'} ${r.ok ? r.minLatency + ' ms' : '—'} ${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'} ${r.ok && r.avgRtf ? r.avgRtf.toFixed(2) : '—'}${trend} ${r.ok ? 'OK' : `${escHtml(r.error || 'Failed')}`} `; }).join(''); } batchRunBtn.addEventListener('click', async () => { const backend = batchBackendSel.value; const text = $('perf-text')?.value.trim(); const runs = parseInt(batchRunsSel.value) || 1; const selected = [...(batchVoiceList?.querySelectorAll('.batch-voice-cb:checked') || [])].map(cb => cb.value); if (!backend) { toast('Select a backend first', 'error'); return; } if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; } if (!selected.length) { toast('Select at least one voice', 'error'); return; } batchStopped = false; batchResults = []; batchRunBtn.disabled = true; batchStopBtn.disabled = false; batchProgress.style.display = ''; batchResultsCard.style.display = 'none'; for (let vi = 0; vi < selected.length; vi++) { if (batchStopped) break; const voice = selected[vi]; batchProgLabel.textContent = `${voice} (${vi + 1} / ${selected.length})`; batchProgCount.textContent = `${vi + 1} / ${selected.length}`; batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`; const entry = { voice, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' }; const rowRunResults = []; for (let ri = 0; ri < runs; ri++) { if (batchStopped) break; try { const t0 = performance.now(); const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend); const lat = Math.round(performance.now() - t0); let dur = 0; try { const ac = new (window.AudioContext || window.webkitAudioContext)(); const buf = await ac.decodeAudioData(await blob.arrayBuffer()); dur = buf.duration; ac.close(); } catch(_) {} rowRunResults.push({ lat, dur }); } catch(e) { entry.error = e.message; break; } } if (rowRunResults.length) { entry.ok = true; entry.avgLatency = rowRunResults.reduce((s, r) => s + r.lat, 0) / rowRunResults.length; entry.minLatency = Math.min(...rowRunResults.map(r => r.lat)); const durRows = rowRunResults.filter(r => r.dur > 0); entry.avgAudio = durRows.length ? durRows.reduce((s, r) => s + r.dur, 0) / durRows.length : 0; const rtfArr = durRows.map(r => r.lat / 1000 / r.dur); entry.avgRtf = rtfArr.length ? rtfArr.reduce((s, v) => s + v, 0) / rtfArr.length : 0; // compute trend vs previous session for this voice if (entry.avgRtf > 0) { const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number'); if (prev.length) { const prevRtf = prev[prev.length - 1].avgRtf; const delta = entry.avgRtf - prevRtf; const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100; if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: '→ stable' }; else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `↓ ${pct.toFixed(0)}% faster` }; else entry.trend = { cls: 'perf-trend-worse', label: `↑ ${pct.toFixed(0)}% slower` }; } perfHistoryAdd({ ts: Date.now(), backend, voice, textLen: text.length, avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency, maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)), avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true, }); } } batchResults.push(entry); renderBatchResults(); } batchProgBar.style.width = '100%'; batchProgLabel.textContent = batchStopped ? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.` : `Done — ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`; batchStopBtn.disabled = true; batchRunBtn.disabled = false; renderPerfHistory(); const ok = batchResults.filter(r => r.ok); const best = ok.slice().sort((a, b) => a.avgRtf - b.avgRtf)[0]; toast( `Batch done: ${ok.length}/${batchResults.length} OK` + (best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''), ok.length < batchResults.length ? 'error' : 'success' ); }); batchStopBtn.addEventListener('click', () => { batchStopped = true; batchStopBtn.disabled = true; batchProgLabel.textContent = 'Stopping after current voice…'; }); })(); // ── Benchmark section ───────────────────────────────────────────────────── (function initBenchmarkSection() { let initialized = false; let sttEngines = []; let turnHistoryCount = 0; const q = (id) => document.getElementById(id); const fmtMs = (ms) => ms == null ? '—' : ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : Math.round(ms) + 'ms'; const fmtSec = (sec) => sec == null ? '—' : Number(sec).toFixed(Number(sec) < 10 ? 2 : 1) + 's'; const esc = (s) => typeof escHtml === 'function' ? escHtml(s) : String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const say = (msg, type = '') => typeof toast === 'function' ? toast(msg, type) : console.log(msg); function setBenchTab(name) { document.querySelectorAll('.bench-tab').forEach(btn => btn.classList.toggle('active', btn.dataset.benchTab === name)); document.querySelectorAll('.bench-pane').forEach(pane => pane.classList.toggle('active', pane.dataset.benchPane === name)); } async function fetchJson(url, opts) { const r = await fetch(url, opts); if (!r.ok) { let detail = r.statusText || `HTTP ${r.status}`; try { const d = await r.json(); detail = typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail || d); } catch (_) { try { detail = await r.text(); } catch (_) {} } throw new Error(detail || `HTTP ${r.status}`); } return r.json(); } function dedupeEngines(items) { const seen = new Set(); const out = []; for (const item of items) { const key = `${item.url || ''}|${item.model || ''}|${item.id || item.label || ''}`; if (seen.has(key) || !item.url) continue; seen.add(key); out.push(item); } return out; } async function loadBenchmarkSttEngines() { const list = q('bench-stt-engines'); const count = q('bench-stt-engine-count'); if (!list) return; list.innerHTML = '
Checking STT engines...
'; let settings = {}; const items = []; try { settings = await fetchJson('/api/settings'); } catch (_) {} try { const d = await fetchJson('/api/stt-backends'); (d.backends || []).forEach(b => items.push({ id: b.id, backend: b.id, label: b.label || b.id, url: b.url, model: (b.models && b.models[0]) || b.model || 'whisper-1', available: !!b.available, source: 'configured', })); } catch (e) { console.warn('[benchmark stt backends]', e); } try { const d = await fetchJson('/api/local-containers'); (d.containers || []).filter(c => c.role === 'stt' || c.role === 'stt+tts').forEach(c => { const url = c.port ? `http://host.docker.internal:${c.port}` : ''; const model = settings?.engine_local_models?.['dc-' + c.name] || c.model || 'whisper-1'; items.push({ id: c.name, label: c.label || c.name, url, model, available: c.running === true || c.status === 'running', source: c.stack || 'docker', }); }); } catch (e) { console.warn('[benchmark local containers]', e); } sttEngines = dedupeEngines(items); if (count) count.textContent = sttEngines.length ? `${sttEngines.length} engine${sttEngines.length === 1 ? '' : 's'} loaded.` : 'No engines found.'; if (!sttEngines.length) { list.innerHTML = '
No STT engines found. Check Engines -> Speech to Text.
'; return; } list.innerHTML = sttEngines.map((e, i) => ` `).join(''); } function selectedSttEngines() { return [...document.querySelectorAll('.bench-stt-engine-check:checked')].map(cb => { const i = Number(cb.dataset.index); const item = { ...sttEngines[i] }; const modelInput = document.querySelector(`.bench-engine-model[data-index="${i}"]`); item.model = modelInput?.value.trim() || item.model || 'whisper-1'; return item; }).filter(Boolean); } async function runSttBenchmark() { const audio = q('bench-stt-audio')?.files?.[0]; const refFile = q('bench-stt-ref-file')?.files?.[0]; const refText = q('bench-stt-ref-text')?.value || ''; const engines = selectedSttEngines(); if (!audio) { say('Choose an audio file first', 'error'); return; } if (!refFile && !refText.trim()) { say('Add a reference .txt or paste reference text', 'error'); return; } if (!engines.length) { say('Select at least one STT engine', 'error'); return; } const btn = q('bench-stt-run'); const st = q('bench-stt-status'); const tbody = q('bench-stt-table')?.querySelector('tbody'); if (btn) btn.disabled = true; if (st) st.textContent = `Running ${engines.length} STT benchmark${engines.length === 1 ? '' : 's'}...`; if (tbody) tbody.innerHTML = `Benchmarking ${engines.length} engines...`; try { const fd = new FormData(); fd.append('audio', audio, audio.name); if (refFile) fd.append('reference_file', refFile, refFile.name); fd.append('reference_text', refText); fd.append('engines_json', JSON.stringify(engines)); const d = await fetchJson('/api/stt-benchmark', { method: 'POST', body: fd }); renderSttResults(d); if (st) st.textContent = `Finished ${d.results?.length || 0} STT engines.`; say('STT benchmark complete', 'success'); } catch (e) { if (st) st.textContent = 'STT benchmark failed'; if (tbody) tbody.innerHTML = `${esc(e.message)}`; say('STT benchmark failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } function renderSttResults(d) { const tbody = q('bench-stt-table')?.querySelector('tbody'); const summary = q('bench-stt-summary'); const rows = d.results || []; if (summary) { summary.innerHTML = [ ['Engines', String(rows.length)], ['Reference words', String(d.reference_words || 0)], ['Best time', d.best_time_sec == null ? '—' : fmtSec(d.best_time_sec)], ['Best accuracy', d.best_accuracy == null ? '—' : d.best_accuracy.toFixed(1) + '%'], ].map(([label, val]) => `${esc(val)} ${esc(label)}`).join(''); } if (!tbody) return; tbody.innerHTML = rows.map(r => { const acc = r.accuracy == null ? '—' : `${Number(r.accuracy).toFixed(1)}%`; const out = r.ok ? (r.output || '') : `⚠ ${r.error || 'failed'}`; return ` ${esc(r.engine)}${esc(r.url || '')} ${esc(r.model || '')} ${esc(r.device || 'Unknown')} ${fmtSec(r.time_sec)} ${esc(acc)} ${esc(out)} `; }).join(''); } async function runTtsBenchmark() { const text = q('bench-tts-text')?.value.trim() || ''; if (!text) { say('Enter a sample sentence first', 'error'); return; } const activeOnly = (q('bench-tts-scope')?.value || 'active') === 'active'; const btn = q('bench-tts-run'); const st = q('bench-tts-status'); const tbody = q('bench-tts-table')?.querySelector('tbody'); if (btn) btn.disabled = true; if (st) st.textContent = 'Running TTS benchmark...'; if (tbody) tbody.innerHTML = 'Benchmarking voices...'; try { const d = await fetchJson('/api/voices/benchmark', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, active_only: activeOnly }), }); renderTtsResults(d); if (st) st.textContent = `Finished ${d.benchmarked || 0} voices.`; say('TTS benchmark complete', d.errors?.length ? 'error' : 'success'); } catch (e) { if (st) st.textContent = 'TTS benchmark failed'; if (tbody) tbody.innerHTML = `${esc(e.message)}`; say('TTS benchmark failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } function renderTtsResults(d) { const tbody = q('bench-tts-table')?.querySelector('tbody'); const summary = q('bench-tts-summary'); const rows = d.voices || []; const okRows = rows.map(r => r.benchmark).filter(b => b && b.ok); const avg = (key) => { const vals = okRows.map(b => Number(b[key])).filter(Number.isFinite); return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; }; if (summary) { summary.innerHTML = [ ['Voices', String(rows.length)], ['OK', String(okRows.length)], ['Avg TTFA', avg('ttfa_ms') == null ? '—' : Math.round(avg('ttfa_ms')) + 'ms'], ['Avg total', avg('elapsed_sec') == null ? '—' : fmtSec(avg('elapsed_sec'))], ].map(([label, val]) => `${esc(val)} ${esc(label)}`).join(''); } if (!tbody) return; tbody.innerHTML = rows.length ? rows.map(row => { const b = row.benchmark || {}; const status = b.ok ? (b.realtime_ok === false ? 'Slow' : 'OK') : (b.error || 'Failed'); return ` ${esc(row.voice_id)} ${b.ttfa_ms == null ? '—' : Math.round(b.ttfa_ms) + 'ms'} ${fmtSec(b.elapsed_sec)} ${fmtSec(b.audio_sec)} ${b.rtf == null ? '—' : Number(b.rtf).toFixed(2)} ${b.speed == null ? '—' : Number(b.speed).toFixed(2) + 'x'} ${esc(status)} `; }).join('') : 'No voices benchmarked.'; } async function initTurnControls() { try { const settings = await fetchJson('/api/settings'); if (q('bench-turn-llm-url')) q('bench-turn-llm-url').value = settings.conv_llm_url || settings.llm_url || ''; } catch (_) {} try { const d = await fetchJson('/api/stt-backends'); const sel = q('bench-turn-stt'); if (sel) sel.innerHTML = (d.backends || []).map(b => ``).join('') || ''; } catch (_) {} try { if (typeof refreshTtsBackendAvailability === 'function') await refreshTtsBackendAvailability(); const sel = q('bench-turn-tts-backend'); const backends = (window._ttsBackends || (typeof _ttsBackends !== 'undefined' ? _ttsBackends : []) || []).filter(Boolean); if (sel) sel.innerHTML = backends.map(b => ``).join('') || ''; } catch (_) {} } async function fetchTurnModels() { const btn = q('bench-turn-fetch-llm'); const sel = q('bench-turn-llm-model'); const url = q('bench-turn-llm-url')?.value.trim() || ''; if (btn) btn.disabled = true; try { const d = await fetchJson('/api/conversation/llm-models' + (url ? '?url=' + encodeURIComponent(url) : '')); const models = d.models || []; if (sel) sel.innerHTML = models.length ? models.map(m => ``).join('') : ''; } catch (e) { if (sel) sel.innerHTML = ''; say('Model fetch failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } async function fetchTurnVoices() { const btn = q('bench-turn-fetch-voices'); const sel = q('bench-turn-voice'); const backend = q('bench-turn-tts-backend')?.value || 'voice_clone'; if (btn) btn.disabled = true; try { const raw = await fetchJson('/api/tts-voices?backend=' + encodeURIComponent(backend)); const ids = (Array.isArray(raw) ? raw : []).map(v => typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name))).filter(Boolean); if (sel) sel.innerHTML = ids.length ? ids.map(id => ``).join('') : ''; } catch (e) { if (sel) sel.innerHTML = ''; say('Voice fetch failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } function updateTurnStats(stats) { const max = stats.total_ms || 1; const pairs = [ ['stt', stats.stt_ms], ['ttft', stats.llm_ttft_ms], ['llm', stats.llm_total_ms], ['tts', stats.tts_ms], ['total', stats.total_ms], ]; pairs.forEach(([key, ms]) => { const val = q('bench-turn-val-' + key); const fill = q('bench-turn-fill-' + key); if (val) val.textContent = fmtMs(ms); if (fill) fill.style.width = max > 0 ? Math.min(100, ((ms || 0) / max) * 100) + '%' : '0%'; }); } function addTurnLog(role, text) { const log = q('bench-turn-log'); if (!log) return null; log.querySelector('.conv-chat-welcome')?.remove(); const wrap = document.createElement('div'); wrap.className = `conv-bubble-wrap conv-bubble-wrap--${role}`; const bubble = document.createElement('div'); bubble.className = `conv-bubble conv-bubble--${role}`; bubble.textContent = text || ''; wrap.appendChild(bubble); log.appendChild(wrap); log.scrollTop = log.scrollHeight; return bubble; } function addTurnHistory(total, ok) { const hist = q('bench-turn-history'); if (!hist) return; hist.querySelector('.conv-history-empty')?.remove(); turnHistoryCount++; const item = document.createElement('div'); item.className = 'conv-hist-item'; item.innerHTML = `#${turnHistoryCount}${fmtMs(total)}`; hist.prepend(item); } async function runTurnBenchmark() { const audio = q('bench-turn-audio')?.files?.[0]; const text = q('bench-turn-text')?.value.trim() || ''; if (!audio && !text) { say('Choose turn audio or enter fallback text', 'error'); return; } const btn = q('bench-turn-run'); const st = q('bench-turn-status'); if (btn) btn.disabled = true; if (st) st.textContent = 'Running conversation turn...'; const t0 = Date.now(); const userBubble = addTurnLog('user', text || 'Transcribing audio...'); const assistantBubble = addTurnLog('assistant', '...'); let assistantText = ''; let lastStats = null; try { const fd = new FormData(); if (audio) fd.append('audio', audio, audio.name); if (text) fd.append('text', text); fd.append('stt_backend', q('bench-turn-stt')?.value || 'configured'); fd.append('llm_url', q('bench-turn-llm-url')?.value.trim() || ''); fd.append('llm_model', q('bench-turn-llm-model')?.value || ''); fd.append('tts_backend', q('bench-turn-tts-backend')?.value || 'voice_clone'); fd.append('tts_voice', q('bench-turn-voice')?.value || ''); fd.append('system_prompt', q('bench-turn-system')?.value.trim() || 'You are a helpful voice assistant.'); fd.append('history', '[]'); const resp = await fetch('/api/conversation/turn', { method: 'POST', body: fd }); if (!resp.ok) throw new Error('Server error ' + resp.status); const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (!line.startsWith('data:')) continue; let evt; try { evt = JSON.parse(line.slice(5).trim()); } catch (_) { continue; } if (evt.type === 'transcript' && userBubble) userBubble.textContent = evt.text || '(empty)'; if (evt.type === 'token') { assistantText += evt.delta || ''; if (assistantBubble) assistantBubble.textContent = assistantText; } if (evt.type === 'llm_done') { assistantText = evt.text || assistantText; if (assistantBubble) assistantBubble.textContent = assistantText; } if (evt.type === 'audio' && evt.b64) { const bytes = Uint8Array.from(atob(evt.b64), c => c.charCodeAt(0)); const url = URL.createObjectURL(new Blob([bytes], { type: evt.mime || 'audio/wav' })); const audioEl = document.createElement('audio'); audioEl.controls = true; audioEl.src = url; audioEl.addEventListener('ended', () => URL.revokeObjectURL(url), { once: true }); q('bench-turn-log')?.appendChild(audioEl); } if (evt.type === 'stats') { lastStats = evt; updateTurnStats(evt); } if (evt.type === 'error') throw new Error(`[${evt.stage || 'turn'}] ${evt.message || 'Unknown error'}`); } } const total = lastStats?.total_ms ?? (Date.now() - t0); addTurnHistory(total, true); if (st) st.textContent = `Finished in ${fmtMs(total)}.`; say('Turn benchmark complete', 'success'); } catch (e) { if (assistantBubble) assistantBubble.textContent = e.message; addTurnHistory(Date.now() - t0, false); if (st) st.textContent = 'Turn benchmark failed'; say('Turn benchmark failed: ' + e.message, 'error'); } finally { if (btn) btn.disabled = false; } } function bindBenchmarkSection() { if (initialized || !q('bench-stt-run')) return; initialized = true; document.querySelectorAll('.bench-tab').forEach(btn => btn.addEventListener('click', () => setBenchTab(btn.dataset.benchTab))); q('bench-stt-refresh')?.addEventListener('click', loadBenchmarkSttEngines); q('bench-stt-run')?.addEventListener('click', runSttBenchmark); q('bench-tts-run')?.addEventListener('click', runTtsBenchmark); q('bench-turn-fetch-llm')?.addEventListener('click', fetchTurnModels); q('bench-turn-fetch-voices')?.addEventListener('click', fetchTurnVoices); q('bench-turn-run')?.addEventListener('click', runTurnBenchmark); q('bench-turn-tts-backend')?.addEventListener('change', () => { if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = ''; }); loadBenchmarkSttEngines(); initTurnControls(); } bindBenchmarkSection(); if (!initialized && document.body) { const obs = new MutationObserver(() => bindBenchmarkSection()); obs.observe(document.body, { childList: true, subtree: true }); } })();