Fish-Speech clones from the app's own saved WAV library but was missing from the set of backends populated with those voices, so the UI probed the Fish server for a voice-listing endpoint it does not have, showed "Fetched 0 voices" and left the engine unusable despite being healthy. It now reports the full library (218 voices). Also adds a Fish-only panel listing all 49 documented emotion tags with click-to-insert and a multi-emotion example, and corrects the backend's advertised capabilities: it was flagged style_aware with "emotion markers are honoured per request", but measurement shows a reference clip's in-context prosody overwhelms inline tags (10x loudness spread across emotions without a reference, 1.2x with one). It is now described by what it does well: deterministic, byte-reproducible cloning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
976 lines
49 KiB
JavaScript
976 lines
49 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 || '');
|
|
}
|
|
|
|
// 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 = '<option value="">— select after fetch —</option>';
|
|
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 || ''); }
|
|
|
|
// ── Fish Audio inline [tag] reference ───────────────────────────────────────
|
|
// Fish is the only backend here that reads bracket tags straight out of the text
|
|
// (every other engine takes a separate instruct field and would speak "[happy]"
|
|
// aloud as literal words), so this panel only appears for Fish backends.
|
|
// Tag list per Fish Audio's emotion documentation: 24 basic + 25 advanced.
|
|
const FISH_EMOTION_TAGS = {
|
|
'Basic emotions': [
|
|
['happy','Cheerful, upbeat'], ['sad','Melancholic, downcast'], ['angry','Frustrated, aggressive'],
|
|
['excited','Energetic, enthusiastic'], ['calm','Peaceful, relaxed'], ['nervous','Anxious, uncertain'],
|
|
['confident','Assertive, self-assured'], ['surprised','Shocked, amazed'], ['satisfied','Content, pleased'],
|
|
['delighted','Very pleased, joyful'], ['scared','Frightened, fearful'], ['worried','Concerned, troubled'],
|
|
['upset','Disturbed, distressed'], ['frustrated','Annoyed, exasperated'], ['depressed','Very sad, hopeless'],
|
|
['empathetic','Understanding, caring'], ['embarrassed','Ashamed, awkward'], ['disgusted','Repelled, revolted'],
|
|
['moved','Emotionally touched'], ['proud','Accomplished, satisfied'], ['relaxed','At ease, casual'],
|
|
['grateful','Thankful, appreciative'], ['curious','Inquisitive, interested'], ['sarcastic','Ironic, mocking'],
|
|
],
|
|
'Advanced emotions': [
|
|
['disdainful','Contemptuous, scornful'], ['unhappy','Discontent, dissatisfied'], ['anxious','Very worried, uneasy'],
|
|
['hysterical','Uncontrollably emotional'], ['indifferent','Uncaring, neutral'], ['uncertain','Doubtful, unsure'],
|
|
['doubtful','Skeptical, questioning'], ['confused','Puzzled, perplexed'], ['disappointed','Let down'],
|
|
['regretful','Sorry, remorseful'], ['guilty','Culpable, responsible'], ['ashamed','Deeply embarrassed'],
|
|
['jealous','Envious, resentful'], ['envious','Wanting what others have'], ['hopeful','Optimistic about future'],
|
|
['optimistic','Positive outlook'], ['pessimistic','Negative outlook'], ['nostalgic','Longing for the past'],
|
|
['lonely','Isolated, alone'], ['bored','Uninterested, weary'], ['contemptuous','Showing contempt'],
|
|
['sympathetic','Showing sympathy'], ['compassionate','Showing deep care'], ['determined','Resolved, decided'],
|
|
['resigned','Accepting defeat'],
|
|
],
|
|
};
|
|
const FISH_TAG_SAMPLE_TEXT = `[happy] I got the promotion!
|
|
[uncertain] But... it means relocating.
|
|
[sad] I'll miss everyone here.
|
|
[hopeful] Though it's a great opportunity.
|
|
[determined] I'm going to make it work!`;
|
|
|
|
function _fishInsertTag(tag) {
|
|
const ta = $('preview-text-area');
|
|
if (!ta) return;
|
|
const snippet = `[${tag}] `;
|
|
const s = ta.selectionStart ?? ta.value.length, e = ta.selectionEnd ?? ta.value.length;
|
|
ta.value = ta.value.slice(0, s) + snippet + ta.value.slice(e);
|
|
const pos = s + snippet.length;
|
|
ta.setSelectionRange(pos, pos);
|
|
ta.focus();
|
|
}
|
|
|
|
function _fishTagsRender() {
|
|
const body = $('fish-tags-body');
|
|
if (!body || body.dataset.built) return;
|
|
body.innerHTML = Object.entries(FISH_EMOTION_TAGS).map(([group, tags]) => `
|
|
<div class="fish-tags-group">
|
|
<div class="fish-tags-group-title">${escHtml(group)} <span class="note">(${tags.length})</span></div>
|
|
<div class="fish-tags-grid">
|
|
${tags.map(([t, d]) => `<button type="button" class="fish-tag" data-tag="${escHtml(t)}" title="${escHtml(d)}">[${escHtml(t)}]</button>`).join('')}
|
|
</div>
|
|
</div>`).join('');
|
|
body.querySelectorAll('.fish-tag').forEach(b =>
|
|
b.addEventListener('click', () => _fishInsertTag(b.dataset.tag)));
|
|
body.dataset.built = '1';
|
|
}
|
|
|
|
function _fishTagsSync() {
|
|
const panel = $('fish-tags-panel');
|
|
if (!panel) return;
|
|
panel.hidden = !_ttsIsFishBackend($('tts-backend-select')?.value || '');
|
|
}
|
|
|
|
(function initFishTagsPanel() {
|
|
const toggle = $('fish-tags-toggle'), body = $('fish-tags-body');
|
|
toggle?.addEventListener('click', () => {
|
|
_fishTagsRender();
|
|
body.hidden = !body.hidden;
|
|
toggle.textContent = body.hidden ? 'Show tags' : 'Hide tags';
|
|
});
|
|
$('fish-tags-sample')?.addEventListener('click', () => {
|
|
const ta = $('preview-text-area');
|
|
if (ta) { ta.value = FISH_TAG_SAMPLE_TEXT; ta.focus(); }
|
|
});
|
|
})();
|
|
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;
|
|
}
|
|
});
|
|
// Sections are injected asynchronously, so sync the Fish panel once the
|
|
// backend <select> actually exists rather than at module-eval time.
|
|
_fishTagsSync();
|
|
}, 0);
|
|
|
|
$('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;
|
|
$('preview-emotion-select')?.dispatchEvent(new Event('change'));
|
|
_fishTagsSync();
|
|
});
|
|
|
|
$('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 `<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 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 `<span class="bench-device ${escHtml(cls)}">${escHtml(label)}</span>`;
|
|
}
|
|
|
|
function perfHistoryAvatarHtml(entry) {
|
|
const meta = perfHistoryVoiceMeta(entry);
|
|
const title = meta.label || meta.id || 'Voice';
|
|
if (meta.hasPicture) {
|
|
return `<span class="perf-history-avatar" title="${escHtml(title)}"><img src="/api/voice/picture/${encodeURIComponent(meta.id)}" alt=""></span>`;
|
|
}
|
|
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null;
|
|
if (icon) return `<span class="perf-history-avatar" title="${escHtml(title)}">${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}</span>`;
|
|
const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280';
|
|
const init = (title || '?').trim()[0]?.toUpperCase() || '?';
|
|
return `<span class="perf-history-avatar perf-history-avatar-init" style="background:${color}" title="${escHtml(title)}">${escHtml(init)}</span>`;
|
|
}
|
|
|
|
|
|
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 `<button class="perf-history-sort${active ? ' active' : ''}" type="button" data-history-sort="${escHtml(key)}" aria-label="Sort by ${escHtml(label)}">
|
|
<span>${escHtml(label)}</span><span class="mdi ${icon}" aria-hidden="true"></span>
|
|
</button>`;
|
|
}
|
|
|
|
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 = '<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;
|
|
}
|
|
entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts)));
|
|
const head = `<div class="perf-history-row perf-history-head">
|
|
<span>${perfHistoryHeadButton('ts', 'Date / Time')}</span>
|
|
<span>${perfHistoryHeadButton('backend', 'Backend')}</span>
|
|
<span>Profile</span>
|
|
<span>${perfHistoryHeadButton('language', 'Language')}</span>
|
|
<span>${perfHistoryHeadButton('gender', 'Gender')}</span>
|
|
<span>${perfHistoryHeadButton('voice', 'Voice')}</span>
|
|
<span>${perfHistoryHeadButton('device', 'CPU / GPU')}</span>
|
|
<span>${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')}</span>
|
|
<span>${perfHistoryHeadButton('minLatencyMs', 'Min')}</span>
|
|
<span>${perfHistoryHeadButton('avgRtf', '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';
|
|
const meta = perfHistoryVoiceMeta(e);
|
|
return `<div class="perf-history-row">
|
|
<span class="perf-history-ts">${escHtml(dt)}</span>
|
|
<span>${escHtml(e.backend)}</span>
|
|
<span>${perfHistoryAvatarHtml(e)}</span>
|
|
<span>${escHtml(meta.lang || '-')}</span>
|
|
<span>${escHtml(perfHistoryGenderLabel(meta.gender) || '-')}</span>
|
|
<span>${escHtml(e.voice)}</span>
|
|
<span>${perfHistoryDeviceHtml(e)}</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-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 =>
|
|
`<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
|
|
).join('') || '<option value="">No backends available</option>';
|
|
}
|
|
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 => `<option value="${escHtml(v.id)}"${v.id===cur?' selected':''}>${escHtml(v.id)}</option>`).join('') || '<option value="">No voices</option>';
|
|
}
|
|
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 = '<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><span class="bench-device ${typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend) : ''}" title="Inferred from the selected TTS backend metadata">${escHtml(typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend) : 'Unknown')}</span></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,
|
|
...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 = '<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();
|
|
})();
|
|
|