Add benchmark mode tabs
This commit is contained in:
parent
9c2e507ccf
commit
10a11132c0
199
routes/stt.py
199
routes/stt.py
@ -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
|
||||
|
||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 });
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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 → 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 → LLM → 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 → LLM → 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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user