tts-voice-creator-clone-and.../static/js/benchmark.js

1062 lines
50 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.

// ── Batch benchmark ───────────────────────────────────────────────────────
(function initBatchBenchmark() {
const batchBackendSel = $('batch-backend-select');
const batchRunsSel = $('batch-runs');
const batchLoadBtn = $('batch-load-voices-btn');
const batchSearchInput = $('batch-voice-search');
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 perfRunSelectedBtn = $('perf-run-selected-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 = [];
let batchVoices = [];
let batchSelected = new Set();
let batchSortField = 'factor';
let batchSortDir = -1; // -1 = desc (fastest first)
function populateBatchBackends() {
if (!batchBackendSel) return;
const cur = batchBackendSel.value;
batchBackendSel.innerHTML = availableTtsBackends().map(b =>
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
).join('') || '<option value="">No backends available</option>';
}
populateBatchBackends();
function libraryVoice(id) {
return (window._voices || []).find(v => v && v.id === id) || null;
}
function normalizeBatchVoice(value) {
const id = typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || value?.voice_id || '');
const meta = typeof value === 'object' ? (value.meta || libraryVoice(id) || value) : (libraryVoice(id) || null);
return id ? {
id,
label: meta?.display_name || value?.label || meta?.name || value?.name || id,
meta,
} : null;
}
function batchVoiceStats(v) {
const meta = v?.meta || libraryVoice(v?.id) || {};
const b = meta.benchmark || {};
const parts = [];
const dur = Number(meta.duration);
const speed = Number(b.speed ?? b.avg_speed);
const rtf = Number(b.rtf ?? b.avg_rtf);
if (Number.isFinite(dur) && dur > 0) parts.push(`${dur.toFixed(1)}s`);
if (Number.isFinite(speed) && speed > 0) parts.push(`${speed.toFixed(2)}x`);
else if (Number.isFinite(rtf) && rtf > 0) parts.push(`RTF ${rtf.toFixed(2)}`);
return parts.join(' · ');
}
function batchColor(id) {
const palette = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16'];
let h = 0;
for (let i = 0; i < String(id || '').length; i++) h = (h * 31 + String(id).charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function batchAvatar(v) {
const meta = v?.meta || libraryVoice(v?.id) || {};
if (meta.has_picture) return `<img class="batch-voice-avatar" src="/api/voice/picture/${encodeURIComponent(v.id)}" alt="">`;
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 28) : null;
if (icon) return icon.replace(/vp-avatar/g, 'batch-voice-avatar');
const init = (v.label || v.id || '?')[0].toUpperCase();
return `<span class="batch-voice-avatar batch-voice-avatar-init" style="background:${batchColor(meta.lang || v.id)}">${escHtml(init)}</span>`;
}
function batchHistoryExtraFields(voice, backend) {
const item = batchVoices.find(v => v.id === voice) || {};
const meta = item.meta || libraryVoice(voice) || {};
return {
voiceMeta: {
label: meta.display_name || meta.name || item.label || voice,
lang: meta.lang || meta.language || '',
gender: meta.gender || '',
flag: meta.flag || '',
avatar: meta.avatar || '',
has_picture: !!meta.has_picture,
},
device: typeof backendComputeDevice === 'function' ? backendComputeDevice(backend) : '',
};
}
function batchSearchText(v) {
const meta = v.meta || {};
return [v.id, v.label, meta.display_name, meta.name, meta.lang, meta.language, Array.isArray(meta.tags) ? meta.tags.join(' ') : meta.tags, batchVoiceStats(v)]
.filter(Boolean).join(' ').toLowerCase();
}
function syncRunSelectedLabel() {
const count = batchSelected.size;
if (perfRunSelectedBtn) {
perfRunSelectedBtn.disabled = count === 0;
perfRunSelectedBtn.innerHTML = `<span class="mdi mdi-playlist-play"></span> ${count ? `Run ${count} selected voice${count === 1 ? '' : 's'}` : 'Run selected voices'}`;
}
}
function updateSelCount() {
const total = batchVoices.length;
const checked = batchSelected.size;
const visible = filteredBatchVoices().length;
if (batchSelCount) batchSelCount.textContent = total ? `${checked} of ${total} selected${visible !== total ? ` · ${visible} shown` : ''}` : '';
batchRunBtn.disabled = checked === 0;
syncRunSelectedLabel();
}
function filteredBatchVoices() {
const q = (batchSearchInput?.value || '').trim().toLowerCase();
return q ? batchVoices.filter(v => batchSearchText(v).includes(q)) : batchVoices;
}
function renderBatchVoiceList() {
if (!batchVoiceList) return;
const visible = filteredBatchVoices();
if (!batchVoices.length) {
batchVoiceList.innerHTML = '<div class="perf-history-empty">No voices found. Fetch voices above or reload from backend.</div>';
updateSelCount();
return;
}
if (!visible.length) {
batchVoiceList.innerHTML = '<div class="perf-history-empty">No matching voices.</div>';
updateSelCount();
return;
}
const activeIds = new Set(activeVoiceIds());
batchVoiceList.innerHTML = visible.map(v => {
const checked = batchSelected.has(v.id);
const isActive = activeIds.has(v.id);
const stats = batchVoiceStats(v);
const meta = v.meta || libraryVoice(v.id) || {};
const flag = meta.flag || '';
const lang = meta.lang || meta.language || '';
const genderMap = { M: '♂', F: '♀', N: '⚬' };
const gender = genderMap[meta.gender] || '';
const langGender = [flag || lang, gender].filter(Boolean).join(' ');
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}${checked ? ' is-selected' : ''}">
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}" aria-label="Benchmark ${escHtml(v.label || v.id)}"${checked ? ' checked' : ''}>
${batchAvatar(v)}
<span class="batch-voice-main"><span class="batch-voice-name">${escHtml(v.label || v.id)}</span><span class="batch-voice-id">${escHtml(v.id)}</span></span>
${langGender ? `<span class="batch-voice-meta">${escHtml(langGender)}</span>` : ''}
${stats ? `<span class="batch-voice-stats">${escHtml(stats)}</span>` : ''}
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
</label>`;
}).join('');
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', () => {
if (cb.checked) batchSelected.add(cb.value);
else batchSelected.delete(cb.value);
renderBatchVoiceList();
}));
updateSelCount();
}
function buildVoiceList(voices, opts = {}) {
const previous = opts.preserve ? new Set(batchSelected) : null;
const byId = new Map();
(voices || []).map(normalizeBatchVoice).filter(Boolean).forEach(v => byId.set(v.id, v));
batchVoices = Array.from(byId.values()).sort((a, b) => (a.label || a.id).localeCompare(b.label || b.id));
if (previous) {
batchSelected = new Set(batchVoices.filter(v => previous.has(v.id)).map(v => v.id));
} else if (opts.keepSelection) {
batchSelected = new Set(Array.from(batchSelected).filter(id => byId.has(id)));
} else {
const activeIds = new Set(activeVoiceIds());
batchSelected = new Set(batchVoices.filter(v => activeIds.has(v.id)).map(v => v.id));
}
renderBatchVoiceList();
}
async function populateFromLibrary() {
try {
if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') await loadVoiceLibrary();
const voices = (window._voices || []).filter(v => v.enabled !== false).map(v => ({ id: v.id, label: v.display_name || v.name || v.id, meta: v }));
buildVoiceList(voices);
} catch (e) {
if (batchVoiceList) batchVoiceList.innerHTML = `<div class="perf-history-empty">Voice library unavailable: ${escHtml(e.message)}</div>`;
updateSelCount();
}
}
window.populateBatchBenchmarkFromLibrary = populateFromLibrary;
async function loadBatchVoicesFromBackend() {
const backend = batchBackendSel.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
batchLoadBtn.disabled = true;
batchVoiceList.innerHTML = '<div class="perf-history-empty">Loading from backend...</div>';
try {
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const voices = raw.map(v => {
const id = backendVoiceId(v);
return { id, label: id, meta: libraryVoice(id) || (typeof v === 'object' ? v : null) };
}).filter(v => v.id);
buildVoiceList(voices);
} catch(e) {
batchVoiceList.innerHTML = `<div class="perf-history-empty">Failed: ${escHtml(e.message)}</div>`;
updateSelCount();
}
finally { batchLoadBtn.disabled = false; }
}
batchLoadBtn.addEventListener('click', loadBatchVoicesFromBackend);
batchSearchInput?.addEventListener('input', renderBatchVoiceList);
batchSelectAllBtn.addEventListener('click', () => {
batchVoices.forEach(v => batchSelected.add(v.id));
renderBatchVoiceList();
});
batchSelectNoneBtn.addEventListener('click', () => {
batchSelected.clear();
renderBatchVoiceList();
});
window.addEventListener('benchmark:tts-voices-fetched', e => {
const detail = e.detail || {};
if (detail.backend && batchBackendSel) batchBackendSel.value = detail.backend;
buildVoiceList(detail.voices || []);
});
function batchSortValue(r, field) {
const factor = r.ok && r.avgRtf > 0 ? 1 / r.avgRtf : null;
switch (field) {
case 'voice': return (r.voice || '').toLowerCase();
case 'lang': return (r.flag || r.lang || '').toLowerCase();
case 'gender': return (r.gender || '').toLowerCase();
case 'device': return typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend || '').toLowerCase() : '';
case 'latency': return r.ok ? r.avgLatency : 999999;
case 'best': return r.ok ? r.minLatency : 999999;
case 'duration': return r.ok && r.avgAudio > 0 ? r.avgAudio : 999999;
case 'factor': return factor != null ? factor : -1;
case 'time': return r.ok ? r.avgLatency / 1000 : 999999;
case 'wpm': return r.ok && r.avgAudio > 0 && r.wordCount > 0 ? Math.round(r.wordCount / (r.avgAudio / 60)) : -1;
default: return 0;
}
}
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;
const av = batchSortValue(a, batchSortField), bv = batchSortValue(b, batchSortField);
return typeof av === 'string' ? av.localeCompare(bv) * batchSortDir : (av - bv) * batchSortDir;
});
const okCount = batchResults.filter(r => r.ok).length;
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
// Update header sort indicators
document.querySelectorAll('#batch-table th[data-sort]').forEach(th => {
th.classList.remove('perf-sort-asc', 'perf-sort-desc');
if (th.dataset.sort === batchSortField) th.classList.add(batchSortDir === 1 ? 'perf-sort-asc' : 'perf-sort-desc');
});
batchTbody.innerHTML = sorted.map(r => {
const factor = r.ok && r.avgRtf > 0 ? 1 / r.avgRtf : null;
const timeSec = r.ok && r.avgLatency > 0 ? r.avgLatency / 1000 : null;
const wpm = r.ok && r.avgAudio > 0 && r.wordCount > 0 ? Math.round(r.wordCount / (r.avgAudio / 60)) : null;
const factorCls = factor == null ? '' : factor >= 1 ? 'perf-good' : 'perf-slow';
const trend = r.trend ? `<span class="perf-trend-badge ${r.trend.cls}" style="font-size:11px;padding:1px 6px">${r.trend.label}</span>` : '';
const device = typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend || batchBackendSel.value) : 'Unknown';
const deviceCls = typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend || batchBackendSel.value) : '';
const genderMap = { M: '♂', F: '♀', N: '⚬' };
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
<td>${escHtml(r.voice)}</td>
<td>${escHtml(r.flag || r.lang || '—')}</td>
<td>${escHtml(genderMap[r.gender] || '—')}</td>
<td><span class="bench-device ${deviceCls}" title="Inferred from the selected TTS backend metadata">${escHtml(device)}</span></td>
<td>${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'}</td>
<td>${r.ok ? r.minLatency + ' ms' : '—'}</td>
<td>${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(1) + 's' : '—'}</td>
<td class="${factorCls}">${factor != null ? factor.toFixed(2) + '×' : '—'}${trend}</td>
<td>${timeSec != null ? timeSec.toFixed(1) + 's' : '—'}</td>
<td>${wpm != null ? wpm + ' wpm' : '—'}</td>
<td>${r.ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error || 'Failed')}</span>`}</td>
</tr>`;
}).join('');
}
// Wire sortable headers
document.querySelector('#batch-table')?.addEventListener('click', e => {
const th = e.target.closest('th[data-sort]');
if (!th) return;
const field = th.dataset.sort;
if (field === batchSortField) batchSortDir *= -1;
else { batchSortField = field; batchSortDir = field === 'voice' || field === 'lang' || field === 'gender' ? 1 : -1; }
renderBatchResults();
});
async function runBatchBenchmark() {
const topBackend = $('perf-backend-select')?.value || '';
if (topBackend && batchBackendSel && batchBackendSel.value !== topBackend) batchBackendSel.value = topBackend;
const topRuns = $('perf-runs')?.value || '';
if (topRuns && batchRunsSel && Array.from(batchRunsSel.options).some(o => o.value === topRuns)) batchRunsSel.value = topRuns;
const singleVoice = $('perf-voice-select')?.value || '';
if (!batchSelected.size && singleVoice && batchVoices.some(v => v.id === singleVoice)) {
batchSelected.add(singleVoice);
renderBatchVoiceList();
}
const backend = batchBackendSel.value;
const text = $('perf-text')?.value.trim();
const runs = parseInt(batchRunsSel.value) || 1;
const validIds = new Set(batchVoices.map(v => v.id));
const selected = Array.from(batchSelected).filter(id => validIds.has(id));
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 one or more voices first', 'error'); return; }
batchStopped = false;
batchResults = [];
batchRunBtn.disabled = true;
if (perfRunSelectedBtn) perfRunSelectedBtn.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 _vm = batchVoices.find(bv => bv.id === voice);
const _meta = _vm?.meta || libraryVoice(voice) || {};
const entry = { voice, backend, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, wordCount: text ? text.trim().split(/\s+/).length : 0, lang: _meta.lang || _meta.language || '', flag: _meta.flag || '', gender: _meta.gender || '', 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((sum, r) => sum + 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((sum, r) => sum + r.dur, 0) / durRows.length : 0;
const rtfArr = durRows.map(r => r.lat / 1000 / r.dur);
entry.avgRtf = rtfArr.length ? rtfArr.reduce((sum, value) => sum + value, 0) / rtfArr.length : 0;
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,
...batchHistoryExtraFields(voice, backend),
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;
syncRunSelectedLabel();
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'
);
// Sync results into voice meta so My Voices Factor/WPM columns update
const syncable = batchResults.filter(r => r.ok && r.avgRtf > 0 && r.avgAudio > 0);
if (syncable.length && typeof window.loadVoiceLibrary === 'function') {
const benchText = ($('perf-text')?.value || '').trim();
await Promise.allSettled(syncable.map(r =>
fetch('/api/voice/meta', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
voice_id: r.voice,
benchmark: {
ok: true,
text: benchText,
elapsed_sec: r.avgLatency / 1000,
audio_sec: r.avgAudio,
speed: 1 / r.avgRtf,
rtf: r.avgRtf,
ttfa_ms: r.minLatency,
}
})
})
));
await window.loadVoiceLibrary();
}
}
batchRunBtn.addEventListener('click', runBatchBenchmark);
perfRunSelectedBtn?.addEventListener('click', runBatchBenchmark);
batchStopBtn.addEventListener('click', () => {
batchStopped = true;
batchStopBtn.disabled = true;
batchProgLabel.textContent = 'Stopping after current voice...';
});
})();
// ── Benchmark section ─────────────────────────────────────────────────────
(function initBenchmarkSection() {
let initialized = false;
let benchmarkDataLoaded = 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;
}
function benchmarkModelList(engine) {
const values = [];
const add = (v) => {
const s = String(v || '').trim();
if (s && !values.includes(s)) values.push(s);
};
add(engine.model);
(engine.models || []).forEach(add);
if (!values.length) add('whisper-1');
return values;
}
async function fetchBenchmarkModels(engine) {
if (!engine?.url) return [];
try {
const d = await fetchJson('/api/engine-models?type=stt&url=' + encodeURIComponent(engine.url));
return Array.isArray(d.models) ? d.models.filter(Boolean) : [];
} catch (e) {
console.warn('[benchmark models]', engine.label || engine.id, e);
return [];
}
}
async function hydrateBenchmarkModels(engines) {
await Promise.all(engines.map(async (engine) => {
const found = await fetchBenchmarkModels(engine);
engine.models = benchmarkModelList({ ...engine, models: found.length ? found : engine.models });
if (!engine.models.includes(engine.model)) engine.model = engine.models[0] || engine.model || 'whisper-1';
}));
}
function renderBenchmarkModelOptions(index, filter = '') {
const engine = sttEngines[index];
const picker = document.querySelector(`.bench-model-picker[data-index="${index}"]`);
const list = picker?.querySelector('.bench-model-options');
if (!engine || !list) return;
const qv = String(filter || '').trim().toLowerCase();
const models = benchmarkModelList(engine).filter(m => !qv || m.toLowerCase().includes(qv));
list.innerHTML = models.length ? models.map(m => `
<button type="button" class="bench-model-option${m === engine.model ? ' selected' : ''}" data-model="${esc(m)}">
<span>${esc(m)}</span>${m === engine.model ? '<span class="mdi mdi-check"></span>' : ''}
</button>`).join('') : '<div class="bench-model-empty">No matching models</div>';
}
function setBenchmarkModel(index, model) {
const engine = sttEngines[index];
if (!engine) return;
engine.model = String(model || '').trim() || engine.model || 'whisper-1';
const picker = document.querySelector(`.bench-model-picker[data-index="${index}"]`);
const hidden = picker?.querySelector('.bench-engine-model');
const label = picker?.querySelector('.bench-model-label');
if (hidden) hidden.value = engine.model;
if (label) label.textContent = engine.model;
picker?.querySelector('.bench-model-menu')?.setAttribute('hidden', '');
picker?.querySelector('.bench-model-trigger')?.classList.remove('open');
}
function closeBenchmarkModelPickers(except = null) {
document.querySelectorAll('.bench-model-picker').forEach(picker => {
if (except && picker === except) return;
picker.querySelector('.bench-model-menu')?.setAttribute('hidden', '');
picker.querySelector('.bench-model-trigger')?.classList.remove('open');
});
}
function bindBenchmarkModelPickers() {
document.querySelectorAll('.bench-model-picker').forEach(picker => {
picker.addEventListener('click', (ev) => ev.stopPropagation());
const index = Number(picker.dataset.index);
const trigger = picker.querySelector('.bench-model-trigger');
const menu = picker.querySelector('.bench-model-menu');
const search = picker.querySelector('.bench-model-search');
renderBenchmarkModelOptions(index);
trigger?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const opening = menu?.hasAttribute('hidden');
closeBenchmarkModelPickers(picker);
if (opening) {
menu?.removeAttribute('hidden');
trigger.classList.add('open');
search.value = '';
renderBenchmarkModelOptions(index);
setTimeout(() => search?.focus(), 0);
} else {
menu?.setAttribute('hidden', '');
trigger.classList.remove('open');
}
});
search?.addEventListener('input', () => renderBenchmarkModelOptions(index, search.value));
picker.querySelector('.bench-model-options')?.addEventListener('click', (ev) => {
const opt = ev.target.closest('.bench-model-option');
if (!opt) return;
setBenchmarkModel(index, opt.dataset.model || '');
});
});
}
async function loadBenchmarkSttEngines() {
const list = q('bench-stt-engines');
const count = q('bench-stt-engine-count');
if (!list) return;
list.innerHTML = '<div class="bench-engine-empty">Checking STT engines...</div>';
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',
models: b.models || [],
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,
models: c.models || [],
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 ? `Loading models for ${sttEngines.length} engine${sttEngines.length === 1 ? '' : 's'}...` : 'No engines found.';
await hydrateBenchmarkModels(sttEngines);
if (count) count.textContent = sttEngines.length ? `${sttEngines.length} engine${sttEngines.length === 1 ? '' : 's'} loaded.` : 'No engines found.';
if (!sttEngines.length) {
list.innerHTML = '<div class="bench-engine-empty">No STT engines found. Check Engines -> Speech to Text.</div>';
return;
}
list.innerHTML = sttEngines.map((e, i) => `
<label class="bench-engine-item ${e.available ? 'is-online' : 'is-offline'}">
<input type="checkbox" class="bench-stt-engine-check" data-index="${i}" ${e.available ? 'checked' : ''}>
<span class="bench-engine-main">
<strong>${esc(e.label)}</strong>
<small>${esc(e.url || '')}</small>
</span>
<span class="bench-engine-state">${e.available ? 'Running' : 'Stopped'}</span>
<div class="bench-model-picker" data-index="${i}">
<button type="button" class="bench-model-trigger" title="Choose model">
<span class="bench-model-label">${esc(e.model || benchmarkModelList(e)[0] || 'whisper-1')}</span>
<span class="mdi mdi-chevron-down"></span>
</button>
<div class="bench-model-menu" hidden>
<div class="bench-model-search-row"><span class="mdi mdi-magnify"></span><input class="bench-model-search" type="search" placeholder="Search models..."></div>
<div class="bench-model-options"></div>
</div>
<input class="bench-engine-model" data-index="${i}" type="hidden" value="${esc(e.model || benchmarkModelList(e)[0] || 'whisper-1')}">
</div>
</label>`).join('');
bindBenchmarkModelPickers();
}
async function loadBenchmarkVoiceLibrary() {
const sel = q('bench-stt-library-voice');
if (!sel) return;
const picker = window.BenchmarkVoicePicker;
if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Loading voices...', empty: 'Loading voices...' });
else sel.innerHTML = '<option value="">Loading voices...</option>';
try {
const voices = await fetchJson('/api/voices');
const usable = (voices || [])
.filter(v => v && v.enabled !== false && v.has_ref && (v.transcript || '').trim())
.sort((a, b) => String(a.display_name || a.id).localeCompare(String(b.display_name || b.id)));
const items = usable.map(v => ({ id: v.id, label: v.display_name || v.name || v.id, meta: v }));
if (picker) {
picker.populate('bench-stt-library-voice', items, {
placeholder: '-- choose from voice library --',
empty: 'No library voices with reference transcripts found',
});
} else {
sel.innerHTML = '<option value="">-- choose from voice library --</option>' + usable
.map(v => `<option value="${esc(v.id)}">${esc(v.display_name || v.name || v.id)}${v.duration ? ` (${Number(v.duration).toFixed(1)}s)` : ''}</option>`)
.join('');
}
const note = q('bench-stt-source-note');
if (note) note.textContent = usable.length ? `${usable.length} voices with reference text available.` : 'No library voices with reference transcripts found.';
} catch (e) {
if (picker) picker.populate('bench-stt-library-voice', [], { placeholder: 'Voice library unavailable', empty: 'Voice library unavailable' });
else sel.innerHTML = '<option value="">Voice library unavailable</option>';
const note = q('bench-stt-source-note');
if (note) note.textContent = 'Could not load voice library: ' + e.message;
}
}
async function useBenchmarkLibraryVoice() {
const sel = q('bench-stt-library-voice');
const voiceId = sel?.value || '';
if (!voiceId) { say('Choose a voice library sample first', 'error'); return; }
const btn = q('bench-stt-load-voice');
const note = q('bench-stt-source-note');
if (btn) btn.disabled = true;
if (note) note.textContent = 'Loading voice sample...';
try {
const d = await fetchJson('/api/voice-load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: voiceId }),
});
if (q('bench-stt-source-id')) q('bench-stt-source-id').value = d.id || '';
if (q('bench-stt-ref-text')) q('bench-stt-ref-text').value = d.transcript || '';
if (q('bench-stt-audio')) q('bench-stt-audio').value = '';
if (q('bench-stt-ref-file')) q('bench-stt-ref-file').value = '';
if (note) note.textContent = `${d.voice_id || voiceId} loaded from library${d.duration ? ` (${Number(d.duration).toFixed(1)}s)` : ''}.`;
say('Voice library sample loaded', 'success');
} catch (e) {
if (note) note.textContent = 'Voice load failed.';
say('Voice load failed: ' + e.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
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 sourceId = q('bench-stt-source-id')?.value || '';
const refFile = q('bench-stt-ref-file')?.files?.[0];
const refText = q('bench-stt-ref-text')?.value || '';
const engines = selectedSttEngines();
if (!audio && !sourceId) { say('Choose an audio file or load a voice library sample 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 = `<tr><td colspan="6" class="bench-empty">Benchmarking ${engines.length} engines...</td></tr>`;
try {
const fd = new FormData();
if (audio) fd.append('audio', audio, audio.name);
if (sourceId) fd.append('source_id', sourceId);
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 = `<tr><td colspan="6" class="bench-empty perf-err">${esc(e.message)}</td></tr>`;
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]) => `<span class="perf-stat"><strong>${esc(val)}</strong> ${esc(label)}</span>`).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 `<tr class="${r.ok ? '' : 'perf-row-error'}">
<td><strong>${esc(r.engine)}</strong><small>${esc(r.url || '')}</small></td>
<td>${esc(r.model || '')}</td>
<td><span class="bench-device ${String(r.device).includes('CUDA') ? 'gpu' : String(r.device).includes('CPU') ? 'cpu' : ''}">${esc(r.device || 'Unknown')}</span></td>
<td>${fmtSec(r.time_sec)}</td>
<td class="${r.ok ? 'perf-ok' : 'perf-err'}">${esc(acc)}</td>
<td class="bench-output" title="${esc(out)}">${esc(out)}</td>
</tr>`;
}).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 = '<tr><td colspan="7" class="bench-empty">Benchmarking voices...</td></tr>';
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 = `<tr><td colspan="7" class="bench-empty perf-err">${esc(e.message)}</td></tr>`;
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]) => `<span class="perf-stat"><strong>${esc(val)}</strong> ${esc(label)}</span>`).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 `<tr class="${b.ok ? '' : 'perf-row-error'}">
<td><strong>${esc(row.voice_id)}</strong></td>
<td>${b.ttfa_ms == null ? '—' : Math.round(b.ttfa_ms) + 'ms'}</td>
<td>${fmtSec(b.elapsed_sec)}</td>
<td>${fmtSec(b.audio_sec)}</td>
<td>${b.rtf == null ? '—' : Number(b.rtf).toFixed(2)}</td>
<td>${b.speed == null ? '—' : Number(b.speed).toFixed(2) + 'x'}</td>
<td class="${b.ok ? (b.realtime_ok === false ? 'perf-slow' : 'perf-ok') : 'perf-err'}" title="${esc((b.advice || []).join(' '))}">${esc(status)}</td>
</tr>`;
}).join('') : '<tr><td colspan="7" class="bench-empty">No voices benchmarked.</td></tr>';
}
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 => `<option value="${esc(b.id)}" ${!b.available ? 'disabled' : ''}>${b.available ? '✓' : '✗'} ${esc(b.label)}</option>`).join('') || '<option value="configured">Configured</option>';
} 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 => `<option value="${esc(b.id)}" ${!b.available ? 'disabled' : ''}>${b.available ? '✓' : '✗'} ${esc(b.label)}</option>`).join('') || '<option value="voice_clone">Voice Clone</option>';
} 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 => `<option value="${esc(m)}">${esc(m)}</option>`).join('') : '<option value="">No models found</option>';
} catch (e) {
if (sel) sel.innerHTML = '<option value="">Fetch failed</option>';
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';
const picker = window.BenchmarkVoicePicker;
if (btn) btn.disabled = true;
try {
const raw = await fetchJson('/api/tts-voices?backend=' + encodeURIComponent(backend));
const items = (Array.isArray(raw) ? raw : []).map(v => {
const id = typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name));
return id ? { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) } : null;
}).filter(Boolean);
if (picker) picker.populate('bench-turn-voice', items, { placeholder: 'Fetch voices', empty: 'No voices' });
else if (sel) sel.innerHTML = items.length ? items.map(v => `<option value="${esc(v.id)}">${esc(v.id)}</option>`).join('') : '<option value="">No voices</option>';
} catch (e) {
if (picker) picker.populate('bench-turn-voice', [], { placeholder: 'Fetch failed', empty: 'Fetch failed' });
else if (sel) sel.innerHTML = '<option value="">Fetch failed</option>';
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 = `<span class="conv-hist-num">#${turnHistoryCount}</span><span class="${ok ? 'conv-hist-ok' : 'conv-hist-err'}"><span class="mdi ${ok ? 'mdi-check-circle-outline' : 'mdi-alert-outline'}"></span></span><span class="conv-hist-time ${ok ? 'conv-hist-ok' : 'conv-hist-err'}">${fmtMs(total)}</span>`;
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)));
if (window.BenchmarkVoicePicker) {
BenchmarkVoicePicker.upgrade('bench-stt-library-voice', { placeholder: '-- choose from voice library --', empty: 'No library voices with reference transcripts found' });
BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' });
BenchmarkVoicePicker.upgrade('bench-turn-voice', { placeholder: 'Fetch voices', empty: 'No voices' });
}
document.addEventListener('click', () => closeBenchmarkModelPickers());
q('bench-stt-refresh')?.addEventListener('click', loadBenchmarkSttEngines);
q('bench-stt-load-voice')?.addEventListener('click', useBenchmarkLibraryVoice);
q('bench-stt-audio')?.addEventListener('change', () => { if (q('bench-stt-source-id')) q('bench-stt-source-id').value = ''; });
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 (window.BenchmarkVoicePicker) BenchmarkVoicePicker.populate('bench-turn-voice', [], { placeholder: 'Fetch voices', empty: 'No voices' });
else if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = '<option value="">Fetch voices</option>';
});
}
function loadBenchmarkSectionData() {
bindBenchmarkSection();
if (benchmarkDataLoaded || !q('bench-stt-run')) return;
benchmarkDataLoaded = true;
loadBenchmarkSttEngines();
loadBenchmarkVoiceLibrary();
initTurnControls();
}
window.loadBenchmarkSectionData = loadBenchmarkSectionData;
bindBenchmarkSection();
if (!initialized && document.body) {
const obs = new MutationObserver(() => bindBenchmarkSection());
obs.observe(document.body, { childList: true, subtree: true });
}
})();