637 lines
30 KiB
JavaScript
637 lines
30 KiB
JavaScript
// ── Batch benchmark ───────────────────────────────────────────────────────
|
|
|
|
(function initBatchBenchmark() {
|
|
const batchBackendSel = $('batch-backend-select');
|
|
const batchRunsSel = $('batch-runs');
|
|
const batchLoadBtn = $('batch-load-voices-btn');
|
|
const batchSelectAllBtn = $('batch-select-all-btn');
|
|
const batchSelectNoneBtn= $('batch-select-none-btn');
|
|
const batchVoiceList = $('batch-voice-list');
|
|
const batchSelCount = $('batch-selected-count');
|
|
const batchRunBtn = $('batch-run-btn');
|
|
const batchStopBtn = $('batch-stop-btn');
|
|
const batchProgress = $('batch-progress');
|
|
const batchProgLabel = $('batch-progress-label');
|
|
const batchProgCount = $('batch-progress-count');
|
|
const batchProgBar = $('batch-progress-bar');
|
|
const batchResultsCard = $('batch-results-card');
|
|
const batchResultsLabel = $('batch-results-label');
|
|
const batchTbody = $('batch-tbody');
|
|
if (!batchRunBtn) return;
|
|
|
|
let batchStopped = false;
|
|
let batchResults = [];
|
|
|
|
function populateBatchBackends() {
|
|
if (!batchBackendSel) return;
|
|
const cur = batchBackendSel.value;
|
|
batchBackendSel.innerHTML = availableTtsBackends().map(b =>
|
|
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
|
|
).join('') || '<option value="">No backends available</option>';
|
|
}
|
|
populateBatchBackends();
|
|
|
|
function updateSelCount() {
|
|
if (!batchVoiceList || !batchSelCount) return;
|
|
const total = batchVoiceList.querySelectorAll('.batch-voice-cb').length;
|
|
const checked = batchVoiceList.querySelectorAll('.batch-voice-cb:checked').length;
|
|
batchSelCount.textContent = total ? `${checked} of ${total} selected` : '';
|
|
batchRunBtn.disabled = checked === 0;
|
|
}
|
|
|
|
function buildVoiceList(voices) {
|
|
const activeIds = new Set(activeVoiceIds());
|
|
if (!voices.length) {
|
|
batchVoiceList.innerHTML = '<div class="perf-history-empty">No voices found.</div>';
|
|
updateSelCount();
|
|
return;
|
|
}
|
|
batchVoiceList.innerHTML = voices.map(v => {
|
|
const isActive = activeIds.has(v.id);
|
|
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}">
|
|
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}"${isActive ? ' checked' : ''}>
|
|
<span class="batch-voice-name">${escHtml(v.label)}</span>
|
|
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
|
|
</label>`;
|
|
}).join('');
|
|
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', updateSelCount));
|
|
updateSelCount();
|
|
}
|
|
|
|
// Pre-populate from My Voices library on init
|
|
function populateFromLibrary() {
|
|
const voices = (_voices || [])
|
|
.filter(v => v.enabled !== false)
|
|
.map(v => ({ id: v.id, label: v.display_name || v.name || v.id }))
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
buildVoiceList(voices);
|
|
}
|
|
populateFromLibrary();
|
|
|
|
batchLoadBtn.addEventListener('click', async () => {
|
|
const backend = batchBackendSel.value;
|
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
|
batchLoadBtn.disabled = true;
|
|
batchVoiceList.innerHTML = '<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 => ({ id: backendVoiceId(v), label: backendVoiceId(v) }))
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
buildVoiceList(voices);
|
|
} catch(e) {
|
|
batchVoiceList.innerHTML = `<div class="perf-history-empty">Failed: ${escHtml(e.message)}</div>`;
|
|
}
|
|
finally { batchLoadBtn.disabled = false; }
|
|
});
|
|
|
|
batchSelectAllBtn.addEventListener('click', () => {
|
|
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = true);
|
|
updateSelCount();
|
|
});
|
|
batchSelectNoneBtn.addEventListener('click', () => {
|
|
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = false);
|
|
updateSelCount();
|
|
});
|
|
|
|
function renderBatchResults() {
|
|
if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; }
|
|
batchResultsCard.style.display = '';
|
|
const sorted = batchResults.slice().sort((a, b) => {
|
|
if (a.ok && !b.ok) return -1;
|
|
if (!a.ok && b.ok) return 1;
|
|
return (a.avgRtf || 999) - (b.avgRtf || 999);
|
|
});
|
|
const okCount = batchResults.filter(r => r.ok).length;
|
|
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
|
|
batchTbody.innerHTML = sorted.map(r => {
|
|
const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : '';
|
|
const trend = r.trend ? `<span class="perf-trend-badge ${r.trend.cls}" style="font-size:11px;padding:1px 6px">${r.trend.label}</span>` : '';
|
|
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
|
|
<td>${escHtml(r.voice)}</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(2) : '—'}</td>
|
|
<td class="${rtfCls}">${r.ok && r.avgRtf ? r.avgRtf.toFixed(2) : '—'}${trend}</td>
|
|
<td>${r.ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error || 'Failed')}</span>`}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
batchRunBtn.addEventListener('click', async () => {
|
|
const backend = batchBackendSel.value;
|
|
const text = $('perf-text')?.value.trim();
|
|
const runs = parseInt(batchRunsSel.value) || 1;
|
|
const selected = [...(batchVoiceList?.querySelectorAll('.batch-voice-cb:checked') || [])].map(cb => cb.value);
|
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
|
if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; }
|
|
if (!selected.length) { toast('Select at least one voice', 'error'); return; }
|
|
|
|
batchStopped = false;
|
|
batchResults = [];
|
|
batchRunBtn.disabled = true;
|
|
batchStopBtn.disabled = false;
|
|
batchProgress.style.display = '';
|
|
batchResultsCard.style.display = 'none';
|
|
|
|
for (let vi = 0; vi < selected.length; vi++) {
|
|
if (batchStopped) break;
|
|
const voice = selected[vi];
|
|
batchProgLabel.textContent = `${voice} (${vi + 1} / ${selected.length})`;
|
|
batchProgCount.textContent = `${vi + 1} / ${selected.length}`;
|
|
batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`;
|
|
|
|
const entry = { voice, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' };
|
|
const rowRunResults = [];
|
|
|
|
for (let ri = 0; ri < runs; ri++) {
|
|
if (batchStopped) break;
|
|
try {
|
|
const t0 = performance.now();
|
|
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
|
|
const lat = Math.round(performance.now() - t0);
|
|
let dur = 0;
|
|
try {
|
|
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
const buf = await ac.decodeAudioData(await blob.arrayBuffer());
|
|
dur = buf.duration; ac.close();
|
|
} catch(_) {}
|
|
rowRunResults.push({ lat, dur });
|
|
} catch(e) { entry.error = e.message; break; }
|
|
}
|
|
|
|
if (rowRunResults.length) {
|
|
entry.ok = true;
|
|
entry.avgLatency = rowRunResults.reduce((s, r) => s + r.lat, 0) / rowRunResults.length;
|
|
entry.minLatency = Math.min(...rowRunResults.map(r => r.lat));
|
|
const durRows = rowRunResults.filter(r => r.dur > 0);
|
|
entry.avgAudio = durRows.length ? durRows.reduce((s, r) => s + r.dur, 0) / durRows.length : 0;
|
|
const rtfArr = durRows.map(r => r.lat / 1000 / r.dur);
|
|
entry.avgRtf = rtfArr.length ? rtfArr.reduce((s, v) => s + v, 0) / rtfArr.length : 0;
|
|
|
|
// compute trend vs previous session for this voice
|
|
if (entry.avgRtf > 0) {
|
|
const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
|
|
if (prev.length) {
|
|
const prevRtf = prev[prev.length - 1].avgRtf;
|
|
const delta = entry.avgRtf - prevRtf;
|
|
const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
|
|
if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: '→ stable' };
|
|
else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `↓ ${pct.toFixed(0)}% faster` };
|
|
else entry.trend = { cls: 'perf-trend-worse', label: `↑ ${pct.toFixed(0)}% slower` };
|
|
}
|
|
perfHistoryAdd({
|
|
ts: Date.now(), backend, voice, textLen: text.length,
|
|
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
|
|
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
|
|
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
batchResults.push(entry);
|
|
renderBatchResults();
|
|
}
|
|
|
|
batchProgBar.style.width = '100%';
|
|
batchProgLabel.textContent = batchStopped
|
|
? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.`
|
|
: `Done — ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`;
|
|
batchStopBtn.disabled = true;
|
|
batchRunBtn.disabled = false;
|
|
renderPerfHistory();
|
|
|
|
const ok = batchResults.filter(r => r.ok);
|
|
const best = ok.slice().sort((a, b) => a.avgRtf - b.avgRtf)[0];
|
|
toast(
|
|
`Batch done: ${ok.length}/${batchResults.length} OK` +
|
|
(best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''),
|
|
ok.length < batchResults.length ? 'error' : 'success'
|
|
);
|
|
});
|
|
|
|
batchStopBtn.addEventListener('click', () => {
|
|
batchStopped = true;
|
|
batchStopBtn.disabled = true;
|
|
batchProgLabel.textContent = 'Stopping after current voice…';
|
|
});
|
|
})();
|
|
|
|
// ── Benchmark section ─────────────────────────────────────────────────────
|
|
|
|
(function initBenchmarkSection() {
|
|
let initialized = false;
|
|
let sttEngines = [];
|
|
let turnHistoryCount = 0;
|
|
|
|
const q = (id) => document.getElementById(id);
|
|
const fmtMs = (ms) => ms == null ? '—' : ms >= 1000 ? (ms / 1000).toFixed(2) + 's' : Math.round(ms) + 'ms';
|
|
const fmtSec = (sec) => sec == null ? '—' : Number(sec).toFixed(Number(sec) < 10 ? 2 : 1) + 's';
|
|
const esc = (s) => typeof escHtml === 'function' ? escHtml(s) : String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
const say = (msg, type = '') => typeof toast === 'function' ? toast(msg, type) : console.log(msg);
|
|
|
|
function setBenchTab(name) {
|
|
document.querySelectorAll('.bench-tab').forEach(btn => btn.classList.toggle('active', btn.dataset.benchTab === name));
|
|
document.querySelectorAll('.bench-pane').forEach(pane => pane.classList.toggle('active', pane.dataset.benchPane === name));
|
|
}
|
|
|
|
async function fetchJson(url, opts) {
|
|
const r = await fetch(url, opts);
|
|
if (!r.ok) {
|
|
let detail = r.statusText || `HTTP ${r.status}`;
|
|
try { const d = await r.json(); detail = typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail || d); }
|
|
catch (_) { try { detail = await r.text(); } catch (_) {} }
|
|
throw new Error(detail || `HTTP ${r.status}`);
|
|
}
|
|
return r.json();
|
|
}
|
|
|
|
function dedupeEngines(items) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const item of items) {
|
|
const key = `${item.url || ''}|${item.model || ''}|${item.id || item.label || ''}`;
|
|
if (seen.has(key) || !item.url) continue;
|
|
seen.add(key);
|
|
out.push(item);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function loadBenchmarkSttEngines() {
|
|
const list = q('bench-stt-engines');
|
|
const count = q('bench-stt-engine-count');
|
|
if (!list) return;
|
|
list.innerHTML = '<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',
|
|
available: !!b.available,
|
|
source: 'configured',
|
|
}));
|
|
} catch (e) {
|
|
console.warn('[benchmark stt backends]', e);
|
|
}
|
|
|
|
try {
|
|
const d = await fetchJson('/api/local-containers');
|
|
(d.containers || []).filter(c => c.role === 'stt' || c.role === 'stt+tts').forEach(c => {
|
|
const url = c.port ? `http://host.docker.internal:${c.port}` : '';
|
|
const model = settings?.engine_local_models?.['dc-' + c.name] || c.model || 'whisper-1';
|
|
items.push({
|
|
id: c.name,
|
|
label: c.label || c.name,
|
|
url,
|
|
model,
|
|
available: c.running === true || c.status === 'running',
|
|
source: c.stack || 'docker',
|
|
});
|
|
});
|
|
} catch (e) {
|
|
console.warn('[benchmark local containers]', e);
|
|
}
|
|
|
|
sttEngines = dedupeEngines(items);
|
|
if (count) count.textContent = sttEngines.length ? `${sttEngines.length} engine${sttEngines.length === 1 ? '' : 's'} loaded.` : 'No engines found.';
|
|
if (!sttEngines.length) {
|
|
list.innerHTML = '<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>
|
|
<input class="bench-engine-model" data-index="${i}" type="text" value="${esc(e.model || '')}" placeholder="model">
|
|
</label>`).join('');
|
|
}
|
|
|
|
function selectedSttEngines() {
|
|
return [...document.querySelectorAll('.bench-stt-engine-check:checked')].map(cb => {
|
|
const i = Number(cb.dataset.index);
|
|
const item = { ...sttEngines[i] };
|
|
const modelInput = document.querySelector(`.bench-engine-model[data-index="${i}"]`);
|
|
item.model = modelInput?.value.trim() || item.model || 'whisper-1';
|
|
return item;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
async function runSttBenchmark() {
|
|
const audio = q('bench-stt-audio')?.files?.[0];
|
|
const refFile = q('bench-stt-ref-file')?.files?.[0];
|
|
const refText = q('bench-stt-ref-text')?.value || '';
|
|
const engines = selectedSttEngines();
|
|
if (!audio) { say('Choose an audio file first', 'error'); return; }
|
|
if (!refFile && !refText.trim()) { say('Add a reference .txt or paste reference text', 'error'); return; }
|
|
if (!engines.length) { say('Select at least one STT engine', 'error'); return; }
|
|
const btn = q('bench-stt-run');
|
|
const st = q('bench-stt-status');
|
|
const tbody = q('bench-stt-table')?.querySelector('tbody');
|
|
if (btn) btn.disabled = true;
|
|
if (st) st.textContent = `Running ${engines.length} STT benchmark${engines.length === 1 ? '' : 's'}...`;
|
|
if (tbody) tbody.innerHTML = `<tr><td colspan="6" class="bench-empty">Benchmarking ${engines.length} engines...</td></tr>`;
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('audio', audio, audio.name);
|
|
if (refFile) fd.append('reference_file', refFile, refFile.name);
|
|
fd.append('reference_text', refText);
|
|
fd.append('engines_json', JSON.stringify(engines));
|
|
const d = await fetchJson('/api/stt-benchmark', { method: 'POST', body: fd });
|
|
renderSttResults(d);
|
|
if (st) st.textContent = `Finished ${d.results?.length || 0} STT engines.`;
|
|
say('STT benchmark complete', 'success');
|
|
} catch (e) {
|
|
if (st) st.textContent = 'STT benchmark failed';
|
|
if (tbody) tbody.innerHTML = `<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';
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
const raw = await fetchJson('/api/tts-voices?backend=' + encodeURIComponent(backend));
|
|
const ids = (Array.isArray(raw) ? raw : []).map(v => typeof backendVoiceId === 'function' ? backendVoiceId(v) : (typeof v === 'string' ? v : (v.id || v.voice || v.name))).filter(Boolean);
|
|
if (sel) sel.innerHTML = ids.length ? ids.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('') : '<option value="">No voices</option>';
|
|
} catch (e) {
|
|
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)));
|
|
q('bench-stt-refresh')?.addEventListener('click', loadBenchmarkSttEngines);
|
|
q('bench-stt-run')?.addEventListener('click', runSttBenchmark);
|
|
q('bench-tts-run')?.addEventListener('click', runTtsBenchmark);
|
|
q('bench-turn-fetch-llm')?.addEventListener('click', fetchTurnModels);
|
|
q('bench-turn-fetch-voices')?.addEventListener('click', fetchTurnVoices);
|
|
q('bench-turn-run')?.addEventListener('click', runTurnBenchmark);
|
|
q('bench-turn-tts-backend')?.addEventListener('change', () => { if (q('bench-turn-voice')) q('bench-turn-voice').innerHTML = '<option value="">Fetch voices</option>'; });
|
|
loadBenchmarkSttEngines();
|
|
initTurnControls();
|
|
}
|
|
|
|
bindBenchmarkSection();
|
|
if (!initialized && document.body) {
|
|
const obs = new MutationObserver(() => bindBenchmarkSection());
|
|
obs.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
})();
|