tts-voice-creator-clone-and.../static/js/tts-preview.js
mARTin-B78 c7a1e35539 Security audit, modular refactor, and container-name field
Security fixes:
- Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal)
- Verify yt-dlp output stays inside TEMP_DIR before registration
- Remove Access-Control-Allow-Origin: * from /api/proxy-audio
- TTL-based temp file registry (default 2h) to prevent disk fill

Performance:
- Cache settings + routing rules in memory (mtime-checked); eliminates
  per-request disk reads on every TTS call

UI:
- Add container name (optional) field to Docker stack TTS/STT engine
  cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming,
  NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on
  all engine cards, matching the existing Other Local TTS/STT cards

Refactor — backend:
- server.py: 5560 lines → 43-line entry point
- core/ package: constants, registry, validation, docker_client,
  config, routing, audio, voice, presets, tts_helpers
- routes/ package: admin, settings, library, stt, sources, docker,
  tts, conversation (FastAPI APIRouter modules)
- Dockerfile + docker-compose.yml updated to include core/ and routes/

Refactor — frontend:
- static/app.js: 8744 lines → 16 modules in static/js/
  utils, voice-inspector, voice-sources, integrations, routing,
  settings, voice-clone, voice-library, tts-preview, benchmark,
  stt, init, engines, ai-backends, generation, conversation
- static/loader.js updated to load modules sequentially

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:13:07 +02:00

529 lines
25 KiB
JavaScript

