// ── 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…';
});
})();