Add benchmark mode tabs

This commit is contained in:
mARTin-B78 2026-06-05 14:16:35 +02:00
parent 9c2e507ccf
commit 10a11132c0
4 changed files with 807 additions and 1 deletions

View File

@ -2,7 +2,10 @@
from __future__ import annotations
import asyncio
import json
import re
import struct
import time
import uuid
from pathlib import Path
@ -274,6 +277,142 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
return _transcription_text_from_response(resp), backend
def _word_tokens(text: str) -> list[str]:
return re.findall(r"[\w']+", (text or "").lower(), flags=re.UNICODE)
def _word_accuracy(reference: str, hypothesis: str) -> float | None:
ref = _word_tokens(reference)
hyp = _word_tokens(hypothesis)
if not ref:
return None
prev = list(range(len(hyp) + 1))
for i, rw in enumerate(ref, 1):
cur = [i]
for j, hw in enumerate(hyp, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (0 if rw == hw else 1)))
prev = cur
return max(0.0, (1.0 - prev[-1] / max(1, len(ref))) * 100.0)
def _stt_device_hint(engine: dict, settings: dict) -> str:
text = " ".join(str(engine.get(k, "")) for k in ("id", "backend", "label", "url", "model", "source")).lower()
if any(token in text for token in ("nvidia", "parakeet", "nemotron", "whisperx", "faster", "cuda", "gpu", "blackwell")):
return "CUDA/GPU"
if "cpu" in text or "whisper_cpp" in text or "whisper.cpp" in text:
return "CPU"
backend = _clean_stt_backend(str(engine.get("backend") or engine.get("id") or ""))
metrics = _STT_BACKEND_METRICS.get(backend, {})
speed = str(metrics.get("speed", "")).lower()
if "gpu" in speed or "cuda" in speed:
return "CUDA/GPU"
if "cpu" in speed:
return "CPU"
return "Unknown"
def _transcribe_url_for_benchmark(src: Path, url: str, model: str, api_key: str = "") -> str:
base = _validate_http_url(url, allow_private=True).rstrip("/")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
last_resp: requests.Response | None = None
last_error = ""
for path in ("/v1/audio/transcriptions", "/transcribe", "/audio/transcriptions"):
try:
with src.open("rb") as f:
resp = requests.post(
f"{base}{path}",
files={"file": ("benchmark.wav", f, "audio/wav")},
data={"model": model or "whisper-1", "response_format": "text"},
headers=headers,
timeout=_STT_REQUEST_TIMEOUT,
)
last_resp = resp
if resp.status_code in {404, 405}:
continue
if not resp.ok and (model or "whisper-1") != "whisper-1":
with src.open("rb") as f:
retry = requests.post(
f"{base}{path}",
files={"file": ("benchmark.wav", f, "audio/wav")},
data={"model": "whisper-1", "response_format": "text"},
headers=headers,
timeout=_STT_REQUEST_TIMEOUT,
)
if retry.ok:
return _transcription_text_from_response(retry)
last_resp = retry
if resp.ok:
return _transcription_text_from_response(resp)
try:
body = resp.json()
last_error = body.get("detail") or body.get("error") or body.get("message") or str(body)
except Exception:
last_error = resp.text[:400].strip()
except Exception as e:
last_error = str(e)
status = f"HTTP {last_resp.status_code}" if last_resp is not None else "STT request failed"
raise RuntimeError(f"{status}: {last_error or 'No compatible transcription endpoint'}")
def _benchmark_stt_engine(src: Path, engine: dict, settings: dict, reference: str) -> dict:
label = str(engine.get("label") or engine.get("id") or engine.get("backend") or "STT")
backend = str(engine.get("backend") or engine.get("id") or "").strip()
url = str(engine.get("url") or "").strip()
model = str(engine.get("model") or "").strip() or (backend and _stt_backend_model(backend)) or "whisper-1"
device = _stt_device_hint(engine, settings)
t0 = time.perf_counter()
try:
if url:
api_key = _stt_backend_api_key(settings, _clean_stt_backend(backend)) if backend else settings.get("whisper_api_key", "")
text = _transcribe_url_for_benchmark(src, url, model, api_key)
used_url = _validate_http_url(url, allow_private=True).rstrip("/")
else:
text, used_backend = _transcribe_audio(src, settings, backend or "configured")
used_url = _stt_backend_url(settings, used_backend)
model = model or _stt_backend_model(used_backend)
elapsed = time.perf_counter() - t0
accuracy = _word_accuracy(reference, text)
return {
"ok": True,
"engine": label,
"backend": backend,
"url": used_url,
"model": model,
"device": device,
"time_sec": elapsed,
"accuracy": accuracy,
"output": text,
}
except Exception as e:
return {
"ok": False,
"engine": label,
"backend": backend,
"url": url,
"model": model,
"device": device,
"time_sec": time.perf_counter() - t0,
"accuracy": None,
"output": "",
"error": str(e),
}
async def _default_stt_benchmark_engines() -> list[dict]:
payload = await stt_backends()
engines = []
for item in payload.get("backends", []):
if item.get("available"):
engines.append({
"id": item.get("id"),
"backend": item.get("id"),
"label": item.get("label"),
"url": item.get("url"),
"model": item.get("model") or (item.get("models") or ["whisper-1"])[0],
})
return engines
# ── STT routes ────────────────────────────────────────────────────────────────
@router.get("/api/stt-backends")
@ -380,3 +519,63 @@ async def transcribe_bytes(
p.unlink(missing_ok=True)
except Exception:
pass
@router.post("/api/stt-benchmark")
async def stt_benchmark(
audio: UploadFile = File(...),
reference_text: str = Form(""),
reference_file: UploadFile | None = File(None),
engines_json: str = Form(""),
):
suffix = Path(audio.filename or "audio.wav").suffix.lower() or ".wav"
if suffix not in (_AUDIO_EXTS | _UPLOAD_EXTS):
raise HTTPException(400, "Unsupported audio type")
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_stt_bench{suffix}"
wav_tmp = tmp
try:
with tmp.open("wb") as f:
_copy_limited(audio.file, f, _MAX_UPLOAD_BYTES)
if suffix != ".wav":
wav_tmp = _to_wav_16k(tmp)
reference = (reference_text or "").strip()
if reference_file is not None:
raw = await reference_file.read()
if raw:
reference = raw[:2_000_000].decode("utf-8", errors="replace").strip()
if not reference:
raise HTTPException(400, "Reference text is required")
try:
parsed = json.loads(engines_json) if engines_json else []
engines = parsed if isinstance(parsed, list) else []
except Exception:
engines = []
if not engines:
engines = await _default_stt_benchmark_engines()
if not engines:
raise HTTPException(400, "No STT engines selected")
settings = _load_settings()
results = []
for engine in engines:
if not isinstance(engine, dict):
continue
results.append(await asyncio.to_thread(_benchmark_stt_engine, wav_tmp, engine, settings, reference))
ok_rows = [r for r in results if r.get("ok")]
best_time = min((r.get("time_sec") for r in ok_rows if isinstance(r.get("time_sec"), (int, float))), default=None)
best_accuracy = max((r.get("accuracy") for r in ok_rows if isinstance(r.get("accuracy"), (int, float))), default=None)
return {
"ok": True,
"reference_words": len(_word_tokens(reference)),
"results": results,
"best_time_sec": best_time,
"best_accuracy": best_accuracy,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, f"STT benchmark failed: {e}")
finally:
for p in {tmp, wav_tmp}:
try:
p.unlink(missing_ok=True)
except Exception:
pass

View File

@ -216,3 +216,421 @@
});
})();
// ── 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 => ({'&':'&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;
}
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 });
}
})();

