// ── 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