// ── TTS preview ───────────────────────────────────────────────────────────
function backendVoiceId(value) {
return typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || JSON.stringify(value));
}
function shouldFilterBackendVoices(backend) {
return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || '');
}
async function activeLibraryVoiceIds() {
if (!_voices.length) await loadVoiceLibrary();
return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id));
}
function cleanReferenceText(text) {
return String(text || '').trim();
}
function selectedPreviewLibraryVoice() {
const id = $('tts-voice-select')?.value || '';
return id ? (_voices || []).find(v => v.id === id) : null;
}
function previewVoiceWarnings(v) {
const warnings = [];
const backend = backendById($('tts-backend-select')?.value || '');
if (backend && backend.id && !['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend.id)) {
warnings.push(backend.id === 'nvidia_magpie' ? 'NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.' : 'This backend may follow style/model voice more than the saved WAV identity.');
}
if (backend && backend.id === 'nvidia_zeroshot' && v.duration && (Number(v.duration) < 3 || Number(v.duration) > 10)) {
warnings.push('NVIDIA Zeroshot works best with a clear 3-10 second prompt.');
}
if (backend && backend.id === 'nvidia_flow' && !v.transcript) {
warnings.push('NVIDIA Flow requires the exact saved reference transcript for this voice.');
}
if (!v.transcript) warnings.push('No reference transcript is saved; cloned identity is harder to judge.');
if (v.duration && (Number(v.duration) < 3 || Number(v.duration) > 20)) warnings.push('Reference clip length is outside the 3-20 second sweet spot.');
if (v.needs_tts_restart) warnings.push('This voice changed since the last backend refresh; restart or clear restart flags before judging it.');
const healthWarnings = v.health && Array.isArray(v.health.warnings) ? v.health.warnings : [];
warnings.push(...healthWarnings.slice(0, 3));
return warnings;
}
function updatePreviewVoiceMatchPanel() {
const panel = $('preview-match-panel');
if (!panel) return;
const v = selectedPreviewLibraryVoice();
if (!v) { panel.hidden = true; return; }
panel.hidden = false;
const lang = v.language || v.lang || (v.id || '').split('_')[0] || '-';
const gender = v.gender || (v.id || '').split('_')[1] || '-';
const db = fmtDbfs(v);
const dur = v.duration ? fmtDuration(v.duration) : '-';
$('preview-match-title').textContent = v.id;
$('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`;
const warnings = previewVoiceWarnings(v);
$('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.';
const transcript = cleanReferenceText(v.transcript || '');
$('preview-match-transcript').textContent = transcript || 'No reference text saved for this voice.';
$('preview-ref-use-text').disabled = !transcript;
$('preview-ref-synth').disabled = !transcript;
const audio = $('preview-ref-audio');
const expected = voiceFileUrl(v);
if (audio.dataset.src !== expected) {
audio.pause();
audio.src = expected;
audio.dataset.src = expected;
}
// Persona rewrite button — shown only when voice has a persona
const actionsEl = panel.querySelector('.preview-match-actions');
let personaBtn = panel.querySelector('.preview-persona-btn');
if (v.persona) {
if (!personaBtn) {
personaBtn = document.createElement('button');
personaBtn.className = 'btn-secondary preview-persona-btn';
personaBtn.type = 'button';
personaBtn.textContent = 'Rewrite with persona';
actionsEl?.appendChild(personaBtn);
personaBtn.addEventListener('click', async () => {
const text = $('preview-text-area').value.trim();
if (!text) { toast('Enter text to rewrite', 'error'); return; }
const lv = selectedPreviewLibraryVoice();
if (!lv?.persona) { toast('This voice has no persona', 'error'); return; }
personaBtn.disabled = true;
personaBtn.textContent = 'Rewriting…';
try {
const llmUrl = localStorage.getItem('refine-llm-url') || 'http://localhost:11434/v1';
const r = await fetch('/api/rewrite-with-persona', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ text, persona: lv.persona, llm_url: llmUrl, mode:'rewrite' }) });
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
$('preview-text-area').value = d.text;
toast('Text rewritten in persona style', 'success');
} catch(e) { toast('Persona rewrite failed: ' + e.message, 'error'); }
finally { personaBtn.disabled = false; personaBtn.textContent = 'Rewrite with persona'; }
});
}
} else {
personaBtn?.remove();
}
}
async function synthesizeSelectedReferenceText() {
const v = selectedPreviewLibraryVoice();
if (!v) { toast('Select a library voice first', 'error'); return; }
let text = cleanReferenceText(v.transcript || '');
if (!text) { toast('This voice has no reference text', 'error'); return; }
if (v.needs_tts_restart) {
const ok = confirm('This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?');
if (!ok) return;
await clearTtsRestartFlags();
v.needs_tts_restart = false;
updatePreviewVoiceMatchPanel();
}
const backend = $('tts-backend-select').value;
if (!backend) { toast('No available TTS backend', 'error'); return; }
const btn = $('preview-ref-synth');
btn.disabled = true;
try {
$('preview-text-area').value = text;
const source = await createTtsAudioSource(v.id, text, backend, $('preview-playback-mode').value, $('preview-style-instruction').value.trim());
previewBlob = source.blob;
const audio = $('preview-audio');
audio.src = source.url;
audio.style.display = '';
await audio.play();
$('save-preview-mp3-btn').disabled = false;
$('save-preview-btn').disabled = source.streaming;
toast(source.streaming ? 'Reference text streaming' : 'Reference text synthesized', 'success');
} catch(e) { toast('Reference synthesis failed: ' + e.message, 'error'); }
finally { btn.disabled = false; }
}
$('fetch-tts-voices-btn').addEventListener('click', async () => {
$('fetch-tts-voices-btn').disabled = true;
try {
const backend = $('tts-backend-select')?.value;
if (!backend) throw new Error('No available TTS backend');
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
let voices = Array.isArray(rawVoices) ? rawVoices : [];
if (shouldFilterBackendVoices(backend)) {
const activeIds = await activeLibraryVoiceIds();
voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
}
const sel = $('tts-voice-select'), prev = sel.value;
sel.innerHTML = '<option value="">— select after fetch —</option>';
voices.forEach(v => {
const id = backendVoiceId(v);
const opt = document.createElement('option'); opt.value = opt.textContent = id; sel.appendChild(opt);
});
if(prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev;
updatePreviewVoiceMatchPanel();
const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices';
toast('Fetched '+voices.length+suffix,'success');
} catch(e) { toast('Fetch failed: '+e.message,'error'); }
finally { $('fetch-tts-voices-btn').disabled = false; }
});
$('tts-backend-select').addEventListener('change', () => {
const sel = $('tts-voice-select');
sel.innerHTML = '<option value="">— select after fetch —</option>';
updateBackendHelp();
updatePreviewVoiceMatchPanel();
previewBlob = null;
$('save-preview-mp3-btn').disabled = true;
$('save-preview-btn').disabled = true;
});
$('tts-voice-select').addEventListener('change', updatePreviewVoiceMatchPanel);
$('preview-ref-play').addEventListener('click', async () => {
updatePreviewVoiceMatchPanel();
const audio = $('preview-ref-audio');
try { await audio.play(); }
catch(e) { toast('Reference playback failed: ' + e.message, 'error'); }
});
$('preview-ref-use-text').addEventListener('click', () => {
const v = selectedPreviewLibraryVoice();
const text = cleanReferenceText(v?.transcript || '');
if (!text) { toast('This voice has no reference text', 'error'); return; }
$('preview-text-area').value = text;
toast('Reference text copied to target text', 'success');
});
$('preview-ref-synth').addEventListener('click', synthesizeSelectedReferenceText);
let _ttsStreamHealth = null;
function effectiveTtsPlaybackMode(override = 'settings') {
if (override && override !== 'settings') return override;
return _appSettings.tts_stream_mode || 'auto';
}
async function isTtsStreamAvailable(force = false) {
if (_ttsStreamHealth && !force) return _ttsStreamHealth.ok;
try {
_ttsStreamHealth = await fetch('/api/tts-stream-health').then(r => r.json());
return !!_ttsStreamHealth.ok;
} catch (_) {
_ttsStreamHealth = {ok:false};
return false;
}
}
async function createTtsStreamUrl(voice, text, instruct = '') {
if (!await isTtsStreamAvailable()) throw new Error('streaming backend unavailable');
const r = await fetch('/api/tts-stream-session', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({text,voice,instruct})});
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const data = await r.json();
return data.url;
}
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false) {
const body = {text, voice, response_format: responseFormat, instruct, backend};
if (applyPersona) body.apply_persona = true;
const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
return await r.blob();
}
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false) {
const mode = effectiveTtsPlaybackMode(modeOverride);
if (backend !== 'streaming' || mode === 'buffered') {
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona);
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
}
try {
return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
} catch (e) {
if (mode === 'streaming') throw e;
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona);
return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
}
}
let previewBlob = null;
const PREVIEW_SAMPLE_TEXT = 'Hello! This is a voice preview from TTS Voice Creator - Clone and Design.';
$('preview-text-area').addEventListener('focus', () => {
if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = '';
}, { once:true });
function _onPreviewGenerated(source, voice, text, backend) {
if (typeof effectsSourceBlob !== 'undefined') window._effectsSourceBlob = null;
const ea = $('effects-apply-btn'); if (ea && source.blob) ea.disabled = false;
const ap = $('add-to-playlist-btn'); if (ap && source.blob) ap.disabled = false;
if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url);
}
$('preview-btn').addEventListener('click', async () => {
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
const applyPersona = $('preview-persona-toggle')?.checked || false;
if(!backend) { toast('No available TTS backend','error'); return; }
if(!voice) { toast('Select a TTS voice','error'); return; }
if(!text) { toast('Enter preview text','error'); return; }
$('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true;
if($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true;
if($('effects-apply-btn')) $('effects-apply-btn').disabled=true;
try {
const audio = $('preview-audio');
const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
const source = useChunked
? await generateChunkedTts(voice, text, backend, instruct)
: await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona);
previewBlob = source.blob;
window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
audio.src = source.url;
audio.style.display='';
await audio.play();
$('save-preview-mp3-btn').disabled = false;
$('save-preview-btn').disabled = source.streaming;
_onPreviewGenerated(source, voice, text, backend);
toast(source.streaming ? 'Streaming preview playing' : source.label === 'chunked' ? `Chunked (${text.length} chars) playing` : 'Preview playing', 'success');
} catch(e) { toast('TTS failed: '+e.message,'error'); }
finally { $('preview-btn').disabled=false; }
});
$('save-preview-mp3-btn').addEventListener('click', async () => {
const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
if(!backend) { toast('No available TTS backend','error'); return; }
if(!voice || !text) return;
const btn = $('save-preview-mp3-btn');
btn.disabled = true;
try {
const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (voice||'preview')+'_preview.mp3'; a.click();
toast('MP3 saved', 'success');
} catch(e) { toast('MP3 save failed: '+e.message,'error'); }
finally { btn.disabled = false; }
});
$('save-preview-btn').addEventListener('click', () => {
if(!previewBlob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(previewBlob);
a.download = ($('tts-voice-select').value||'preview')+'_preview.wav'; a.click();
});
// ── Performance benchmark ─────────────────────────────────────────────────
const PERF_HISTORY_KEY = 'vcf-perf-history';
const PERF_HISTORY_MAX = 50;
function perfHistoryLoad() {
try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; }
}
function perfHistorySave(entries) {
try { localStorage.setItem(PERF_HISTORY_KEY, JSON.stringify(entries.slice(-PERF_HISTORY_MAX))); } catch(_) {}
}
function perfHistoryAdd(entry) {
const h = perfHistoryLoad();
h.push(entry);
perfHistorySave(h);
}
function perfSparklineSvg(rtfValues) {
if (!rtfValues.length) return '';
const W = 120, H = 32, PAD = 2, barW = Math.max(4, Math.floor((W - PAD * 2) / rtfValues.length) - 1);
const maxV = Math.max(...rtfValues, 1);
const bars = rtfValues.map((v, i) => {
const bh = Math.max(3, Math.round((v / maxV) * (H - PAD * 2)));
const x = PAD + i * (barW + 1);
const y = H - PAD - bh;
const col = v < 1 ? 'var(--green)' : 'var(--yellow)';
return `<rect x="${x}" y="${y}" width="${barW}" height="${bh}" rx="1" fill="${col}" opacity=".8"/>`;
}).join('');
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');
const perfFetchBtn = $('perf-fetch-voices-btn');
const perfRunBtn = $('perf-run-btn');
const perfClearBtn = $('perf-clear-btn');
const perfProgress = $('perf-progress');
const perfResultsCard = $('perf-results-card');
const perfSummary = $('perf-summary');
const perfTbody = $('perf-tbody');
const perfText = $('perf-text');
const perfRunsSel = $('perf-runs');
if (!perfRunBtn) return;
let perfRows = [];
function populatePerfBackends() {
if (!perfBackendSel) return;
const cur = perfBackendSel.value;
perfBackendSel.innerHTML = availableTtsBackends().map(b =>
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
).join('') || '<option value="">No backends available</option>';
}
populatePerfBackends();
perfFetchBtn.addEventListener('click', async () => {
const backend = perfBackendSel.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
perfFetchBtn.disabled = true;
try {
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const cur = perfVoiceSel.value;
perfVoiceSel.innerHTML = rawVoices.map(v => {
const id = backendVoiceId(v);
return `<option value="${escHtml(id)}"${id===cur?' selected':''}>${escHtml(id)}</option>`;
}).join('') || '<option value="">No voices</option>';
} catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
finally { perfFetchBtn.disabled = false; }
});
function updateTrendDisplay(backend, voice, currentAvgRtf) {
const trendRow = $('perf-trend-row');
const trendBadge = $('perf-trend-badge');
const sparkWrap = $('perf-sparkline-wrap') || trendRow?.querySelector('.perf-sparkline-wrap');
if (!trendRow) return;
const history = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
if (history.length === 0) { trendRow.style.display = 'none'; return; }
const prevRtf = history[history.length - 1].avgRtf;
const delta = currentAvgRtf - prevRtf;
const pct = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
let cls, label;
if (pct < 5) { cls = 'perf-trend-stable'; label = '<span class="mdi mdi-minus"></span> Stable'; }
else if (delta < 0) { cls = 'perf-trend-better'; label = `<span class="mdi mdi-arrow-down"></span> ${pct.toFixed(0)}% faster`; }
else { cls = 'perf-trend-worse'; label = `<span class="mdi mdi-arrow-up"></span> ${pct.toFixed(0)}% slower`; }
trendBadge.className = 'perf-trend-badge ' + cls;
trendBadge.innerHTML = label;
const rtfValues = [...history.slice(-9).map(e => e.avgRtf), currentAvgRtf];
if (sparkWrap) sparkWrap.innerHTML = perfSparklineSvg(rtfValues);
trendRow.style.display = '';
}
function renderPerfTable(sessionDone = false) {
if (!perfRows.length) { perfResultsCard.style.display='none'; return; }
perfResultsCard.style.display = '';
perfTbody.innerHTML = perfRows.map((r, i) => {
const rtf = r.audioDuration > 0 ? (r.latencyMs / 1000 / r.audioDuration).toFixed(2) : '—';
const ok = r.ok;
return `<tr class="${ok?'':'perf-row-error'}">
<td>${i+1}</td>
<td>${escHtml(r.backend)}</td>
<td>${escHtml(r.voice)}</td>
<td>${ok ? r.latencyMs : '—'}</td>
<td>${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}</td>
<td>${ok ? rtf : '—'}</td>
<td>${ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error||'Error')}</span>`}</td>
</tr>`;
}).join('');
const ok = perfRows.filter(r => r.ok);
if (ok.length) {
const avg = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length;
const minL = Math.min(...ok.map(r => r.latencyMs));
const maxL = Math.max(...ok.map(r => r.latencyMs));
const rtfArr = ok.filter(r=>r.audioDuration>0).map(r=>r.latencyMs/1000/r.audioDuration);
const avgRtf = rtfArr.length ? rtfArr.reduce((s,v)=>s+v,0)/rtfArr.length : 0;
const labelEl = $('perf-results-label');
if (labelEl) labelEl.textContent = `${ok.length} run${ok.length>1?'s':''}${perfBackendSel.value} / ${perfVoiceSel.value}`;
perfSummary.innerHTML = `
<span class="perf-stat"><strong>${Math.round(avg)} ms</strong> avg latency</span>
<span class="perf-stat"><strong>${minL} ms</strong> best</span>
<span class="perf-stat"><strong>${maxL} ms</strong> worst</span>
<span class="perf-stat"><strong>${avgRtf.toFixed(2)}</strong> avg RTF</span>
<span class="perf-stat ${avgRtf < 1 ? 'perf-good' : 'perf-slow'}">${avgRtf < 1 ? '<span class="mdi mdi-check-circle-outline"></span> Real-time capable' : '<span class="mdi mdi-alert-outline"></span> Slower than real-time'}</span>
`;
if (sessionDone && rtfArr.length) {
updateTrendDisplay(perfBackendSel.value, perfVoiceSel.value, avgRtf);
perfHistoryAdd({
ts: Date.now(),
backend: perfBackendSel.value,
voice: perfVoiceSel.value,
textLen: perfText.value.trim().length,
avgLatencyMs: avg,
minLatencyMs: minL,
maxLatencyMs: maxL,
avgRtf,
runCount: ok.length,
allOk: ok.length === perfRows.length,
});
renderPerfHistory();
}
} else { perfSummary.innerHTML = '<span class="perf-err">All runs failed</span>'; }
}
perfClearBtn.addEventListener('click', () => {
perfRows = [];
renderPerfTable();
if ($('perf-trend-row')) $('perf-trend-row').style.display = 'none';
perfProgress.style.display = 'none';
});
perfRunBtn.addEventListener('click', async () => {
const backend = perfBackendSel.value;
const voice = perfVoiceSel.value;
const text = perfText.value.trim();
const runs = parseInt(perfRunsSel.value) || 3;
if (!backend) { toast('Select a backend first', 'error'); return; }
if (!voice) { toast('Fetch and select a voice first', 'error'); return; }
if (!text) { toast('Enter sample text', 'error'); return; }
perfRows = [];
perfRunBtn.disabled = true;
perfProgress.style.display = '';
for (let i = 0; i < runs; i++) {
perfProgress.textContent = `Run ${i+1} / ${runs}`;
const row = { backend, voice, ok: false, latencyMs: 0, audioDuration: 0, error: '' };
try {
const t0 = performance.now();
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
row.latencyMs = Math.round(performance.now() - t0);
row.ok = true;
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const buf = await audioCtx.decodeAudioData(await blob.arrayBuffer());
row.audioDuration = buf.duration;
audioCtx.close();
} catch(_) {}
} catch(e) { row.error = e.message; }
perfRows.push(row);
renderPerfTable(false);
}
perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`;
renderPerfTable(true);
perfRunBtn.disabled = false;
});
// History filter toggle
$('perf-history-filter-current')?.addEventListener('change', renderPerfHistory);
$('perf-history-clear-btn')?.addEventListener('click', () => {
perfHistorySave([]);
renderPerfHistory();
toast('Benchmark history cleared', 'success');
});
renderPerfHistory();
})();