Voices table: removed Length, Duration, Time — only Factor and WPM remain from benchmark data. Setup → Benchmark batch results now shows Duration, Factor (sorted fastest-first, colour-coded), Time, and WPM. Factor replaces Avg RTF with the same data flipped to a more intuitive direction (higher=better). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
981 lines
47 KiB
JavaScript
981 lines
47 KiB
JavaScript
// ── 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();
|
||
|
||
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);
|
||
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>
|
||
${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();
|
||
}
|
||
}
|
||
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 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;
|
||
// sort by Factor descending (higher = faster); Factor = 1/avgRtf
|
||
return (b.avgRtf > 0 ? 1/b.avgRtf : 0) - (a.avgRtf > 0 ? 1/a.avgRtf : 0);
|
||
});
|
||
const okCount = batchResults.filter(r => r.ok).length;
|
||
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
|
||
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) : '';
|
||
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
|
||
<td>${escHtml(r.voice)}</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('');
|
||
}
|
||
|
||
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 entry = { voice, backend, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, wordCount: text ? text.trim().split(/\s+/).length : 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((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'
|
||
);
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
|
||
|
||
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>';
|
||
});
|
||
loadBenchmarkSttEngines();
|
||
loadBenchmarkVoiceLibrary();
|
||
initTurnControls();
|
||
}
|
||
|
||
bindBenchmarkSection();
|
||
if (!initialized && document.body) {
|
||
const obs = new MutationObserver(() => bindBenchmarkSection());
|
||
obs.observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
})();
|