View File

@ -2,10 +2,64 @@
<span class="section-icon"><span class="mdi mdi-speedometer"></span></span>
<div class="section-title">
<h2>Benchmark</h2>
<p>Measure synthesis latency and real-time factor. Track trends across backends and voices over time.</p>
<p>Measure speech recognition, voice synthesis, and full conversation turns with the same local engines used by the app.</p>
</div>
</div>
<div class="bench-tabs" role="tablist" aria-label="Benchmark modes">
<button class="bench-tab" type="button" data-bench-tab="stt"><span class="mdi mdi-microphone-outline"></span> STT <span>Speech to Text</span></button>
<button class="bench-tab active" type="button" data-bench-tab="tts"><span class="mdi mdi-text-to-speech"></span> TTS <span>Text to Speech</span></button>
<button class="bench-tab" type="button" data-bench-tab="turn"><span class="mdi mdi-swap-horizontal"></span> Turn <span>STT &rarr; TTS</span></button>
</div>
<section class="bench-pane" id="bench-pane-stt" data-bench-pane="stt">
<div class="card bench-card">
<div class="bench-card-head">
<div>
<h3>Speech to Text benchmark</h3>
<p>Run every selected STT endpoint against the same reference clip and compare speed, accuracy, model, and compute device.</p>
</div>
<button class="btn-secondary" id="bench-stt-refresh" type="button"><span class="mdi mdi-refresh"></span> Refresh engines</button>
</div>
<div class="settings-grid three bench-input-grid">
<div class="s-field">
<label>Audio (.wav or audio file)</label>
<input id="bench-stt-audio" type="file" accept="audio/*,.wav">
</div>
<div class="s-field">
<label>Reference (.txt)</label>
<input id="bench-stt-ref-file" type="file" accept="text/plain,.txt">
</div>
<div class="s-field">
<label>Reference text</label>
<textarea id="bench-stt-ref-text" rows="3" placeholder="Paste the expected transcript here..."></textarea>
</div>
</div>
<div class="bench-engine-panel">
<div class="bench-engine-head">
<strong>STT engines</strong>
<span id="bench-stt-engine-count">No engines loaded.</span>
</div>
<div class="bench-engine-list" id="bench-stt-engines"></div>
</div>
<div class="btn-row">
<button class="btn-primary" id="bench-stt-run" type="button"><span class="mdi mdi-play"></span> Run STT benchmark</button>
<span class="bench-status" id="bench-stt-status">Ready</span>
</div>
</div>
<div class="card bench-card">
<div class="bench-summary" id="bench-stt-summary"></div>
<div class="perf-table-wrap bench-table-wrap">
<table class="perf-table bench-table" id="bench-stt-table">
<thead><tr><th>Engine</th><th>Model</th><th>Device</th><th>Time</th><th>Accuracy</th><th>Output</th></tr></thead>
<tbody><tr><td colspan="6" class="bench-empty">Run a benchmark to see results.</td></tr></tbody>
</table>
</div>
</div>
</section>
<section class="bench-pane active" id="bench-pane-tts" data-bench-pane="tts">
<!-- Run benchmark -->
<div class="card">
<h2>Run benchmark</h2>
@ -140,3 +194,73 @@
<div class="perf-history-empty">No benchmark history yet. Run a benchmark above to start tracking.</div>
</div>
</div>
</section>
<section class="bench-pane" id="bench-pane-turn" data-bench-pane="turn">
<div class="card bench-card conv-config-bar">
<div class="bench-card-head">
<div>
<h3>Conversation turn benchmark</h3>
<p>Runs the same STT &rarr; LLM &rarr; TTS pipeline as Conversation Playground and records turn latency.</p>
</div>
</div>
<div class="conv-config-row">
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
<select id="bench-turn-stt"><option value="configured">Checking...</option></select>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
<div class="bench-inline-controls">
<input id="bench-turn-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
<button class="btn-secondary" id="bench-turn-fetch-llm" type="button" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="bench-turn-llm-model"><option value="">Fetch models</option></select>
</div>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
<div class="bench-inline-controls">
<select id="bench-turn-tts-backend"><option value="">Checking...</option></select>
<button class="btn-secondary" id="bench-turn-fetch-voices" type="button" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="bench-turn-voice"><option value="">Fetch voices</option></select>
</div>
</div>
</div>
<div class="settings-grid bench-input-grid">
<div class="s-field">
<label>Turn audio (full STT &rarr; LLM &rarr; TTS)</label>
<input id="bench-turn-audio" type="file" accept="audio/*,.wav,.webm,.ogg">
</div>
<div class="s-field">
<label>Text fallback</label>
<input id="bench-turn-text" type="text" placeholder="Hello, please answer in one short sentence.">
</div>
</div>
<div class="conv-prompt-row">
<label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label>
<textarea id="bench-turn-system" class="conv-system-textarea" rows="1" spellcheck="false">You are a helpful voice assistant. Keep replies short and conversational.</textarea>
</div>
<div class="btn-row">
<button class="btn-primary" id="bench-turn-run" type="button"><span class="mdi mdi-play"></span> Run turn benchmark</button>
<span class="bench-status" id="bench-turn-status">Ready</span>
</div>
</div>
<div class="conv-main bench-turn-main">
<div class="conv-chat-panel bench-turn-log" id="bench-turn-log">
<div class="conv-chat-welcome"><span class="mdi mdi-forum-outline" style="font-size:32px;opacity:.25"></span><p>Run a turn to see transcript, reply, and timing.</p></div>
</div>
<div class="conv-stats-panel">
<div class="conv-stats-head">Latency</div>
<div class="conv-pipeline">
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-microphone-outline"></span> STT</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-stt"></div></div><div class="conv-pipe-val" id="bench-turn-val-stt"></div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-timer-outline"></span> LLM first token</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-ttft"></div></div><div class="conv-pipe-val" id="bench-turn-val-ttft"></div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-brain"></span> LLM total</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-llm"></div></div><div class="conv-pipe-val" id="bench-turn-val-llm"></div></div>
<div class="conv-pipe-step"><div class="conv-pipe-label"><span class="mdi mdi-text-to-speech"></span> TTS</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-tts"></div></div><div class="conv-pipe-val" id="bench-turn-val-tts"></div></div>
<div class="conv-pipe-step conv-pipe-total"><div class="conv-pipe-label"><span class="mdi mdi-timer-check-outline"></span> Total</div><div class="conv-pipe-bar"><div class="conv-pipe-fill" id="bench-turn-fill-total" style="background:var(--accent)"></div></div><div class="conv-pipe-val" id="bench-turn-val-total"></div></div>
</div>
<div class="conv-stats-head" style="margin-top:14px">Turn history</div>
<div class="conv-turn-history" id="bench-turn-history"><div class="conv-history-empty">No turns yet.</div></div>
</div>
</div>
</section>

