// ── 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 || ''); }
// ── 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]) => `
${escHtml(group)} (${tags.length})
${tags.map(([t, d]) => ``).join('')}
`).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