// ── 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 || ''); } // voice_design's raw discovery endpoint (/v1/audio/voices) ONLY ever lists // its own bundled presets (vd_british_male, vd_german_male, ...) — it never // includes any of the user's own designed characters, even though the // SAME engine happily synthesizes those same ids when asked directly // (that's exactly how every designed voice in a book gets synthesized). // Filtering the raw fetched list the same way voice_clone/streaming are // filtered (intersecting it with the user's own library) was the first // attempt here — and made things WORSE, not better: since the raw list // never contains custom ids at all, that intersection is always empty, so // the dropdown went from "shows only irrelevant presets" to "shows // nothing." The fix isn't filtering the fetched list, it's not using the // fetched list at all — just show the user's own active library directly, // since that's what actually works with this backend's synthesis endpoint. function shouldReplaceWithLibraryVoices(backend) { return backend === 'voice_design'; } 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') || _appSettings?.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, model: _appSettings?.llm_model || '', 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(); } // "Apply character persona" is a manually-typed field on the Voice // Inspector page, never auto-filled — confirmed live that checking it for // a voice with none set (the common case for freshly Voice-Designed // characters) silently did nothing, indistinguishable from a bug. Reflect // whether the selected voice actually has one right on the checkbox. const personaToggle = $('preview-persona-toggle'); if (personaToggle) { const label = personaToggle.closest('label'); if (v.persona) { personaToggle.disabled = false; if (label) label.title = "Rewrite text through this voice's character persona before generating"; } else { personaToggle.checked = false; personaToggle.disabled = true; if (label) label.title = 'This voice has no character persona saved — set one on the Voice Inspector page first.'; } } } 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'); let ids; if (shouldReplaceWithLibraryVoices(backend)) { if (!_voices.length) await loadVoiceLibrary(); ids = (_voices || []).filter(v => v.enabled !== false).map(v => v.id); } else { 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))); } ids = voices.map(backendVoiceId); } const sel = $('tts-voice-select'), prev = sel.value; if (window.VoicePicker) { VoicePicker.upgrade('tts-voice-select'); VoicePicker.populate('tts-voice-select', ids); if (prev && ids.includes(prev)) VoicePicker.setValue('tts-voice-select', prev); } else { sel.innerHTML = ''; ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); }); if (prev && ids.includes(prev)) sel.value = prev; } updatePreviewVoiceMatchPanel(); const suffix = (shouldFilterBackendVoices(backend) || shouldReplaceWithLibraryVoices(backend)) ? ' active voices' : ' voices'; toast('Fetched '+ids.length+suffix,'success'); } catch(e) { toast('Fetch failed: '+e.message,'error'); } finally { $('fetch-tts-voices-btn').disabled = false; } }); // ── Emotion quick-pick (Try a Voice / "Quick Play") ───────────────────────── // Fish-Speech ignores the free-text style-instruction field entirely (it only // reads inline [tag] emotion markers from the TEXT itself) — typing an // emotion there silently does nothing on that backend, which is exactly the // bug reported and fixed for the Rehearser/Studio pipeline (see // _rehInlineTone / _rehEmotionEnglishTag in rehearser.js). This reuses that // same translation table so a Fish-Speech voice actually reacts to the pick, // while style-aware backends (VoiceDesign/CustomVoice) keep working through // the instruct field exactly as before. function _ttsIsFishBackend(id) { return /fish/i.test(id || ''); } function _ttsApplyEmotionTag(text, emotionValue) { const backend = $('tts-backend-select')?.value || ''; if (!_ttsIsFishBackend(backend) || !emotionValue) return text; if (/^\s*\[/.test(text)) return text; // already carries an inline tag const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotionValue) : ''; return tag ? `[${tag}] ${text}` : text; } // Deferred to a macrotask: REH_EMOTIONS is a `const` declared in rehearser.js, // which the minified bundle concatenates into ONE shared scope AFTER this file. // A bare `typeof REH_EMOTIONS` here would not return "undefined" — for a const in // its temporal dead zone it THROWS, aborting the rest of the bundle's top-level // initialization (confirmed live: it left every later module's consts permanently // uninitialized). Running after the current task guarantees the whole bundle has // finished executing, so the const is initialized either way. setTimeout(function initPreviewEmotionPicker() { const sel = $('preview-emotion-select'); if (!sel || typeof window.REH_EMOTIONS === 'undefined') return; window.REH_EMOTIONS.forEach(e => { if (!e.value) return; const o = document.createElement('option'); o.value = e.value; o.textContent = `${e.emoji} ${e.label}`; sel.appendChild(o); }); sel.addEventListener('change', () => { const backend = $('tts-backend-select')?.value || ''; const help = $('preview-emotion-help'); if (_ttsIsFishBackend(backend)) { // Fish reads emotion from an inline [tag] in the text, not the // style-instruction field — applied automatically at synth/save time. if (help) { help.style.display = sel.value ? 'block' : 'none'; help.textContent = sel.value ? 'Applied as an inline [tag] in the text for Fish-Speech — the style instruction field below is ignored by this backend.' : ''; } } else { if (help) help.style.display = 'none'; const styleInput = $('preview-style-instruction'); if (styleInput && sel.value) styleInput.value = sel.value; } }); }, 0); $('tts-backend-select').addEventListener('change', () => { const sel = $('tts-voice-select'); sel.innerHTML = ''; updateBackendHelp(); updatePreviewVoiceMatchPanel(); previewBlob = null; $('save-preview-mp3-btn').disabled = true; $('save-preview-btn').disabled = true; $('preview-emotion-select')?.dispatchEvent(new Event('change')); }); $('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; } // A GPU-contended TTS backend container can be mid-restart for a handful of // seconds at a time (see _fetchRetryingNetworkErrors in library-characters.js // for the same fix on the voice-design path) — a bulk job hitting this // function hundreds of times in a row (Synth all / Audiobook export) will // reliably catch that window at least once. Confirmed live: a ~2000-line // export lost 346 lines outright to "Failed to fetch" with zero retry. // A non-ok HTTP response (a real error with a status/detail) is NOT // retried — only a connection-level failure that never got a response at all. async function _ttsPreviewFetchWithRetry(body, tries) { tries = tries || 3; for (let i = 1; i <= tries; i++) { try { return await fetch('/api/tts-preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (e) { if (i === tries) throw e; await new Promise(r => setTimeout(r, 2500 * i)); } } } // A designed voice (origin==='designed', or simply no reference WAV) can // only ever be synthesized through the voice_design engine — it has no // audio to clone a speaker from. The Rehearser/Audiobook pipeline picks one // backend for the whole script (rehState.backend) and used to pass it // straight through for every line regardless of which kind of voice that // specific line's speaker actually has — confirmed live as a real cause of // bulk synthesis failures: any designed voice mixed into a cast synthesized // under a voice_clone-family backend fails outright, every single time, no // retry possible, since the engine genuinely can't do it. Only overrides // for that one case; every other voice still respects whatever backend the // script/global selector actually has picked. function _ttsBackendForVoice(voiceId, fallbackBackend) { if (!voiceId || voiceId === 'me') return fallbackBackend; const v = (window._voices || []).find(x => x.id === voiceId); // Only route to voice_design when there is genuinely no reference clip to // clone from. `origin === 'designed'` used to force voice_design even once // a voice HAD a saved reference — but Voice Design has no seed parameter at // all (confirmed against the model source), so every read of an // already-designed voice was a fresh, unpinned roll instead of a // reproducible clone read. Once a designed voice has a reference clip, it's // exactly as clonable as any other voice, and should be for consistency. if (v && !v.has_ref) return 'voice_design'; return fallbackBackend; } async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) { const body = {text, voice, response_format: responseFormat, instruct, backend}; if (applyPersona) body.apply_persona = true; if (extra && typeof extra === 'object') Object.assign(body, extra); // e.g. {seed, temperature} const r = await _ttsPreviewFetchWithRetry(body); if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const blob = await r.blob(); // Different backends ship very different default output levels (confirmed // live: a cloned Narrator voice noticeably louder than designed-voice // characters in a finished audiobook export) — but normalizing each clip // INDEPENDENTLY to one target level was the first attempt here and broke // something real: it erases a voice's own loudness dynamics along with // fixing the cross-voice gap. Confirmed live: a whispered line came out // LOUDER than its own neutral reading once both got pushed to the same // target — exactly backwards. Instead, apply the voice's OWN already- // computed reference gain (from Calc dB) as a FIXED offset — this shifts // the whole voice's baseline to match others without touching how loud // one particular line is relative to another from the SAME voice. // Best-effort: any failure just ships the original clip. if (responseFormat === 'wav') { const v = (window._voices || []).find(x => x.id === voice); const gainDb = v && v.loudness && typeof v.loudness.gain_db === 'number' ? v.loudness.gain_db : 0; if (gainDb) { try { const nr = await fetch('/api/audio/apply-gain?gain_db=' + encodeURIComponent(gainDb), { method: 'POST', body: blob }); if (nr.ok) return await nr.blob(); } catch (_) { /* ship the un-adjusted clip */ } } } return blob; } // ── TTS→STT round-trip verification ───────────────────────────────────────── // A duration/wpm-only benchmark can't tell "read the line correctly" from // "repeated it twice" or "said something else entirely" — both can produce // a perfectly normal-looking duration and pass every other check. Actually // transcribing the synthesized audio back with Whisper and comparing it to // the original text catches both directly, at the cost of one extra STT // call per check. function _textWords(s) { return String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '') .replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean); } // Bag-of-words Dice coefficient: tolerant of STT word-order/minor // transcription slips, but drops sharply for genuinely different content // (nonsense) or a doubled-up transcript (repeated speech), since the extra // duplicate words inflate the length without a matching increase in overlap. function _textSimilarity(a, b) { const wa = _textWords(a), wb = _textWords(b); if (!wa.length && !wb.length) return 1; if (!wa.length || !wb.length) return 0; const counts = new Map(); wa.forEach(w => counts.set(w, (counts.get(w) || 0) + 1)); let overlap = 0; wb.forEach(w => { const c = counts.get(w); if (c) { overlap++; counts.set(w, c - 1); } }); return (2 * overlap) / (wa.length + wb.length); } async function _voiceRoundtripCheck(voiceId, text, backend, instruct = '') { const blob = await fetchTtsPreviewBlob(voiceId, text, 'wav', instruct, backend); const fd = new FormData(); fd.append('file', blob, 'roundtrip.wav'); fd.append('backend', 'configured'); const r = await fetch('/api/transcribe-bytes', { method: 'POST', body: fd }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); return { transcript: d.text || '', score: _textSimilarity(text, d.text || ''), blob }; } async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) { const mode = effectiveTtsPlaybackMode(modeOverride); if (backend !== 'streaming' || mode === 'buffered') { const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra); 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, extra); 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, instruct) { if (typeof effectsSourceBlob !== 'undefined') window._effectsSourceBlob = null; // Streaming playback (the default, lower-latency path) never produces a // blob — audio plays via MSE without ever being fully buffered client-side // — so gating "Apply effects" on source.blob left it permanently disabled // whenever streaming succeeded, which is the common case. Enable it // regardless; the effects handler re-fetches a buffered blob on demand // (see window._effectsSynthArgs) if one isn't already sitting in memory. window._effectsSynthArgs = { voice, text, instruct: instruct || '', backend }; const ea = $('effects-apply-btn'); if (ea) 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); } const _TRYOUT_SPEED_KEY = 'ttsvc_tryout_native_speed'; (function _restoreTryoutSpeed() { const saved = localStorage.getItem(_TRYOUT_SPEED_KEY); if (saved) { const el = $('preview-native-speed'); if (el) el.value = saved; } })(); $('preview-native-speed')?.addEventListener('change', function () { localStorage.setItem(_TRYOUT_SPEED_KEY, this.value); }); $('preview-btn').addEventListener('click', async () => { const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, instruct=$('preview-style-instruction').value.trim(); const text=_ttsApplyEmotionTag($('preview-text-area').value.trim(), $('preview-emotion-select')?.value); 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; const _nspd = parseFloat($('preview-native-speed')?.value); const _extra = (!isNaN(_nspd) && _nspd !== 1) ? {speed: _nspd} : null; 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, _extra, applyPersona) : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra); 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, instruct); 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, instruct=$('preview-style-instruction').value.trim(); const text=_ttsApplyEmotionTag($('preview-text-area').value.trim(), $('preview-emotion-select')?.value); 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; const PERF_HISTORY_SORT = { key: 'ts', dir: 'desc' }; 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 ``; }).join(''); return ``; } function perfHistoryVoiceLookup(voiceId) { const list = Array.isArray(window._voices) && window._voices.length ? window._voices : (typeof _voices !== 'undefined' && Array.isArray(_voices) ? _voices : []); return list.find(v => v && (v.id === voiceId || v.name === voiceId || v.voice_id === voiceId)) || null; } function perfHistoryVoiceMeta(entry) { const voice = entry?.voice || ''; const saved = entry?.voiceMeta || {}; const lib = perfHistoryVoiceLookup(voice) || {}; const language = saved.lang || saved.language || entry?.lang || entry?.language || lib.lang || lib.language || ''; const gender = String(saved.gender || entry?.gender || lib.gender || '').trim().toUpperCase().charAt(0); return { id: voice, label: saved.label || saved.display_name || entry?.voiceLabel || lib.display_name || lib.name || voice, lang: language, gender: ['F', 'M', 'N'].includes(gender) ? gender : '', flag: saved.flag || entry?.flag || lib.flag || '', avatar: saved.avatar || entry?.avatar || lib.avatar || '', hasPicture: Boolean(saved.has_picture ?? saved.hasPicture ?? entry?.hasPicture ?? lib.has_picture), }; } function perfHistoryExtraFields(voice, backend) { const lib = perfHistoryVoiceLookup(voice) || {}; return { voiceMeta: { label: lib.display_name || lib.name || voice, lang: lib.lang || lib.language || '', gender: lib.gender || '', flag: lib.flag || '', avatar: lib.avatar || '', has_picture: !!lib.has_picture, }, device: typeof backendComputeDevice === 'function' ? backendComputeDevice(backend) : '', }; } function perfHistoryGenderLabel(gender) { return ({F:'Female', M:'Male', N:'Diverse'}[gender] || ''); } function perfHistoryDeviceLabel(entry) { return entry?.device || (typeof backendComputeDevice === 'function' ? backendComputeDevice(entry?.backend || '') : '') || 'Unknown'; } function perfHistoryDeviceHtml(entry) { const label = perfHistoryDeviceLabel(entry); const cls = typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(entry?.backend || '') : (label.toLowerCase().includes('gpu') ? 'gpu' : label.toLowerCase().includes('cpu') ? 'cpu' : ''); return `${escHtml(label)}`; } function perfHistoryAvatarHtml(entry) { const meta = perfHistoryVoiceMeta(entry); const title = meta.label || meta.id || 'Voice'; if (meta.hasPicture) { return ``; } const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null; if (icon) return `${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}`; const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280'; const init = (title || '?').trim()[0]?.toUpperCase() || '?'; return `${escHtml(init)}`; } function perfHistorySortValue(entry, key) { switch (key) { case 'backend': return String(entry.backend || '').toLowerCase(); case 'language': return String(perfHistoryVoiceMeta(entry).lang || '').toLowerCase(); case 'gender': return String(perfHistoryGenderLabel(perfHistoryVoiceMeta(entry).gender) || '').toLowerCase(); case 'voice': return String(entry.voice || '').toLowerCase(); case 'device': return String(perfHistoryDeviceLabel(entry) || '').toLowerCase(); case 'avgLatencyMs': return Number(entry.avgLatencyMs); case 'minLatencyMs': return Number(entry.minLatencyMs); case 'avgRtf': return Number(entry.avgRtf); case 'ts': default: return Number(entry.ts); } } function perfHistoryCompare(a, b) { const av = perfHistorySortValue(a, PERF_HISTORY_SORT.key); const bv = perfHistorySortValue(b, PERF_HISTORY_SORT.key); let result = 0; if (typeof av === 'string' || typeof bv === 'string') { result = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' }); } else { const an = Number.isFinite(av) ? av : -Infinity; const bn = Number.isFinite(bv) ? bv : -Infinity; result = an === bn ? 0 : an - bn; } return PERF_HISTORY_SORT.dir === 'asc' ? result : -result; } function perfHistoryHeadButton(key, label) { const active = PERF_HISTORY_SORT.key === key; const icon = active ? (PERF_HISTORY_SORT.dir === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down') : 'mdi-swap-vertical'; return ``; } 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(); 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; } entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts))); const head = `
${perfHistoryHeadButton('ts', 'Date / Time')} ${perfHistoryHeadButton('backend', 'Backend')} Profile ${perfHistoryHeadButton('language', 'Language')} ${perfHistoryHeadButton('gender', 'Gender')} ${perfHistoryHeadButton('voice', 'Voice')} ${perfHistoryHeadButton('device', 'CPU / GPU')} ${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')} ${perfHistoryHeadButton('minLatencyMs', 'Min')} ${perfHistoryHeadButton('avgRtf', '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'; const meta = perfHistoryVoiceMeta(e); return `
${escHtml(dt)} ${escHtml(e.backend)} ${perfHistoryAvatarHtml(e)} ${escHtml(meta.lang || '-')} ${escHtml(perfHistoryGenderLabel(meta.gender) || '-')} ${escHtml(e.voice)} ${perfHistoryDeviceHtml(e)} ${Math.round(e.avgLatencyMs)} ms ${Math.round(e.minLatencyMs)} ms ${e.avgRtf.toFixed(2)}
`; }).join(''); histList.innerHTML = head + rows; histList.querySelectorAll('.perf-history-sort').forEach(btn => { btn.addEventListener('click', () => { const key = btn.dataset.historySort || 'ts'; if (PERF_HISTORY_SORT.key === key) { PERF_HISTORY_SORT.dir = PERF_HISTORY_SORT.dir === 'asc' ? 'desc' : 'asc'; } else { PERF_HISTORY_SORT.key = key; PERF_HISTORY_SORT.dir = key === 'backend' || key === 'voice' ? 'asc' : 'desc'; } renderPerfHistory(); }); }); 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 => `` ).join('') || ''; } populatePerfBackends(); if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' }); 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; const items = rawVoices.map(v => { const id = backendVoiceId(v); return { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) }; }).filter(v => v.id); if (window.BenchmarkVoicePicker) { BenchmarkVoicePicker.populate('perf-voice-select', items, { placeholder: '-- select after fetch --', empty: 'No voices' }); if (cur && items.some(v => v.id === cur)) BenchmarkVoicePicker.set('perf-voice-select', cur); } else { perfVoiceSel.innerHTML = items.map(v => ``).join('') || ''; } window.dispatchEvent(new CustomEvent('benchmark:tts-voices-fetched', { detail: { backend, voices: items } })); } 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 = ' Stable'; } else if (delta < 0) { cls = 'perf-trend-better'; label = ` ${pct.toFixed(0)}% faster`; } else { cls = 'perf-trend-worse'; label = ` ${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 ` ${i+1} ${escHtml(r.backend)} ${escHtml(typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend) : 'Unknown')} ${escHtml(r.voice)} ${ok ? r.latencyMs : '—'} ${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'} ${ok ? rtf : '—'} ${ok ? 'OK' : `${escHtml(r.error||'Error')}`} `; }).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 = ` ${Math.round(avg)} ms avg latency ${minL} ms best ${maxL} ms worst ${avgRtf.toFixed(2)} avg RTF ${avgRtf < 1 ? ' Real-time capable' : ' Slower than real-time'} `; if (sessionDone && rtfArr.length) { updateTrendDisplay(perfBackendSel.value, perfVoiceSel.value, avgRtf); perfHistoryAdd({ ts: Date.now(), backend: perfBackendSel.value, voice: perfVoiceSel.value, ...perfHistoryExtraFields(perfVoiceSel.value, perfBackendSel.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 = 'All runs failed'; } } 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(); })();