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 `${bars} `;
}
+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 / Time Backend Voice
+ Avg latency Min Avg 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 / Time Backend Voice
- Avg latency Min Avg 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 =>
+ `${escHtml(b.label)} `
+ ).join('') || 'No backends available ';
+ }
+ 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 `
+
+ ${escHtml(v.label)}
+ ${isActive ? 'active ' : ''}
+ `;
+ }).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.
+
+
+ Backend
+ Checking backends…
+
+
+ Runs per voice
+
+ 1 run
+ 3 runs
+ 5 runs
+
+
+
+
Uses the sample text from the single-voice form above.
+
+ Reload from backend
+ Select all
+ Deselect all
+
+
+
+
+ Run batch
+ Stop
+
+
+
+ Benchmarking…
+
+
+
+
+
+
+
+
+
Batch results
+
Sorted fastest RTF first. Green = real-time capable (<1.0). Trend compares to the previous session for each voice.
+
+
+
+
+ Voice Avg latency Best Audio (s) Avg RTF Status
+
+
+
+
+
+
+
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 {