// ── 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; }
});
$('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;
});
$('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);
if (v && (v.origin === 'designed' || !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, 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;
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, 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;
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 ``;
}
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 = '