// ── 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 });
}
})();