From e434f10d9b36e59774591faab54ca74bd0615e4f Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 27 May 2026 11:29:48 +0200 Subject: [PATCH] Add batch benchmark to Benchmark page Batch card pre-populates from active My Voices (checked by default) with an option to reload from the backend. Select all / deselect all buttons. Runs each selected voice N times sequentially with a live progress bar and stop button. Results table updates after every voice and sorts by avg RTF fastest-first; each row shows a trend badge (faster/slower/stable) vs the previous session for that voice. All runs are saved to History. renderPerfHistory hoisted to module level so both single-voice and batch IIFEs can refresh the History card after saving new entries. Co-Authored-By: Claude Sonnet 4.6 --- static/app.js | 300 +++++++++++++++++++++++++---- static/sections/s-performance.html | 57 ++++++ static/style.css | 12 ++ 3 files changed, 327 insertions(+), 42 deletions(-) diff --git a/static/app.js b/static/app.js index 8195934..0133fd5 100644 --- a/static/app.js +++ b/static/app.js @@ -5891,6 +5891,46 @@ function perfSparklineSvg(rtfValues) { return ``; } +function renderPerfHistory() { + const histList = $('perf-history-list'); + if (!histList) return; + const filterEl = $('perf-history-filter-current'); + const filterOn = filterEl?.checked; + const curBack = $('perf-backend-select')?.value; + const curVoice = $('perf-voice-select')?.value; + let entries = perfHistoryLoad().slice().reverse(); + if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice); + if (!entries.length) { + histList.innerHTML = '
' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '
'; + return; + } + const head = `
+ Date / TimeBackendVoice + Avg latencyMinAvg RTF +
`; + const rows = entries.map(e => { + const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); + const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow'; + return `
+ ${escHtml(dt)} + ${escHtml(e.backend)} + ${escHtml(e.voice)} + ${Math.round(e.avgLatencyMs)} ms + ${Math.round(e.minLatencyMs)} ms + ${e.avgRtf.toFixed(2)} + +
`; + }).join(''); + histList.innerHTML = head + rows; + histList.querySelectorAll('.perf-history-del').forEach(btn => { + btn.addEventListener('click', () => { + const ts = Number(btn.dataset.ts); + perfHistorySave(perfHistoryLoad().filter(e => e.ts !== ts)); + renderPerfHistory(); + }); + }); +} + (function initPerfBenchmark() { const perfBackendSel = $('perf-backend-select'); const perfVoiceSel = $('perf-voice-select'); @@ -5952,48 +5992,6 @@ function perfSparklineSvg(rtfValues) { trendRow.style.display = ''; } - function renderPerfHistory() { - const histList = $('perf-history-list'); - if (!histList) return; - const filterEl = $('perf-history-filter-current'); - const filterOn = filterEl?.checked; - const curBack = perfBackendSel?.value; - const curVoice = perfVoiceSel?.value; - let entries = perfHistoryLoad().slice().reverse(); - if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice); - if (!entries.length) { - histList.innerHTML = '
' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '
'; - return; - } - const head = `
- Date / TimeBackendVoice - Avg latencyMinAvg RTF -
`; - const rows = entries.map((e, i) => { - const dt = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}); - const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow'; - return `
- ${escHtml(dt)} - ${escHtml(e.backend)} - ${escHtml(e.voice)} - ${Math.round(e.avgLatencyMs)} ms - ${Math.round(e.minLatencyMs)} ms - ${e.avgRtf.toFixed(2)} - -
`; - }).join(''); - histList.innerHTML = head + rows; - - histList.querySelectorAll('.perf-history-del').forEach(btn => { - btn.addEventListener('click', () => { - const ts = Number(btn.dataset.ts); - const updated = perfHistoryLoad().filter(e => e.ts !== ts); - perfHistorySave(updated); - renderPerfHistory(); - }); - }); - } - function renderPerfTable(sessionDone = false) { if (!perfRows.length) { perfResultsCard.style.display='none'; return; } perfResultsCard.style.display = ''; @@ -6097,6 +6095,224 @@ function perfSparklineSvg(rtfValues) { renderPerfHistory(); })(); +// ── 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 => + `` + ).join('') || ''; + } + 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 = '
No voices found.
'; + updateSelCount(); + return; + } + batchVoiceList.innerHTML = voices.map(v => { + const isActive = activeIds.has(v.id); + return ``; + }).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 = '
Loading from backend…
'; + 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 = `
Failed: ${escHtml(e.message)}
`; + } + 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 ? `${r.trend.label}` : ''; + return ` + ${escHtml(r.voice)} + ${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'} + ${r.ok ? r.minLatency + ' ms' : '—'} + ${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'} + ${r.ok && r.avgRtf ? r.avgRtf.toFixed(2) : '—'}${trend} + ${r.ok ? 'OK' : `${escHtml(r.error || 'Failed')}`} + `; + }).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…'; + }); +})(); + // ── STT -> TTS ─────────────────────────────────────────────────────────── let sttTtsSourceId = null; diff --git a/static/sections/s-performance.html b/static/sections/s-performance.html index cf9b57b..653e734 100644 --- a/static/sections/s-performance.html +++ b/static/sections/s-performance.html @@ -68,6 +68,63 @@ + +
+

Batch benchmark

+

Benchmark multiple voices in one run. Pre-populated from your active My Voices — or reload from the backend. Results are sorted fastest first and saved to History.

+
+
+ + +
+
+ + +
+
+

Uses the sample text from the single-voice form above.

+
+ + + + +
+
+
+ + +
+ +
+ + + +

History

diff --git a/static/style.css b/static/style.css index 688f88c..9630068 100644 --- a/static/style.css +++ b/static/style.css @@ -1734,6 +1734,18 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .perf-history-ts { color: var(--subtext); font-size: 11px; } .perf-history-del { cursor: pointer; color: var(--subtext); font-size: 11px; text-align: right; } .perf-history-del:hover { color: var(--red); } +.batch-voice-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } +.batch-voice-list { display: flex; flex-direction: column; gap: 0; max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); } +.batch-voice-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border); transition: background .1s; } +.batch-voice-item:last-child { border-bottom: none; } +.batch-voice-item:hover { background: var(--hover); } +.batch-voice-item.is-active .batch-voice-name { font-weight: 500; } +.batch-voice-name { flex: 1; color: var(--text); } +.batch-voice-tag { font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(var(--accent-rgb,99,102,241),.15); color: var(--accent); font-weight: 600; } +.batch-progress { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; } +.batch-progress-head { display: flex; justify-content: space-between; font-size: 12px; color: var(--subtext); } +.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%; } /* ── Chunked TTS toggle ──────────────────────────────────────────────────── */ .chunk-toggle-label {