View File

@ -2361,6 +2361,71 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.batch-progress-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.batch-progress-bar { height: 100%; border-radius: 3px; background: var(--accent); transition: width .3s; width: 0%; }
.bench-tabs {
display: flex; gap: 4px; flex-wrap: wrap; align-items: flex-end;
margin: 0 0 14px; padding: 0; border-bottom: 1px solid var(--border);
}
.bench-tab {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 11px 18px; border: 1px solid transparent; border-bottom: none;
background: transparent; color: var(--subtext); font-size: 14px; font-weight: 700;
border-radius: 10px 10px 0 0; cursor: pointer; font-family: inherit;
position: relative; bottom: -1px;
}
.bench-tab:hover { background: var(--surface); color: var(--text); filter: none; }
.bench-tab.active { background: var(--surface); color: var(--accent); border-color: var(--border); border-bottom-color: var(--surface); }
.bench-tab.active::after { content: ""; position: absolute; left: 0; right: 0; top: -1px; height: 2px; background: var(--accent); border-radius: 2px 2px 0 0; }
.bench-tab .mdi { font-size: 18px; }
.bench-tab span:last-child { font-size: 11px; font-weight: 600; color: var(--subtext); }
.bench-pane { display: none; }
.bench-pane.active { display: flex; flex-direction: column; gap: 14px; }
.bench-card { gap: 14px; }
.bench-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.bench-card-head h3 { margin: 0; font-size: 15px; color: var(--text); }
.bench-card-head p { margin: 4px 0 0; font-size: 13px; color: var(--subtext); line-height: 1.45; max-width: 920px; }
.bench-input-grid { align-items: end; }
.bench-engine-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); overflow: hidden; }
.bench-engine-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
.bench-engine-head span { color: var(--subtext); }
.bench-engine-list { max-height: 320px; overflow: auto; display: flex; flex-direction: column; }
.bench-engine-empty, .bench-empty { color: var(--subtext); font-size: 13px; padding: 16px; text-align: left; }
.bench-engine-item {
display: grid; grid-template-columns: auto minmax(180px, 1fr) auto minmax(160px, .45fr);
gap: 10px; align-items: center; padding: 9px 12px; border-bottom: 1px solid var(--border);
cursor: pointer; background: var(--surface);
}
.bench-engine-item:last-child { border-bottom: none; }
.bench-engine-item:hover { background: color-mix(in srgb, var(--panel) 70%, var(--surface)); }
.bench-engine-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.bench-engine-main strong { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bench-engine-main small { font-size: 11px; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: monospace; }
.bench-engine-state { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 999px; background: var(--panel); color: var(--subtext); border: 1px solid var(--border); }
.bench-engine-item.is-online .bench-engine-state { color: var(--green); background: rgba(22,163,74,.09); border-color: rgba(22,163,74,.28); }
.bench-engine-item.is-offline .bench-engine-state { color: var(--red); background: rgba(220,38,38,.07); border-color: rgba(220,38,38,.24); }
.bench-engine-model { min-width: 0; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 6px 8px; font-family: monospace; font-size: 12px; }
.bench-status { font-size: 13px; color: var(--subtext); }
.bench-summary { display: flex; gap: 10px 18px; flex-wrap: wrap; min-height: 20px; }
.bench-table td strong { display: block; }
.bench-table td small { display: block; color: var(--subtext); font-size: 11px; font-family: monospace; max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.bench-output { max-width: 680px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bench-device { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border); color: var(--subtext); font-size: 11px; font-weight: 700; }
.bench-device.gpu { color: var(--green); background: rgba(22,163,74,.09); border-color: rgba(22,163,74,.28); }
.bench-device.cpu { color: var(--yellow); background: rgba(217,119,6,.09); border-color: rgba(217,119,6,.28); }
.bench-inline-controls { display: flex; gap: 6px; min-width: min(620px, 100%); }
.bench-inline-controls input, .bench-inline-controls select { min-width: 0; }
.bench-turn-main .bench-turn-log { min-height: 440px; overflow-y: auto; padding: 16px; }
.bench-turn-log audio { width: min(520px, 100%); margin: 8px 0 0; }
@media (max-width: 900px) {
.bench-engine-item { grid-template-columns: auto minmax(0, 1fr); }
.bench-engine-state, .bench-engine-model { grid-column: 2; }
.bench-inline-controls { flex-wrap: wrap; min-width: 0; }
.bench-inline-controls input, .bench-inline-controls select { flex: 1 1 180px; }
}
@media (max-width: 560px) {
.bench-tab span:not(.mdi) { display: none; }
.bench-tab { padding: 9px 12px; }
}
/* ── Chunked TTS toggle ──────────────────────────────────────────────────── */
.chunk-toggle-label {
display: inline-flex; align-items: center; gap: 6px;