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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-27 11:29:48 +02:00
parent e0ff837947
commit e434f10d9b
3 changed files with 327 additions and 42 deletions

View File

@ -5891,6 +5891,46 @@ function perfSparklineSvg(rtfValues) {
return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`;
}
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 = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>';
return;
}
const head = `<div class="perf-history-row perf-history-head">
<span>Date / Time</span><span>Backend</span><span>Voice</span>
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span>
</div>`;
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 `<div class="perf-history-row">
<span class="perf-history-ts">${escHtml(dt)}</span>
<span>${escHtml(e.backend)}</span>
<span>${escHtml(e.voice)}</span>
<span>${Math.round(e.avgLatencyMs)} ms</span>
<span>${Math.round(e.minLatencyMs)} ms</span>
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
<span class="perf-history-del" data-ts="${e.ts}" title="Remove"><span class="mdi mdi-close"></span></span>
</div>`;
}).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 = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>';
return;
}
const head = `<div class="perf-history-row perf-history-head">
<span>Date / Time</span><span>Backend</span><span>Voice</span>
<span>Avg latency</span><span>Min</span><span>Avg RTF</span><span></span>
</div>`;
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 `<div class="perf-history-row" data-idx="${i}">
<span class="perf-history-ts">${escHtml(dt)}</span>
<span>${escHtml(e.backend)}</span>
<span>${escHtml(e.voice)}</span>
<span>${Math.round(e.avgLatencyMs)} ms</span>
<span>${Math.round(e.minLatencyMs)} ms</span>
<span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
<span class="perf-history-del" data-ts="${e.ts}" title="Remove this entry"><span class="mdi mdi-close"></span></span>
</div>`;
}).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 =>
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
).join('') || '<option value="">No backends available</option>';
}
populateBatchBackends();
function updateSelCount() {
if (!batchVoiceList || !batchSelCount) return;
const total = batchVoiceList.querySelectorAll('.batch-voice-cb').length;
const checked = batchVoiceList.querySelectorAll('.batch-voice-cb:checked').length;
batchSelCount.textContent = total ? `${checked} of ${total} selected` : '';
batchRunBtn.disabled = checked === 0;
}
function buildVoiceList(voices) {
const activeIds = new Set(activeVoiceIds());
if (!voices.length) {
batchVoiceList.innerHTML = '<div class="perf-history-empty">No voices found.</div>';
updateSelCount();
return;
}
batchVoiceList.innerHTML = voices.map(v => {
const isActive = activeIds.has(v.id);
return `<label class="batch-voice-item${isActive ? ' is-active' : ''}">
<input type="checkbox" class="batch-voice-cb" value="${escHtml(v.id)}"${isActive ? ' checked' : ''}>
<span class="batch-voice-name">${escHtml(v.label)}</span>
${isActive ? '<span class="batch-voice-tag">active</span>' : ''}
</label>`;
}).join('');
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.addEventListener('change', updateSelCount));
updateSelCount();
}
// Pre-populate from My Voices library on init
function populateFromLibrary() {
const voices = (_voices || [])
.filter(v => v.enabled !== false)
.map(v => ({ id: v.id, label: v.display_name || v.name || v.id }))
.sort((a, b) => a.label.localeCompare(b.label));
buildVoiceList(voices);
}
populateFromLibrary();
batchLoadBtn.addEventListener('click', async () => {
const backend = batchBackendSel.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
batchLoadBtn.disabled = true;
batchVoiceList.innerHTML = '<div class="perf-history-empty">Loading from backend…</div>';
try {
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const voices = raw.map(v => ({ id: backendVoiceId(v), label: backendVoiceId(v) }))
.sort((a, b) => a.label.localeCompare(b.label));
buildVoiceList(voices);
} catch(e) {
batchVoiceList.innerHTML = `<div class="perf-history-empty">Failed: ${escHtml(e.message)}</div>`;
}
finally { batchLoadBtn.disabled = false; }
});
batchSelectAllBtn.addEventListener('click', () => {
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = true);
updateSelCount();
});
batchSelectNoneBtn.addEventListener('click', () => {
batchVoiceList.querySelectorAll('.batch-voice-cb').forEach(cb => cb.checked = false);
updateSelCount();
});
function renderBatchResults() {
if (!batchResults.length) { batchResultsCard.style.display = 'none'; return; }
batchResultsCard.style.display = '';
const sorted = batchResults.slice().sort((a, b) => {
if (a.ok && !b.ok) return -1;
if (!a.ok && b.ok) return 1;
return (a.avgRtf || 999) - (b.avgRtf || 999);
});
const okCount = batchResults.filter(r => r.ok).length;
if (batchResultsLabel) batchResultsLabel.textContent = `${okCount} / ${batchResults.length} voices — ${batchBackendSel.value}`;
batchTbody.innerHTML = sorted.map(r => {
const rtfCls = r.ok && r.avgRtf < 1 ? 'perf-good' : r.ok ? 'perf-slow' : '';
const trend = r.trend ? `<span class="perf-trend-badge ${r.trend.cls}" style="font-size:11px;padding:1px 6px">${r.trend.label}</span>` : '';
return `<tr class="${r.ok ? '' : 'perf-row-error'}">
<td>${escHtml(r.voice)}</td>
<td>${r.ok ? Math.round(r.avgLatency) + ' ms' : '—'}</td>
<td>${r.ok ? r.minLatency + ' ms' : '—'}</td>
<td>${r.ok && r.avgAudio > 0 ? r.avgAudio.toFixed(2) : '—'}</td>
<td class="${rtfCls}">${r.ok && r.avgRtf ? r.avgRtf.toFixed(2) : '—'}${trend}</td>
<td>${r.ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error || 'Failed')}</span>`}</td>
</tr>`;
}).join('');
}
batchRunBtn.addEventListener('click', async () => {
const backend = batchBackendSel.value;
const text = $('perf-text')?.value.trim();
const runs = parseInt(batchRunsSel.value) || 1;
const selected = [...(batchVoiceList?.querySelectorAll('.batch-voice-cb:checked') || [])].map(cb => cb.value);
if (!backend) { toast('Select a backend first', 'error'); return; }
if (!text) { toast('Enter sample text in the single-voice form above', 'error'); return; }
if (!selected.length) { toast('Select at least one voice', 'error'); return; }
batchStopped = false;
batchResults = [];
batchRunBtn.disabled = true;
batchStopBtn.disabled = false;
batchProgress.style.display = '';
batchResultsCard.style.display = 'none';
for (let vi = 0; vi < selected.length; vi++) {
if (batchStopped) break;
const voice = selected[vi];
batchProgLabel.textContent = `${voice} (${vi + 1} / ${selected.length})`;
batchProgCount.textContent = `${vi + 1} / ${selected.length}`;
batchProgBar.style.width = `${Math.round((vi / selected.length) * 100)}%`;
const entry = { voice, ok: false, avgLatency: 0, minLatency: 0, avgRtf: 0, avgAudio: 0, error: '' };
const rowRunResults = [];
for (let ri = 0; ri < runs; ri++) {
if (batchStopped) break;
try {
const t0 = performance.now();
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
const lat = Math.round(performance.now() - t0);
let dur = 0;
try {
const ac = new (window.AudioContext || window.webkitAudioContext)();
const buf = await ac.decodeAudioData(await blob.arrayBuffer());
dur = buf.duration; ac.close();
} catch(_) {}
rowRunResults.push({ lat, dur });
} catch(e) { entry.error = e.message; break; }
}
if (rowRunResults.length) {
entry.ok = true;
entry.avgLatency = rowRunResults.reduce((s, r) => s + r.lat, 0) / rowRunResults.length;
entry.minLatency = Math.min(...rowRunResults.map(r => r.lat));
const durRows = rowRunResults.filter(r => r.dur > 0);
entry.avgAudio = durRows.length ? durRows.reduce((s, r) => s + r.dur, 0) / durRows.length : 0;
const rtfArr = durRows.map(r => r.lat / 1000 / r.dur);
entry.avgRtf = rtfArr.length ? rtfArr.reduce((s, v) => s + v, 0) / rtfArr.length : 0;
// compute trend vs previous session for this voice
if (entry.avgRtf > 0) {
const prev = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
if (prev.length) {
const prevRtf = prev[prev.length - 1].avgRtf;
const delta = entry.avgRtf - prevRtf;
const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
if (pct < 5) entry.trend = { cls: 'perf-trend-stable', label: '→ stable' };
else if (delta < 0) entry.trend = { cls: 'perf-trend-better', label: `${pct.toFixed(0)}% faster` };
else entry.trend = { cls: 'perf-trend-worse', label: `${pct.toFixed(0)}% slower` };
}
perfHistoryAdd({
ts: Date.now(), backend, voice, textLen: text.length,
avgLatencyMs: entry.avgLatency, minLatencyMs: entry.minLatency,
maxLatencyMs: Math.max(...rowRunResults.map(r => r.lat)),
avgRtf: entry.avgRtf, runCount: rowRunResults.length, allOk: true,
});
}
}
batchResults.push(entry);
renderBatchResults();
}
batchProgBar.style.width = '100%';
batchProgLabel.textContent = batchStopped
? `Stopped after ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''}.`
: `Done — ${batchResults.length} voice${batchResults.length !== 1 ? 's' : ''} benchmarked.`;
batchStopBtn.disabled = true;
batchRunBtn.disabled = false;
renderPerfHistory();
const ok = batchResults.filter(r => r.ok);
const best = ok.slice().sort((a, b) => a.avgRtf - b.avgRtf)[0];
toast(
`Batch done: ${ok.length}/${batchResults.length} OK` +
(best ? `, best RTF ${best.avgRtf.toFixed(2)} (${best.voice})` : ''),
ok.length < batchResults.length ? 'error' : 'success'
);
});
batchStopBtn.addEventListener('click', () => {
batchStopped = true;
batchStopBtn.disabled = true;
batchProgLabel.textContent = 'Stopping after current voice…';
});
})();
// ── STT -> TTS ───────────────────────────────────────────────────────────
let sttTtsSourceId = null;

