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