View File

@ -68,6 +68,63 @@
</div>
</div>
<!-- Batch benchmark -->
<div class="card">
<h2>Batch benchmark</h2>
<p class="card-subtitle">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.</p>
<div class="btn-row" style="align-items:flex-end;flex-wrap:wrap;gap:10px">
<div class="field">
<label>Backend</label>
<select id="batch-backend-select"><option value="">Checking backends…</option></select>
</div>
<div class="field">
<label>Runs per voice</label>
<select id="batch-runs">
<option value="1" selected>1 run</option>
<option value="3">3 runs</option>
<option value="5">5 runs</option>
</select>
</div>
</div>
<p class="note" style="margin:4px 0 8px">Uses the sample text from the single-voice form above.</p>
<div class="batch-voice-toolbar">
<button class="btn-secondary btn-sm" id="batch-load-voices-btn" type="button"><span class="mdi mdi-reload"></span> Reload from backend</button>
<button class="btn-secondary btn-sm" id="batch-select-all-btn" type="button">Select all</button>
<button class="btn-secondary btn-sm" id="batch-select-none-btn" type="button">Deselect all</button>
<span id="batch-selected-count" class="note"></span>
</div>
<div id="batch-voice-list" class="batch-voice-list"></div>
<div class="btn-row" style="margin-top:12px">
<button class="btn-primary" id="batch-run-btn" type="button" disabled><span class="mdi mdi-play"></span> Run batch</button>
<button class="btn-secondary" id="batch-stop-btn" type="button" disabled><span class="mdi mdi-stop"></span> Stop</button>
</div>
<div id="batch-progress" class="batch-progress" style="display:none">
<div class="batch-progress-head">
<span id="batch-progress-label">Benchmarking…</span>
<span id="batch-progress-count"></span>
</div>
<div class="batch-progress-track" role="progressbar">
<div id="batch-progress-bar" class="batch-progress-bar"></div>
</div>
</div>
</div>
<!-- Batch results -->
<div class="card" id="batch-results-card" style="display:none">
<h2>Batch results <span id="batch-results-label" class="s-label-note"></span></h2>
<p class="card-subtitle">Sorted fastest RTF first. Green = real-time capable (&lt;1.0). Trend compares to the previous session for each voice.</p>
<div class="perf-table-wrap">
<table class="perf-table" id="batch-table">
<thead>
<tr>
<th>Voice</th><th>Avg latency</th><th>Best</th><th>Audio (s)</th><th>Avg RTF</th><th>Status</th>
</tr>
</thead>
<tbody id="batch-tbody"></tbody>
</table>
</div>
</div>
<!-- History -->
<div class="card" id="perf-history-card">
<h2>History</h2>

View File

@ -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 {