`; return; }
box.innerHTML = cands.map((cand, i) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`,
})).join('');
}
// Import a freshly searched fish.audio result, assign it, and fold it into candidates
async function _rehUseSearchResult(sp, idx, btn) {
const c = rehState.cast[sp]; if (!c) return;
const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return;
_rehStopAudition();
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid);
if (!c.online) c.online = { candidates: [], picked: 0 };
c.online.candidates.push(cand);
c.online.picked = c.online.candidates.length - 1;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
// Remember fish imports this session so the same voice isn't downloaded twice
const _rehFishImported = {};
// Import one fish.audio candidate into the library and return its voice_id.
// Reuses an already-imported voice (this session OR already in the library) so the
// same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, …
async function _rehImportFishCandidate(sp, cand) {
const key = (cand.sample_audio || cand.title || '').toLowerCase();
if (key && _rehFishImported[key]) {
const id = _rehFishImported[key];
if (!rehState.voices.includes(id)) rehState.voices.push(id);
return id;
}
const title = (cand.title || '').toLowerCase().trim();
const existing = title && (window._voices || []).find(v =>
(v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title);
if (existing) {
if (key) _rehFishImported[key] = existing.id;
if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id);
return existing.id;
}
const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
const vid = `${lang2}_${(typeof _umlautSafe === 'function' ? _umlautSafe(cand.title || sp) : (cand.title || sp)).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
const d = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
}).then(r => r.json());
if (!d.voice_id) throw new Error('import returned no id');
if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e));
if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e));
if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id);
if (key) _rehFishImported[key] = d.voice_id;
return d.voice_id;
}
// "Disagree" → switch this character to one of the presented alternatives
async function _rehUseCandidate(sp, idx, btn) {
const c = rehState.cast[sp]; const o = c && c.online; if (!o) return;
const cand = o.candidates[idx]; if (!cand) return;
_rehStopAudition();
if (cand.voice_id) { // already imported earlier — just reassign
c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx;
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
return;
}
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names
function _ensureNarrator() {
if (!rehState.cast[REH_NARRATOR_KEY]) {
rehState.cast[REH_NARRATOR_KEY] = {
voice: rehState.narratorVoice || '',
color: '#6b7280',
instruct: '',
voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null,
_isNarrator: true,
};
}
// Bidirectional sync between the narrator cast row and the global narratorVoice.
// The cast row is the source of truth (auto-design assigns the voice there), but
// playback/synth read rehState.narratorVoice — keep them aligned both ways.
const n = rehState.cast[REH_NARRATOR_KEY];
if (n.voice) {
rehState.narratorVoice = n.voice;
if (!n.voiceData) n.voiceData = getVoiceData(n.voice);
} else if (rehState.narratorVoice) {
n.voice = rehState.narratorVoice;
n.voiceData = getVoiceData(rehState.narratorVoice);
}
}
const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish'];
const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']];
// Apply a mutation to every dialog line spoken by a character
function _castApplyToLines(sp, fn) {
rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
}
function _safeDomId(value) {
let out = '';
const s = String(value || 'voice');
for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-';
return out || 'voice';
}
// Cross-referencing the Character Library gives the cast cards a real
// portrait, tier badge, and occupation/archetype instead of just a colored
// initial — pulled from whatever book/script this cast shares a title with.
// Fetched once per script title (not per render) and cached. Two separate
// renderers share this one cache — renderCastList (the "Who's playing which
// character?" mecast panel) and renderCastStrip (the Stage sidebar's own
// character list, see below) — but only ONE fetch is ever kicked off per
// script title, guarded by _rehLibCharsCacheBook. Whichever renderer's guard
// check happens to run first "claims" that fetch; the other one sees the
// book already marked as fetched and skips starting its own — so BOTH must
// be re-run once the shared fetch resolves, not just whichever one started
// it. Confirmed live as a real bug: entering Perform & Export borrows both
// panels at once, renderCastList's guard usually wins the race, and its own
// old single-target callback left the Stage sidebar stuck on plain
// colored-letter dots forever (never re-rendered with portraits) even
// though the cache had genuinely finished loading with images moments
// later — only calling renderCastStrip() by hand fixed it.
let _rehLibCharsCache = null;
let _rehLibCharsCacheBook = null;
function _rehEnsureLibCharsCache(scriptTitle) {
if (!scriptTitle || _rehLibCharsCacheBook === scriptTitle || typeof clGetAllByTagOrBook !== 'function') return;
_rehLibCharsCacheBook = scriptTitle;
clGetAllByTagOrBook(scriptTitle).then(recs => {
_rehLibCharsCache = recs || [];
renderCastList();
if (typeof renderCastStrip === 'function') renderCastStrip();
// Stage's per-line portraits (_rehCharAvatarHtml) also read this cache
// directly, not just the sidebar row — re-run the full script page too
// so lines that rendered before the cache arrived pick up portraits.
if (typeof buildScriptPage === 'function') buildScriptPage();
}).catch(() => {});
}
try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; }
// Cast sort + filter state (narrator excluded — it's pinned on top by renderCastList)
rehState.castFilter = { search: '', gender: '', lang: '' };
try { rehState.castSort = JSON.parse(localStorage.getItem('reh-cast-sort')) || { by: 'name', dir: 'asc' }; }
catch (_) { rehState.castSort = { by: 'name', dir: 'asc' }; }
// Remove a character and all of their dialog lines (with index-state remap)
function _castDeleteCharacter(sp) {
const n = rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
if (!confirm(`Delete “${sp}” and their ${n} line${n!==1?'s':''}? This cannot be undone.`)) return;
for (let i = rehState.lines.length - 1; i >= 0; i--) {
if (rehState.lines[i].speaker === sp && rehState.lines[i].type === 'dialog') {
rehState.lines.splice(i, 1);
_reindexLineState(i);
}
}
delete rehState.cast[sp];
renderCastList();
if (rehState.lines.length) buildScriptPage();
toast(`Removed ${sp}`, 'success');
}
// All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
// in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
function _rehAllVoiceIds(include) {
const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include)));
(window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); });
if (include) ids.add(include);
return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function populateNarratorSelect() {
const sel = $('reh-narrator-voice'); if (!sel) return;
const cur = rehState.narratorVoice || sel.value;
sel.innerHTML = '' +
_rehAllVoiceIds(cur).map(v => ``).join('');
}
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
$('reh-fetch-voices-btn').disabled = true;
try {
if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
await loadVoiceLibrary().catch(() => {});
}
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [])
.filter(id => _rehVoiceVisibleId(id));
renderCastList();
populateNarratorSelect();
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
finally { $('reh-fetch-voices-btn').disabled = false; }
});
$('reh-narrator-voice')?.addEventListener('change', function () { rehState.narratorVoice = this.value; });
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
// ── TTS instruct builder — emotion goes first as a directive ─────────────────
// voice_clone backends treat instruct as an IDENTITY description; putting the
// emotion first and using directive language ("Speak in a … manner") makes the
// model prioritise it over the static voice description, which otherwise wins.
// Casting tags emotions in the BOOK's own language (this app runs heavily
// with German books, so `emotion` here is usually a German word like
// "fordernd" or "entschlossen") — but the instruct sentence wrapping it was
// always hardcoded English ("Speak in a fordernd manner."), dropping a
// German adjective into an English carrier sentence. Confirmed as the
// likely reason emotion barely registered for designed voices even though
// isolated English-only test instructs ("speak in an extremely angry
// manner") clearly changed the output — a mixed-language instruct is a much
// weaker signal than a natural sentence in one language. `voiceId`'s own
// language-code prefix (DE_/EN_/...) picks the matching template.
const _BUILD_INSTRUCT_TEMPLATES = {
DE: e => `Sprich in einem ${e} Tonfall.`,
EN: e => `Speak in a ${e} manner.`,
};
// Same wording _buildVoicePrompt uses for the one-time design call — that
// clause was NEVER being resent on ongoing lines: the per-line instruct
// only ever carried the character's saved voice_design_prompt (a plain
// voice-quality description with no accent guidance in it at all), because
// the accent clause was built fresh at design time and never written back
// into that saved profile. Since each synthesis call is stateless — the
// engine has no memory of the original design call — omitting it here meant
// every ONGOING line got zero accent reinforcement, only the one-off
// creation call ever did. Confirmed as a real, separate cause of designed
// voices drifting back toward an American accent during actual narration.
const _BUILD_INSTRUCT_LANG_NAMES = { DE: 'German', FR: 'French', ES: 'Spanish', IT: 'Italian', PT: 'Portuguese', NL: 'Dutch', PL: 'Polish' };
function _buildAccentClause(langCode) {
const langName = _BUILD_INSTRUCT_LANG_NAMES[langCode];
if (langName) return `Speak with an authentic native ${langName} accent — not American-accented, not an English speaker doing ${langName}.`;
if (langCode === 'EN') return 'English with a neutral British or international accent, explicitly not American/US-accented.';
return '';
}
function _buildInstruct(voiceProfile, emotion, voiceId) {
const p = (voiceProfile || '').trim();
const e = (emotion || '').trim();
const langCode = String(voiceId || '').split('_')[0].toUpperCase();
const accent = _buildAccentClause(langCode);
if (!e && !p && !accent) return '';
// The emotion clause is now ALWAYS built in English, regardless of the voice's
// own language — confirmed via controlled A/B testing (same line, same voice,
// varying only instruct language) that Qwen3-TTS's emotional differentiation is
// dramatically stronger in English than German: sad/angry/shocked/happy formed a
// clean, coherent pitch gradient (274Hz -> 398Hz) in English, but stayed muddled
// together in German even with the exact same wording translated. The spoken
// TEXT and the accent clause below stay fully native-language — only the
// emotion instruction itself changes language. The raw per-line emotion is
// LLM-generated in the book's own language (see the casting prompt's "1-2
// deutsche Wörter"), so translate it before building the clause.
const emotionEn = e && langCode !== 'EN' && typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(e) : '';
const effectiveEmotion = emotionEn || e;
const emotionTmpl = _BUILD_INSTRUCT_TEMPLATES.EN;
// Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns
// it "does not follow instructions correctly when dealing with
// conflicting attributes... favoring one over the other." Putting the
// accent clause first (as the previous version did) made it the most
// prominent instruction on every single line, which lines up with the
// reported regression right after that fix landed: mood stopped coming
// through. Emotion is the one thing that MUST vary per line; accent is a
// constant reminder the voice's own identity should mostly already carry,
// so it goes last, not first.
// ...and when an emotion IS present, the accent clause is dropped entirely rather
// than merely demoted. Measured on the deterministic voice_clone backend (identical
// input reproduces bit-identical audio, so these differences are real, not sampling
// noise): with an emotion-only instruct the tones separate cleanly and in the right
// order — whisper 206Hz, sad 170Hz, neutral 225Hz, happy 234Hz, angry 233Hz, scared
// 239Hz, with sad also slower and quieter. Appending "Speak with an authentic native
// German accent." to those same instructs collapsed the whole range to 222-250Hz and
// erased sad almost completely. Two style directives in one instruct compete, and the
// trailing one wins — which is exactly the failure Qwen3-TTS's own prompting guidance
// warns about for conflicting attributes. A cloned voice already carries its accent
// from the reference WAV, so accent reinforcement is the cheaper of the two to lose;
// lines with no emotion still get it.
const parts = e
? [emotionTmpl(effectiveEmotion), p].filter(Boolean)
: [p, accent].filter(Boolean);
return parts.join(' ');
}
// Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the
// input text (the OpenAI-style `instruct` field is ignored). For fish backends we
// therefore prepend the tone as a [tag] so per-line tones actually take effect.
function _rehBackendIsFish() {
let id = '';
try { id = (typeof backendById === 'function' && backendById(rehState.backend)?.id) || rehState.backend || ''; }
catch (_) { id = rehState.backend || ''; }
return /fish/i.test(id);
}
// Fish-Speech's docs are explicit: "Use English emotion tags in square brackets
// regardless of the spoken language." Per-line auto emotions are LLM-generated in the
// book's own language (the casting prompt literally asks for "1-2 deutsche Wörter"),
// which is what _buildInstruct's native-language sentence needs for Qwen3-TTS — but
// that same raw word is useless as a Fish [tag] untranslated. Confirmed live: German
// tags were silently ignored, producing flat/emotionless Fish-Speech output even
// though the audio itself was fine. This table only needs to cover common tone/mood
// adjectives, not a full dictionary — unrecognized words fall through to no tag at
// all (line 8 below), which is a NOP for Fish, safer than sending a foreign-language
// word it will just ignore anyway.
const _REH_EMOTION_DE_EN = {
'wütend':'angry','zornig':'angry','erzürnt':'angry','verärgert':'annoyed','gereizt':'irritated',
'traurig':'sad','melancholisch':'melancholic','betrübt':'sad','niedergeschlagen':'dejected',
'ängstlich':'scared','furchtsam':'fearful','verängstigt':'frightened','panisch':'panicked','nervös':'nervous',
'fröhlich':'happy','glücklich':'happy','freudig':'joyful','heiter':'cheerful','vergnügt':'delighted',
'flüsternd':'whispering','leise':'quiet','gedämpft':'hushed',
'aufgeregt':'excited','begeistert':'enthusiastic','euphorisch':'euphoric',
'überrascht':'surprised','erstaunt':'astonished','verblüfft':'amazed',
'genervt':'annoyed','frustriert':'frustrated',
'verzweifelt':'desperate','hoffnungslos':'hopeless','resigniert':'resigned',
'entschlossen':'determined','entschieden':'decisive',
'selbstbewusst':'confident','stolz':'proud','arrogant':'arrogant',
'schüchtern':'shy','verlegen':'embarrassed','unsicher':'uncertain',
'ironisch':'sarcastic','sarkastisch':'sarcastic','spöttisch':'mocking','höhnisch':'scornful','verächtlich':'contemptuous',
'ernst':'serious','streng':'stern','autoritär':'authoritative','befehlend':'commanding',
'sanft':'gentle','zärtlich':'tender','liebevoll':'loving','warm':'warm',
'kalt':'cold','distanziert':'distant','gleichgültig':'indifferent','gelangweilt':'bored',
'geheimnisvoll':'mysterious','unheimlich':'eerie','düster':'ominous','bedrohlich':'threatening',
'dramatisch':'dramatic','theatralisch':'theatrical','pathetisch':'melodramatic',
'ruhig':'calm','gelassen':'composed','besonnen':'measured',
'schockiert':'shocked','entsetzt':'horrified','fassungslos':'stunned',
'zögernd':'hesitant','verwirrt':'confused','ratlos':'bewildered','unschlüssig':'undecided',
'weinend':'tearful','schluchzend':'sobbing','trauernd':'grieving','gebrochen':'broken',
'schroff':'curt','barsch':'gruff','grob':'rough','abweisend':'dismissive',
'freundlich':'friendly','herzlich':'warm','einladend':'welcoming',
'spielerisch':'playful','neckend':'teasing','frech':'cheeky','schelmisch':'mischievous',
'romantisch':'romantic','sehnsüchtig':'longing','verliebt':'infatuated',
'triumphierend':'triumphant','siegessicher':'victorious',
'erleichtert':'relieved','beruhigt':'reassured',
'schuldbewusst':'guilty','reumütig':'remorseful',
'neugierig':'curious','interessiert':'interested',
'müde':'weary','erschöpft':'exhausted',
'wehmütig':'wistful','nostalgisch':'nostalgic',
'bemerkend':'remarking','feststellend':'noting','sachlich':'matter-of-fact','nüchtern':'plain',
'flehend':'pleading','bittend':'imploring',
'warnend':'warning','mahnend':'admonishing',
'trotzig':'defiant','rebellisch':'rebellious',
'erschrocken':'startled','verstört':'disturbed',
};
function _rehEmotionEnglishTag(emotion) {
const raw = (emotion || '').trim();
if (!raw) return '';
// Preset emotions (REH_EMOTIONS / custom) are already English descriptive phrases —
// the first, most tag-like word is what Fish actually needs.
const firstWord = raw.split(/[,;]\s*/)[0].trim();
const lower = firstWord.toLowerCase();
// Check the DE→EN table BEFORE assuming "looks ASCII" means "is English" — most
// German emotion adjectives (e.g. "bedrohlich") are pure a-z too, so charset alone
// can't distinguish them from English; the dict must win first.
if (_REH_EMOTION_DE_EN[lower]) return _REH_EMOTION_DE_EN[lower];
// Try stripping a common German adjective ending (declined forms LLMs sometimes
// produce, e.g. "ängstliche" instead of "ängstlich") and match on the stem.
const stem = lower.replace(/(e|er|es|en|em)$/, '');
if (stem.length >= 4) {
for (const key in _REH_EMOTION_DE_EN) {
if (key.startsWith(stem)) return _REH_EMOTION_DE_EN[key];
}
}
if (/^[a-z\- ]+$/.test(lower)) return lower; // not in the table, but looks English (e.g. REH_EMOTIONS presets)
return ''; // no reliable English tag — omit rather than send a word Fish will ignore
}
function _rehInlineTone(text, emotion) {
if (!_rehBackendIsFish()) return text;
if (/^\s*\[/.test(text)) return text; // already carries an inline [tag]
const tag = _rehEmotionEnglishTag(emotion);
return tag ? `[${tag}] ${text}` : text;
}
// ── Auto-design voices with LLM + Design a Voice ────────────────────────────
const REH_LANG_CODE = { English:'EN', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Auto:'EN' };
function rehDefaultLlmUrl() {
try {
if (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url)
return _appSettings.llm_url;
} catch(_) {}
return 'http://localhost:11434/v1';
}
// Collect all LLM endpoints visible in the Language Models section
function rehCollectLlmEndpoints() {
const seen = new Set();
const results = [];
const add = (url, label) => {
if (!url) return;
url = url.trim();
if (!url || seen.has(url)) return;
seen.add(url);
results.push({ url, label: label || url });
};
// 1. Active LLM from settings
add(rehDefaultLlmUrl(), 'Active LLM');
// 2. All URL inputs in the Language Models section (llm-local-url-inp)
document.querySelectorAll('.llm-local-url-inp, [data-llm-local-key]').forEach(inp => {
const v = inp.value?.trim();
const def = inp.dataset.llmLocalDefault;
const key = inp.dataset.llmLocalKey || inp.dataset.dcUrlKey || '';
const card = inp.closest('[class*="llm-local-card"], [class*="llm-local"]');
const name = card?.querySelector('.llm-local-name')?.textContent?.trim() || key;
add(v || def, name);
});
// 3. The dc-url-inp fields with LLM role
document.querySelectorAll('.dc-url-inp').forEach(inp => {
const card = inp.closest('[class*="llm-local-card"]');
if (!card) return;
const name = card.querySelector('.llm-local-name')?.textContent?.trim() || '';
add(inp.value?.trim() || inp.dataset.dcDefault, name);
});
// 4. Always include common defaults as suggestions
const DEFAULTS = [
['http://localhost:11434/v1', 'Ollama'],
['http://localhost:8000/v1', 'vLLM'],
['http://localhost:1234/v1', 'LM Studio'],
['http://localhost:28080/v1', 'llama-swap'],
['http://localhost:14000/v1', 'LiteLLM'],
];
DEFAULTS.forEach(([u, l]) => add(u, l));
return results;
}
// Prefill LLM URL + datalist when Phase 2 first shows voices
function rehInitLlmField() {
const u = $('reh-llm-url');
if (!u) return;
if (!u.value) u.value = rehDefaultLlmUrl();
// Populate datalist with all known LLM endpoints
const dl = $('reh-llm-url-list');
if (dl) {
dl.innerHTML = rehCollectLlmEndpoints()
.map(e => ``)
.join('');
}
}
$('reh-llm-refresh')?.addEventListener('click', async () => {
const url = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
const sel = $('reh-llm-model'); if (!sel) return;
sel.innerHTML = '';
try {
const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url));
const d = await r.json();
const models = d.models || [];
sel.innerHTML = '' +
models.map(m => ``).join('');
// Default to the global Active LLM model when available (still overridable here)
const want = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings.llm_model : '';
if (want && models.includes(want)) sel.value = want;
toast(models.length ? `Found ${models.length} models` : 'No models found', models.length ? 'success' : 'error');
} catch(e) {
sel.innerHTML = '';
toast('Could not list models: ' + e.message, 'error');
}
});
// Avatar icon keys for designed voices — male / female / neutral / robot / animal.
// Picked from the LLM description (robot/animal cues) then gender.
const REH_AVATAR_ICONS = {
male: 'mdi-face-man',
female: 'mdi-face-woman',
neutral: 'mdi-account',
robot: 'mdi-robot-outline',
animal: 'mdi-paw',
};
function _pickVoiceAvatar(gender, desc, speaker) {
const d = ((desc || '') + ' ' + (speaker || '')).toLowerCase();
if (/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)) return 'robot';
if (/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)) return 'animal';
if (gender === 'M') return 'male';
if (gender === 'F') return 'female';
return 'neutral';
}
let rehDesignCancelled = false;
$('reh-autodesign-cancel')?.addEventListener('click', () => { rehDesignCancelled = true; });
$('reh-autodesign-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
const speakers = Object.keys(rehState.cast);
if (!speakers.length) { toast('No characters to design for', 'error'); return; }
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
const llmUrl = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
const llmModel= $('reh-llm-model')?.value || '';
const language= $('reh-design-lang')?.value || 'English';
const langCode= REH_LANG_CODE[language] || 'EN';
const scriptTitle = $('reh-script-title')?.value.trim() || 'Script';
const tag = (typeof _umlautSafe === 'function' ? _umlautSafe(scriptTitle) : scriptTitle).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';
const btn = $('reh-autodesign-btn');
const prog = $('reh-autodesign-progress');
const fill = $('reh-autodesign-fill');
const label= $('reh-autodesign-label');
btn.disabled = true;
rehDesignCancelled = false;
if (prog) prog.hidden = false;
const setProg = (d, t, msg) => {
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
if (label) label.textContent = msg || `${d} / ${t}`;
};
setProg(0, speakers.length, 'Analyzing script with LLM…');
// 1) Ask the LLM to describe each character
let characters;
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script, names: speakers, llm_url: llmUrl, model: llmModel, language }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
characters = d.characters || [];
} catch(e) {
toast('Character analysis failed: ' + e.message, 'error');
btn.disabled = false; if (prog) prog.hidden = true;
return;
}
// Map analysis back to speakers (case-insensitive match)
const byName = {};
characters.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
// 2) For each character, design + save a voice
let done = 0;
// rehState.cast[sp] used to be assumed always present since `speakers` came
// from Object.keys(rehState.cast) moments earlier — but the analyze-characters
// await above can take many seconds for a large cast, and if anything
// removes/replaces a cast entry while that request is in flight, the plain
// `rehState.cast[sp].voice` access below threw a TypeError with nothing
// catching it (this whole loop sits outside the try/catch above), silently
// killing the click with no toast and no further requests — exactly the
// "clicked Design all and nothing happens" symptom, confirmed by server
// logs showing analyze-characters succeeding but voice-design never once
// being called. Guard it and keep going instead of crashing the whole run.
// The outer try/finally below is the same idea one level up: ANY unexpected
// exception here used to vanish into the console with the button stuck
// disabled — now it surfaces as a toast and always resets the UI.
try {
const designOnly = speakers.filter(sp => rehState.cast[sp]?.voice !== 'me');
for (const sp of designOnly) {
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
if (!rehState.cast[sp]) { done++; continue; }
const info = byName[sp.toUpperCase().trim()] || {};
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
const hardGender = gender === 'M' ? 'male' : gender === 'F' ? 'female' : 'gender-neutral';
const hardAge = info.age || 'age unspecified';
const hardLanguage = info.language || language || 'the script language';
const desc = [
`HARD CASTING CONSTRAINTS: ${hardGender} voice, apparent age ${hardAge}, spoken in ${hardLanguage}.`,
info.description || `Character named ${sp}; natural expressive audiobook voice.`,
`Make this voice audibly distinct from the other cast members: use a memorable vocal fingerprint, not a generic narrator voice.`,
].join(' ');
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
const voiceId = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90);
rehMarkCastDesigning(sp, 'designing', null, {
gender, language, voiceId, desc,
age: info.age || '',
step: 'Generating voice audio…',
});
setProg(done, designOnly.length, `Designing ${sp}… (${done + 1}/${designOnly.length})`);
try {
// Generate the voice audio
const dr = await fetch('/api/voice-design', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruct: desc, sample_text: stripMarkdown(sampleLine).slice(0, 300), language, gender, dialogue: false }),
});
if (!dr.ok) { const e = await dr.json().catch(()=>({})); throw new Error(e.detail || dr.statusText); }
const dd = await dr.json();
// Save it to the voice library, tagged with script name + Rehearser
const sr = await fetch('/api/save', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: dd.id, voice_id: voiceId, transcript: stripMarkdown(sampleLine).slice(0, 300) }),
});
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
const saved = await sr.json();
// Tag metadata — display name = character, script as a tag, origin 'designed',
// grouped by play title, plus an auto-picked gender/type avatar icon.
const charName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
if (typeof saveMeta === 'function') {
await saveMeta(saved.voice_id, {
gender,
age: info.age || '',
name: charName,
avatar: _pickVoiceAvatar(gender, desc, sp),
origin: 'designed',
group: `Rehearser: ${scriptTitle}`,
note: `Rehearser · ${scriptTitle} · ${charName} — ${desc.slice(0, 180)}`,
transcript: stripMarkdown(sampleLine).slice(0, 300),
tag: scriptTitle,
}).catch(()=>{});
}
// Assign to cast + remember the LLM's research as the style note & character soul
rehState.cast[sp].voice = saved.voice_id;
rehState.cast[sp].instruct = rehState.cast[sp].instruct || desc;
rehState.cast[sp].soul = rehState.cast[sp].soul || [info.age ? `Age: ${info.age}` : '', desc].filter(Boolean).join(' · ');
rehState.cast[sp].voiceData = null;
if (!rehState.voices.includes(saved.voice_id)) rehState.voices.push(saved.voice_id);
rehMarkCastDesigning(sp, 'done');
} catch(e) {
rehMarkCastDesigning(sp, 'err', e.message);
}
done++;
setProg(done, designOnly.length);
}
// Refresh the broader voice library so avatars/pictures resolve
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(()=>{});
if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success');
} catch (e) {
toast('Design all failed: ' + (e?.message || e), 'error');
} finally {
renderCastList();
populateNarratorSelect();
if (prog) prog.hidden = true;
btn.disabled = false;
}
});
// ── Match from library — LLM picks the best EXISTING voice for each character ──
// ── Character research → per-character note ──────────────────────────────────
// Store the LLM's reading of the play as each character's "soul" note (+ gender / style).
function rehWriteCharacterNote(sp, info) {
const c = rehState.cast[sp]; if (!c || !info) return;
if (info.gender && !c.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
const bits = [];
if (info.age) bits.push(`Age: ${info.age}`);
if (info.description) bits.push(info.description);
const note = bits.join(' · ');
if (note && !c.soul) c.soul = note; // fills the Character-soul / LLM-brief note
if (info.description && !c.instruct) c.instruct = info.description;
}
// Ask the LLM to read the script + cast, then annotate every character with a note.
// Returns a name→info map (used by the online/design flows that need gender too).
async function rehResearchCast(speakers) {
const names = speakers.filter(sp => sp !== REH_NARRATOR_KEY);
if (!names.length) return {};
let chars = [];
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: $('reh-script-text')?.value.trim() || linesToScriptText(), names,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English',
}),
});
if (r.ok) chars = (await r.json()).characters || [];
} catch (_) {}
const byName = {};
chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()]));
return byName;
}
$('reh-matchlib-btn')?.addEventListener('click', async () => {
const btn = $('reh-matchlib-btn');
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me');
if (!speakers.length) { toast('No characters to match', 'error'); return; }
let lib = (window._voices || []).filter(v => v.enabled !== false);
if (!lib.length) {
// Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now.
try { lib = (await (typeof window.fetchVoiceLibrary === 'function' ? window.fetchVoiceLibrary() : fetch('/api/voices').then(r => r.json()))).filter(v => v.enabled !== false); } catch (_) {}
}
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
const candidates = lib.map(v => ({
id: v.id, gender: v.gender || '', age: v.age || '', language: v.lang || '',
tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
}));
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);
const byDisplay = {};
speakers.forEach(sp => { byDisplay[nameFor(sp).toUpperCase().trim()] = sp; });
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = ' Matching…';
try {
const r = await fetch('/api/match-characters-voices', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: $('reh-script-text')?.value.trim() || linesToScriptText(),
names: speakers.map(nameFor),
voices: candidates,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const assignments = (await r.json()).assignments || [];
const validIds = new Set(candidates.map(c => c.id));
let n = 0;
assignments.forEach(a => {
const sp = byDisplay[String(a.name || '').toUpperCase().trim()];
if (sp && a.voice_id && validIds.has(a.voice_id)) {
rehState.cast[sp].voice = a.voice_id;
rehState.cast[sp].voiceData = getVoiceData(a.voice_id);
if (!rehState.voices.includes(a.voice_id)) rehState.voices.push(a.voice_id);
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = a.voice_id;
n++;
}
});
// Research the play + cast and drop a note on each character
btn.innerHTML = ' Researching characters…';
await rehResearchCast(speakers);
renderCastList();
populateNarratorSelect();
toast(n ? `Matched ${n} character${n !== 1 ? 's' : ''} & added notes` : 'No good matches — try “Design all voices” instead', n ? 'success' : 'error');
} catch (e) {
toast('Match failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
}
});
// ── Match from online — search fish.audio per character, import & assign ──────
const REH_FISH_LANG = { English: 'en', German: 'de', French: 'fr', Spanish: 'es', Italian: 'it', Portuguese: 'pt', Dutch: 'nl', Auto: '' };
$('reh-matchonline-btn')?.addEventListener('click', async () => {
const btn = $('reh-matchonline-btn');
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me' && sp !== REH_NARRATOR_KEY);
if (!speakers.length) { toast('No characters to match', 'error'); return; }
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
const prog = $('reh-autodesign-progress'), fill = $('reh-autodesign-fill'), label = $('reh-autodesign-label');
const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d / t) * 100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; };
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = ' Analyzing…';
if (prog) prog.hidden = false;
rehDesignCancelled = false;
try {
// 1) Ask the LLM for each character's gender (drives the online search)
let chars = [];
try {
const ar = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '',
language: $('reh-design-lang')?.value || 'English' }),
});
if (ar.ok) chars = (await ar.json()).characters || [];
} catch (_) {}
const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
const G = { M: 'male', F: 'female', N: 'neutral' };
let done = 0, n = 0;
for (let i = 0; i < speakers.length; i++) {
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
const sp = speakers[i];
setProg(done, speakers.length, `Matching ${sp}… (${done + 1}/${speakers.length})`);
const info = byName[sp.toUpperCase().trim()] || {};
rehWriteCharacterNote(sp, info); // drop the LLM's character research into a note
const gender = G[(info.gender || '').toUpperCase().charAt(0)] || '';
// Search fish.audio: by character name first (catches known/celebrity names), then gender+lang
const qs = [`search=${encodeURIComponent(sp)}`, `language=${lang}`, 'page_size=8', 'sort_by=score'];
let items = [];
try { items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {}
let pick = items.find(v => v.sample_audio);
let nameHit = !!pick; // got a result by searching the character's actual name
if (!pick) { // fallback: best by gender+language, offset per character to diversify
const fb = [`language=${lang}`, gender ? `gender=${gender}` : '', 'page_size=12', 'sort_by=score', `page=${(i % 4) + 1}`].filter(Boolean);
try { items = (await fetch('/api/fishaudio/voices?' + fb.join('&')).then(r => r.json())).items || []; } catch (_) {}
pick = items.find(v => v.sample_audio);
}
// Keep the top few playable results so the user can audition & swap them later
let cands = items.filter(v => v.sample_audio).slice(0, 6).map(v => ({
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
}));
// When the name didn't match, the fallback returns the same popular voices for
// everyone — rotate the pick per character so they don't all sound identical,
// and skip voices already taken by earlier characters this run.
if (!nameHit && cands.length > 1) {
const taken = new Set(Object.values(rehState.cast).map(c => (c.online?.candidates?.[c.online.picked]?.sample_audio)).filter(Boolean));
const rotated = [...cands.slice(i % cands.length), ...cands.slice(0, i % cands.length)];
cands = rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0));
}
if (cands.length) {
try {
const vid = await _rehImportFishCandidate(sp, cands[0]);
cands[0].voice_id = vid;
rehState.cast[sp].voice = vid;
rehState.cast[sp].voiceData = getVoiceData(vid);
rehState.cast[sp].online = { candidates: cands, picked: 0 };
n++;
} catch (e) { logErr('match-online import ' + sp, e); }
}
done++; setProg(done, speakers.length);
}
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList();
populateNarratorSelect();
toast(n ? `Imported & matched ${n} online voice${n !== 1 ? 's' : ''}` : 'No online matches found', n ? 'success' : 'error');
} catch (e) {
toast('Online match failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
if (prog) prog.hidden = true;
}
});
// Show per-character design progress card beneath the cast row
function rehMarkCastDesigning(sp, state, msg, info) {
const row = document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);
if (!row) return;
// Inline badge in the cast row header
let badge = row.querySelector('.reh-cast-design-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'reh-cast-design-badge';
row.querySelector('strong')?.after(badge);
}
badge.className = 'reh-cast-design-badge' + (state === 'done' ? ' done' : state === 'err' ? ' err' : '');
badge.textContent = state === 'designing' ? '✨ designing…' : state === 'done' ? '✓ designed' : '✗ failed';
if (msg) badge.title = msg;
row.classList.toggle('reh-cast-designing', state === 'designing');
// Expanded detail panel beneath the instruct row
const wrap = row.closest('div');
let panel = wrap?.querySelector('.reh-cast-design-panel');
if (state === 'designing' && info) {
if (!panel) {
panel = document.createElement('div');
panel.className = 'reh-cast-design-panel';
wrap.appendChild(panel);
}
const genderIcon = info.gender === 'M' ? '♂' : info.gender === 'F' ? '♀' : '⚧';
const genderLabel = info.gender === 'M' ? 'Male' : info.gender === 'F' ? 'Female' : 'Neutral';
panel.innerHTML = `
`;
} else if (state === 'done' && panel) {
// Collapse to a summary line
const descEl = panel.querySelector('.reh-cdp-desc');
const metaEl = panel.querySelector('.reh-cdp-meta');
const stepEl = panel.querySelector('.reh-cdp-step');
if (stepEl) stepEl.remove();
if (descEl) {
// Truncate
const t = descEl.textContent;
if (t.length > 160) descEl.textContent = t.slice(0, 157) + '…';
}
panel.classList.add('done');
} else if (state === 'err') {
if (panel) panel.remove();
}
}
$('reh-start-btn')?.addEventListener('click', () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
rehState.backend = backend;
rehState.narratorVoice = $('reh-narrator-voice')?.value || '';
rehState.lineIndex = 0;
rehState.clips = [];
rehState.playing = false;
rehState.synthCache.clear(); rehDecodedBuffers.clear();
rehState.practiceStart = null;
rehState.practiceEnd = null;
buildScriptPage();
showPhase(3);
highlightCurrentLine();
});
function buildScriptPage() {
const titleEl = $('reh-page-title');
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
renderCastStrip();
rehApplyStageFont();
rehApplyCastCollapsed();
// Fire-and-forget, guarded against re-running for the same script — see
// _lineAudioSyncDots for why this exists (green dots otherwise look reset
// after every reload even when the audio is safely cached on disk).
if (typeof _lineAudioSyncDots === 'function') _lineAudioSyncDots().catch(() => {});
const linesEl = $('reh-script-lines'); if (!linesEl) return;
linesEl.innerHTML = rehState.lines.map((line, i) => {
const isCached = rehState.synthCache.has(i);
const isStale = rehState.staleLines.has(i);
const dotCls = isStale ? 'reh-synth-dot stale' : 'reh-synth-dot';
const dotTitle = isStale ? 'Tone changed — needs re-synthesis' : 'Pre-synthesized';
const synthDot = ``;
const reSynthBtn = ``;
const editBtn = ``;
const note = rehState.lines[i].note || '';
// noteArea includes the edit button so both icons live in the right gutter
const noteArea = `
${editBtn}
`;
// Bulk-edit: hidden lines drop out of the view entirely, unless we're revealing
// them inside bulk mode so they can be selected and restored.
if (line.hidden && !(rehState.bulkMode && rehState.showHidden)) return '';
const _bSel = rehState.bulkSel.has(i);
const bulkCheck = rehState.bulkMode
? ``
: '';
const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
// Same detection audiobookExport() uses to decide chapter/file boundaries
// (audiobook.js) — surfaced here too so a chapter is visible in the script
// itself, not just audible as a pause in the finished export. Runs before
// the normal type switch so a chapter heading (however it was tagged
// during parsing — 'act', 'scene', or plain 'action' text recovered via
// heading OCR) always gets this treatment instead of its usual rendering.
if (typeof audiobookIsChapter === 'function' && audiobookIsChapter(line)) {
const label = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
return `
`;
case 'action': {
// Narrator paragraphs get a play button too now, but deliberately
// NOT wrapped in .reh-block's boxed/indented dialogue treatment
// (avatar circle, name row, highlighted card) — just a small inline
// icon before the text, same weight as the edit button, so plain
// narration keeps reading like plain narration.
const narrPlayBtn = ``;
return `
`
).join('');
listEl.querySelectorAll('.reh-emo-item').forEach(item => {
item.addEventListener('mousedown', e => {
e.preventDefault();
selectEmotion(idx, item.dataset.value, anchorBtn);
closeEmoPicker();
});
});
}
searchEl.addEventListener('input', () => renderList(searchEl.value));
searchEl.addEventListener('keydown', e => {
if (e.key === 'Escape') closeEmoPicker();
if (e.key === 'Enter') {
const val = searchEl.value.trim();
if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }
}
});
applyBtn.addEventListener('mousedown', e => {
e.preventDefault();
const val = searchEl.value.trim(); if (!val) return;
const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val);
if (!exists) {
rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true });
try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {}
}
selectEmotion(idx, val, anchorBtn);
closeEmoPicker();
});
renderList();
searchEl.focus();
setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50);
}
function _closePickerOnOutside(e) {
if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker();
}
function closeEmoPicker() {
if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; }
document.removeEventListener('mousedown', _closePickerOnOutside);
}
function _markSynthDot(idx, state) {
const dot = document.getElementById('reh-syd-' + idx);
if (!dot) return;
dot.className = 'reh-synth-dot'
+ (state === 'stale' ? ' stale' : '')
+ (state === 'synthesizing' ? ' synthesizing' : '');
dot.style.display = state ? '' : 'none';
dot.title = state === 'stale' ? 'Tone changed — needs re-synthesis'
: state === 'synthesizing' ? 'Synthesizing…'
: 'Pre-synthesized';
// Also highlight the block row itself while synthesizing
const block = dot.closest('[data-index]');
if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing');
}
function _showReSynthBtn(idx, show) {
const btn = document.getElementById('reh-rsb-' + idx);
if (btn) btn.hidden = !show;
}
// A voice reassigned in the Library/Studio Voices tab (clPut calls this
// after every character save) used to never reach an already-open
// rehearsal of the same book: rehState.cast[sp].voice is loaded once from
// the saved rehearsal record, and an explicit prior value always wins over
// a fresher one from the shared roster (see the `saved?.voice ?? def.voice`
// load above) — so the Stage/export kept reading, and kept the cached
// audio for, the OLD voice indefinitely. Confirmed live as the cause of an
// audiobook export that finished suspiciously fast right after changing a
// voice: the reassigned character's lines were still "cached" under the
// old voice and never got marked stale. Only acts when a rehearsal for the
// SAME book is actually open right now and the name matches a real speaker.
function _rehSyncCastVoiceFromLibrary(rec) {
if (!window.rehState || !rehState.lines || !rehState.lines.length) return;
if (!rec || !rec.name || !rec.voice) return;
const openBook = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : '';
if (!openBook || String(rec.book || '').trim().toLowerCase() !== openBook.trim().toLowerCase()) return;
const target = String(rec.name).toUpperCase().trim();
const sp = Object.keys(rehState.cast).find(k => String(k).toUpperCase().trim() === target);
if (!sp) return;
const c = rehState.cast[sp];
if (!c || c.voice === rec.voice) return;
c.voice = rec.voice;
c.voiceData = getVoiceData(rec.voice);
rehState.lines.forEach((line, idx) => {
if (line.type === 'dialog' && line.speaker === sp && rehState.synthCache.has(idx)) {
rehState.synthCache.delete(idx);
rehState.staleLines.add(idx);
if (typeof _markSynthDot === 'function') _markSynthDot(idx, 'stale');
}
});
if (typeof _updateStaleBatchBtn === 'function') _updateStaleBatchBtn();
if (typeof renderCastList === 'function') renderCastList();
}
window._rehSyncCastVoiceFromLibrary = _rehSyncCastVoiceFromLibrary;
async function synthOneLine(idx) {
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') return;
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') return;
const instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
_showReSynthBtn(idx, false);
_markSynthDot(idx, 'synthesizing');
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
toast('Re-synthesized line ' + (idx + 1), 'success');
} catch(e) {
_markSynthDot(idx, null);
_showReSynthBtn(idx, true);
toast('Synthesis failed: ' + e.message, 'error');
}
}
function selectEmotion(idx, value, anchorBtn) {
rehState.lines[idx].emotion = value;
if (rehState.synthCache.has(idx)) {
rehState.synthCache.delete(idx);
rehState.staleLines.add(idx);
_markSynthDot(idx, 'stale');
_updateStaleBatchBtn();
}
_showReSynthBtn(idx, true);
// Update button
const info = getEmotionInfo(value);
anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : '');
anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}${escHtml(info.label)} `;
// Show warning if the active backend doesn't reliably support style
if (value) _checkToneStyleSupport();
}
// One row of the tone/identity comparison table.
function _rehToneCmpRow(backend, isCurrent) {
const check = (ok) => ok
? ''
: '';
const action = isCurrent
? 'current'
: ``;
return `
${escHtml(backend.label)}
${check(backend.style_aware)}
${check(backend.uses_wav)}
${action}
`;
}
function _checkToneStyleSupport() {
const warn = $('reh-tone-warn'); if (!warn) return;
const txtEl = $('reh-tone-warn-txt');
const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null;
if (!b) { warn.hidden = true; return; }
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion);
// Two opposite engine trade-offs, surfaced so the user can choose knowingly:
// • clone backends → consistent character identity, but weak tone control
// • design backends → strong tone, but a fresh persona each call (voices drift)
// Originally a single run-on sentence with a button awkwardly wedged into
// the middle of it (confirmed live: read badly, wrapped worse). A table
// says the same two facts as two columns instead of two clauses.
if (_rehBackendIsFish()) {
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} keeps each character’s voice consistent and applies tone. Per-line tones are sent as inline [tags] (e.g. [whisper], [excited], [laughing]). You can also type a custom tone like [professional broadcast tone] — S2 supports free-form descriptions. Fish-Speech S2 ↗`;
warn.hidden = false;
} else if (!b.style_aware && hasTone) {
// Prefer a backend that fixes BOTH weaknesses at once (tone-aware AND
// clones from a reference WAV, e.g. Fish-Speech) over one that only
// fixes this one (tone-aware but re-rolls the voice each line, e.g.
// Voice Design) — confirmed live: with both available, `.find()` was
// silently suggesting whichever happened to come first in the backend
// list, which was never Fish-Speech despite it being the strictly
// better option whenever it's actually running.
const styleAware = all.find(x => x.style_aware && x.uses_wav) || all.find(x => x.style_aware);
if (txtEl) {
txtEl.innerHTML = styleAware
? `
`
: `${escHtml(b.label)} keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.`;
}
warn.hidden = false;
} else if (b.style_aware && !b.uses_wav) {
// Same preference as above, mirrored: a wav-cloning backend that's ALSO
// tone-aware (Fish-Speech) beats one that drops tone control entirely
// (Voice Clone) as the suggested alternative.
const wavBackend = all.find(x => x.uses_wav && x.style_aware) || all.find(x => x.uses_wav);
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? '
Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.
${qwenHint}`
: `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}`;
}
warn.hidden = false;
} else {
warn.hidden = true;
}
}
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
// The suggestion buttons above get rebuilt (via innerHTML) every time
// _checkToneStyleSupport() re-runs, so a delegated listener on the
// container — bound once — is the only reliable way to catch clicks on them.
$('reh-tone-warn')?.addEventListener('click', (e) => {
const btn = e.target.closest('.reh-tone-switch-btn');
if (!btn) return;
const id = btn.dataset.backendId;
const sel = $('reh-backend-select');
if (sel && [...sel.options].some(o => o.value === id)) sel.value = id;
rehState.backend = id;
_checkToneStyleSupport();
const b = (typeof backendById === 'function') ? backendById(id) : null;
toast('Switched to ' + (b ? b.label : id), 'success');
});
// ── Transport controls ──────────────────────────────────────────────────────
$('reh-tb-play')?.addEventListener('click', () => {
if (rehState.playing) pausePlay(); else startPlay();
});
$('reh-tb-stop')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = rehState.practiceStart ?? 0;
highlightCurrentLine();
hideRecOverlay();
});
$('reh-tb-prev')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = Math.max(0, rehState.lineIndex - 1);
highlightCurrentLine();
hideRecOverlay();
});
$('reh-tb-next')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1);
highlightCurrentLine();
hideRecOverlay();
});
$('reh-tb-repeat')?.addEventListener('click', () => {
rehState.repeat = !rehState.repeat;
$('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat);
});
$('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; });
$('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal);
$('reh-fountain-btn')?.addEventListener('click', exportFountain);
$('reh-fountain-export-p4')?.addEventListener('click', exportFountain);
$('reh-fdx-export-btn')?.addEventListener('click', exportFDX);
$('reh-osf-export-btn')?.addEventListener('click', exportOSF);
$('reh-exit-btn')?.addEventListener('click', () => {
stopPlay(); stopRehMic();
if (rehState.clips.length) { renderSummary(); showPhase(4); }
else showPhase(2);
});
// ── Page title inline editing ───────────────────────────────────────────────
$('reh-page-title')?.addEventListener('dblclick', function () {
this.contentEditable = 'true';
this.style.outline = '2px solid var(--accent)';
this.style.borderRadius = '3px';
this.focus();
const range = document.createRange();
range.selectNodeContents(this);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
});
$('reh-page-title')?.addEventListener('blur', function () {
if (this.contentEditable === 'true') {
this.contentEditable = 'false';
this.style.outline = '';
const v = this.textContent.trim() || 'Script';
this.textContent = v;
if ($('reh-script-title')) $('reh-script-title').value = v;
}
});
$('reh-page-title')?.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); }
});
// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────
let _rehPlayCtx = null;
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
let rehCurrentSource = null; // active AudioBufferSourceNode
let rehWordHighlightRaf = null;
function rehPlayCtx() {
if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
return _rehPlayCtx;
}
// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
// blob, so keep only a sliding window around the playhead. Without this, a long
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
const REH_DECODE_WINDOW = 8;
function _rehEvictDecoded(keepIdx) {
if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
for (const k of rehDecodedBuffers.keys()) {
if (k < lo || k > hi) rehDecodedBuffers.delete(k);
}
}
async function preDecodeBlob(lineIdx, blob) {
if (rehDecodedBuffers.has(lineIdx)) return;
try {
const ab = await blob.arrayBuffer();
const buf = await rehPlayCtx().decodeAudioData(ab);
rehDecodedBuffers.set(lineIdx, buf);
_rehEvictDecoded(lineIdx);
} catch(_) {}
}
function computeWordTimings(text, durationSec) {
const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
if (words.length < 2) return [];
// Distribute proportionally by character length (longer words = more time)
const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
let t = 0;
return words.map(w => {
const start = t;
t += (w.length / totalChars) * durationSec;
return { word: w, start, end: t };
});
}
function stopAudioSource() {
if (rehCurrentSource) {
try { rehCurrentSource.stop(0); } catch(_) {}
rehCurrentSource = null;
}
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
}
// Instant playback from pre-decoded buffer + word-level highlight
async function playPreDecoded(lineIdx, blob, text) {
if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
_rehEvictDecoded(lineIdx); // keep the window centred on the playhead
const buf = rehDecodedBuffers.get(lineIdx);
if (!buf) { await playAudioBlobFallback(blob); return; }
const timings = computeWordTimings(text, buf.duration);
const dialogEl = document.getElementById('reh-diag-' + lineIdx);
if (dialogEl && timings.length >= 2) {
dialogEl.innerHTML = timings.map((t, i) =>
`${escHtml(t.word)}`
).join(' ');
}
return new Promise(resolve => {
stopAudioSource();
const ctx = rehPlayCtx();
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
rehCurrentSource = src;
const t0 = ctx.currentTime;
src.onended = () => {
rehCurrentSource = null;
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text);
resolve();
};
src.start(0);
// Pre-decode the line AFTER this one while this is playing
const nextI = findNextCachedLine(lineIdx + 1);
if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI));
if (dialogEl && timings.length >= 2) {
const tick = () => {
if (rehCurrentSource !== src) return;
const elapsed = ctx.currentTime - t0;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) {
if (elapsed >= timings[i].start) { active = i; break; }
}
dialogEl.querySelectorAll('.reh-word').forEach((span, i) => {
span.classList.toggle('reh-word-active', i === active);
});
rehWordHighlightRaf = requestAnimationFrame(tick);
};
rehWordHighlightRaf = requestAnimationFrame(tick);
}
});
}
function findNextCachedLine(fromIdx) {
for (let i = fromIdx; i < rehState.lines.length; i++) {
if (rehState.synthCache.has(i)) return i;
}
return -1;
}
// Fallback when AudioContext decode fails
async function playAudioBlobFallback(blob) {
const url = URL.createObjectURL(blob);
const audio = $('reh-tts-audio'); if (!audio) return;
audio.src = url; audio.style.display = '';
await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); });
await audio.play().catch(() => {});
await waitForAudioEnd(audio);
}
// ── Auto-play sequence ──────────────────────────────────────────────────────
function startPlay() {
rehPlayCtx(); // warm up AudioContext on user gesture
_ensureNarrator(); // make sure rehState.narratorVoice reflects the narrator cast row
rehState.playing = true;
updatePlayBtn();
playNextLine();
}
// Resets the narrator play button's icon back to "play" — separate from
// highlightCurrentLine() (which also moves the active-line highlight and
// scrolls) since pausing/stopping shouldn't jump the page around, just
// stop claiming a line is still playing.
function _rehResetActionPlayIcons() {
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-play';
btn.title = 'Play or pause this line';
});
}
function pausePlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio && !audio.paused) audio.pause();
hideStatusBar();
_rehResetActionPlayIcons();
}
function stopPlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio) { audio.pause(); audio.src = ''; }
hideStatusBar();
_rehResetActionPlayIcons();
}
function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }
let _rehPrefetchRunning = false;
async function _rehPrefetchLines(fromIndex) {
if (_rehPrefetchRunning) return;
_rehPrefetchRunning = true;
try {
let prefetched = 0;
for (let i = fromIndex; i < rehState.lines.length && prefetched < 2; i++) {
if (!rehState.playing) break;
const l = rehState.lines[i];
if (!l || l.ignored || l.hidden || l.type === 'pagebreak') continue;
const isDialog = l.type === 'dialog';
const cast = isDialog ? (rehState.cast[l.speaker] || { voice: '' }) : null;
if (isDialog && (!cast.voice || cast.voice === 'me')) continue;
if (!isDialog && (!rehState.narratorVoice || !(l.text || '').trim() || rehState.skipDescriptions)) continue;
if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) {
prefetched++;
continue;
}
const voice = isDialog ? cast.voice : rehState.narratorVoice;
const cleanTxt = stripMarkdown(l.text);
const instruct = isDialog ? _buildInstruct((cast.instruct || '').trim(), l.emotion, voice) : '';
const text = isDialog ? _rehInlineTone(cleanTxt, l.emotion) : cleanTxt;
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(text, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
try {
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
} catch(e) {
console.warn('Prefetch failed for line', i, e);
continue; // skip counting as prefetched if it failed
}
}
rehState.synthCache.set(i, blob);
rehState.staleLines.delete(i);
_markSynthDot(i, 'ok');
prefetched++;
}
} finally {
_rehPrefetchRunning = false;
}
}
async function playNextLine() {
if (!rehState.playing) return;
// Check practice end boundary
if (rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) {
rehState.lineIndex = rehState.practiceStart ?? 0;
startPlay();
} else {
rehState.lineIndex = rehState.practiceStart ?? 0;
highlightCurrentLine();
}
return;
}
// Always skip pagebreak markers
if (rehState.lines[rehState.lineIndex]?.type === 'pagebreak') {
rehState.lineIndex++;
return playNextLine();
}
// Skip lines the user marked ignore / hide during bulk edit
const _cur = rehState.lines[rehState.lineIndex];
if (_cur && (_cur.ignored || _cur.hidden)) {
rehState.lineIndex++;
return playNextLine();
}
// Skip non-dialog lines whenever skip mode is on, full stop — regardless of
// whether a narrator voice happens to be assigned. This used to also require
// !rehState.narratorVoice, on the theory that skipping was only meaningful
// when there was nothing to skip TO — but the individual-line branch below
// never re-checked skipDescriptions at all, so once ANY narrator voice was
// configured (the common case once a book is actually set up), narration
// played regardless of this toggle. Confirmed live: Studio's "Rehearse ⇄
// Audiobook" toggle (which just flips this same flag) had zero observable
// effect once a narrator voice existed — exactly the reported "doesn't
// read the narrator" / toggle-does-nothing behavior, just inverted from
// what it looked like (narration was stuck ON, not stuck OFF).
if (rehState.skipDescriptions) {
while (
rehState.lineIndex < rehState.lines.length &&
rehState.lines[rehState.lineIndex].type !== 'dialog' &&
rehState.lines[rehState.lineIndex].type !== 'pagebreak' &&
!(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd)
) { rehState.lineIndex++; }
}
if (rehState.lineIndex >= rehState.lines.length) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
toast('Script finished', 'success');
return;
}
// Captured up front so a later `await` (waiting on a fresh TTS synthesis)
// can tell whether the user has since clicked a DIFFERENT line's play
// button — `rehState.lineIndex` itself gets overwritten by that click, so
// re-reading it after the await always looks "current" even when it
// isn't. Checking only `rehState.playing` (a bare boolean, flipped false
// then true again by the new click's own stopPlay()/startPlay() pair
// before this await ever resumes) let this stale continuation slip
// through and actually play — confirmed live as the reported bug: click
// a line while a previous, not-yet-synthesized line is still loading, and
// once that first synthesis finally finishes it cuts in and starts
// playing anyway, on top of (or right over) the line the user actually
// asked for, with no way to stop just that stray one.
const myLineIndex = rehState.lineIndex;
const stillCurrent = () => rehState.playing && rehState.lineIndex === myLineIndex;
const line = rehState.lines[rehState.lineIndex];
highlightCurrentLine();
_rehPrefetchLines(myLineIndex + 1).catch(e => console.warn('Prefetch error:', e));
// Non-dialog lines
if (line.type !== 'dialog') {
// Any line with text can be narrated — direction/transition/scene/act/action all included
const hasText = !!(line.text || '').trim();
if (rehState.narratorVoice && hasText && !rehState.skipDescriptions) {
showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : ''));
const cached = rehState.synthCache.get(rehState.lineIndex);
if (cached) {
await playPreDecoded(rehState.lineIndex, cached, line.text);
} else {
try {
const book = _lineAudioBookName();
const cleanNarr = stripMarkdown(line.text);
const cacheKey = await _lineAudioCacheKey(cleanNarr, rehState.narratorVoice, '');
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', _ttsBackendForVoice(rehState.narratorVoice, rehState.backend));
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
_markSynthDot(myLineIndex, 'ok');
await playPreDecoded(myLineIndex, blob, line.text);
} catch(_) { await new Promise(r => setTimeout(r, 200)); }
}
} else if (!rehState.skipDescriptions) {
await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250));
}
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
return;
}
// Dialog line
const cast = rehState.cast[line.speaker] || { voice: '' };
if (cast.voice === 'me') {
rehState.playing = false; updatePlayBtn();
showRecOverlay(line);
return;
}
if (!cast.voice) {
showStatusBar(line.speaker + ' has no voice — skipping…');
await new Promise(r => setTimeout(r, 350));
if (!stillCurrent()) return;
rehState.lineIndex++; playNextLine(); return;
}
const profile = (cast.instruct || '').trim();
const instruct = _buildInstruct(profile, line.emotion, cast.voice);
const cleanTxt = stripMarkdown(line.text);
const cached = rehState.synthCache.get(rehState.lineIndex);
if (cached) {
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(rehState.lineIndex, cached, line.text); // ← instant: pre-decoded PCM
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob: cached });
} else {
showStatusBar('Synthesizing…');
try {
const toneText = _rehInlineTone(cleanTxt, line.emotion);
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(toneText, cast.voice, instruct);
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, _ttsBackendForVoice(cast.voice, rehState.backend));
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(myLineIndex, blob, line.text); // also decodes + pre-fetches next
rehState.clips.push({ lineIndex: myLineIndex, speaker: line.speaker, type: 'tts', blob });
} catch(e) {
showStatusBar('TTS failed: ' + e.message);
await new Promise(r => setTimeout(r, 1000));
}
}
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
}
function waitForAudioEnd(audio) {
return new Promise(resolve => {
if (!audio || audio.paused || audio.ended) { resolve(); return; }
audio.addEventListener('ended', resolve, { once: true });
audio.addEventListener('pause', resolve, { once: true });
audio.addEventListener('error', resolve, { once: true });
});
}
function showStatusBar(msg) {
const bar = $('reh-tts-status-bar'); if (!bar) return;
bar.hidden = false;
const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg;
}
function highlightCurrentLine() {
const i = rehState.lineIndex, total = rehState.lines.length;
const prog = $('reh-tb-progress');
if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%';
const lbl = $('reh-tb-label');
if (lbl) lbl.textContent = `${i + 1} / ${total}`;
document.querySelectorAll('[data-index]').forEach(el => {
el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i);
});
// Dialogue's own play button shows play/pause via a pure-CSS badge
// overlay on the avatar (.reh-line-active .reh-line-play-avatar::after),
// but the plain narrator play button (.reh-action-play-btn, no avatar to
// overlay onto) never got the same treatment — its icon just stayed a
// static play triangle even while that exact line was the one actively
// playing, with nothing to show that clicking it again would stop it.
// Confirmed live as the reported "no way to stop it" complaint.
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (!icon) return;
const isActive = rehState.playing && parseInt(btn.dataset.index) === i;
icon.className = isActive ? 'mdi mdi-stop' : 'mdi mdi-play';
btn.title = isActive ? 'Stop' : 'Play or pause this line';
});
const active = document.querySelector(`[data-index="${i}"]`);
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
updatePlayBtn();
}
function updatePlayBtn() {
const btn = $('reh-tb-play'); if (!btn) return;
btn.innerHTML = rehState.playing ? '' : '';
btn.title = rehState.playing ? 'Pause' : 'Play all';
}
// ── Persistent per-paragraph audio cache ────────────────────────────────────
//
// rehState.synthCache only ever lived in the browser tab's memory — closing
// the tab, reloading, or a crash threw away everything "Synth all" had
// already paid GPU time for, forcing a full re-synthesis (and the pause
// between paragraphs that comes with it) the next time regardless. This
// persists each line's audio to disk, keyed by a hash of exactly what
// determines its sound (text + voice + tone/instruct) rather than its
// position in the script. An untouched paragraph's key never changes, so it
// keeps reusing the same cached file indefinitely; an edited paragraph's
// key changes the instant the text (or voice/tone) does, so it simply never
// matches a cached file again and gets synthesized fresh next time — no
// separate "delete the old file" step needed, the old file just becomes
// unreachable dead weight rather than ever being served again.
async function _lineAudioCacheKey(text, voice, instruct) {
const enc = new TextEncoder().encode(`${text} ${voice} ${instruct || ''}`);
const digest = await crypto.subtle.digest('SHA-256', enc);
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
}
function _lineAudioBookName() {
return $('reh-script-title')?.value.trim() || 'Untitled';
}
async function _lineAudioCacheGet(book, key) {
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`);
if (!r.ok) return null;
return await r.blob();
} catch (_) { return null; }
}
function _lineAudioCachePut(book, key, blob) {
// Fire-and-forget — a failed save just means this line resynthesizes next
// time too, no worse than before this cache existed at all.
fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`, { method: 'POST', body: blob }).catch(() => {});
}
// Every line's "pre-synthesized" green dot only ever reflected
// rehState.synthCache — this browser tab's own memory, empty on every fresh
// page load — so a script that was fully "Synth all"-ed and safely
// persisted to disk in an EARLIER session still looked completely
// unsynthesized after a reload, with no way to tell the cached audio was
// actually right there. This asks the server, in one batch request, which
// of the current script's lines already have a matching file, and lights
// up their dots — without downloading any actual audio (that still only
// happens lazily, right when a line is about to play).
let _lineAudioSyncedFor = null; // unused now, kept for compat
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
_ensureNarrator();
const idxToKey = new Map();
for (let i = 0; i < rehState.lines.length; i++) {
const line = rehState.lines[i];
if (line.ignored || line.hidden) continue;
if (rehState.synthCache.has(i)) continue; // already known-good this session
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
idxToKey.set(i, await _lineAudioCacheKey(text, voice, instruct));
}
if (!idxToKey.size) return;
const keyToIdx = new Map([...idxToKey].map(([i, k]) => [k, i]));
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/check`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: [...idxToKey.values()] }),
});
if (!r.ok) return;
const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }
}
// ── Synthesize All ─────────────────────────────────────────────────────────
async function synthAll() {
if (rehState.synthRunning) return;
if (!rehState.backend) { toast('Select a TTS backend first', 'error'); return; }
_ensureNarrator(); // sync narrator voice from its cast row before synthesizing
const ttsLines = rehState.lines.map((l, i) => ({ line:l, idx:i })).filter(({ line }) => {
if (line.ignored || line.hidden) return false; // bulk-edit: never synth ignored/hidden lines
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
return c && c.voice && c.voice !== 'me';
}
// All non-dialog lines with text are narrated when a narrator voice is set
return !!(rehState.narratorVoice && (line.text || '').trim());
});
if (!ttsLines.length) { toast('No TTS lines to synthesize', 'error'); return; }
rehState.synthRunning = true; rehState.synthCancelled = false;
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
const prog = (d, t) => {
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
if (label) label.textContent = `${d} / ${t} synthesized`;
};
prog(0, ttsLines.length);
let done = 0;
const book = _lineAudioBookName();
// Wrapped in try/finally so a thrown error (e.g. the speaker's cast entry
// got deleted mid-run — see the `!c` guard below) can never leave
// synthRunning stuck true forever, which would silently no-op every future
// "Synthesize All" / "Re-synthesize stale" click with no error shown, same
// failure shape as the character-sheets generation-lock bug fixed earlier.
try {
for (const { line, idx } of ttsLines) {
if (rehState.synthCancelled) break;
let voice, instruct;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; }
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
} else {
voice = rehState.narratorVoice; instruct = '';
}
_markSynthDot(idx, 'synthesizing');
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback
} catch(_) { _markSynthDot(idx, null); }
prog(++done, ttsLines.length);
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
}
if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success');
}
function _updateStaleBatchBtn() {
const btn = $('reh-tb-resynth-stale');
if (btn) btn.hidden = rehState.staleLines.size === 0;
}
$('reh-tb-synth-all')?.addEventListener('click', () => synthAll());
$('reh-tb-resynth-stale')?.addEventListener('click', async () => {
if (rehState.synthRunning) return;
const stale = [...rehState.staleLines];
if (!stale.length) return;
rehState.synthRunning = true; rehState.synthCancelled = false;
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
let done = 0;
const book = _lineAudioBookName();
try {
for (const idx of stale) {
if (rehState.synthCancelled) break;
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
_markSynthDot(idx, 'synthesizing');
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
} catch(_) { _markSynthDot(idx, 'stale'); }
if (fill) fill.style.width = ((++done / stale.length) * 100) + '%';
if (label) label.textContent = `${done} / ${stale.length} synthesized`;
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
_updateStaleBatchBtn();
}
if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success');
});
$('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; });
// Editing a paragraph doesn't delete its OLD cached audio file — the write
// path only ever knows the NEW content's hash, not whatever the line used
// to hash to before the edit, so the stale file just sits there unreferenced
// forever. This computes every key the CURRENT script would actually use
// and asks the server to delete anything else on disk for this book.
$('reh-tb-clean-cache')?.addEventListener('click', async () => {
const btn = $('reh-tb-clean-cache');
if (btn) { btn.disabled = true; btn.innerHTML = ' Scanning…'; }
try {
_ensureNarrator();
const keep = [];
for (const line of rehState.lines) {
if (line.ignored || line.hidden) continue;
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
keep.push(await _lineAudioCacheKey(text, voice, instruct));
}
const book = _lineAudioBookName();
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/prune`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
toast(d.deleted ? `Cleaned up ${d.deleted} unused cached audio file${d.deleted !== 1 ? 's' : ''}` : 'Nothing to clean up — every cached file is still in use', 'success');
} catch (e) {
toast('Cache cleanup failed: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = ' Clean cache'; }
}
});
// ── Recording overlay ───────────────────────────────────────────────────────
function showRecOverlay(line) {
const overlay = $('reh-rec-overlay'); if (!overlay) return;
overlay.hidden = false;
const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' };
if (cue) cue.innerHTML = `${escHtml(line.speaker)} — your line:
${escHtml(stripMarkdown(line.text))}
`;
if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; }
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
rehState.lastRecBlob = null;
}
function hideRecOverlay() {
const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true;
stopRehMic();
}
// ── Mic recording ───────────────────────────────────────────────────────────
function rehRenderMeter(level=0, db=-Infinity, clipped=false) {
const meter = $('reh-mic-meter'); if (!meter) return;
if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);}
const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length);
[...meter.children].forEach((bar,i)=>{
bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px';
if(i-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');}
});
const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB';
}
function rehStartMeter() {
if (!rehState.recAnalyser) return;
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10;
rehState.recWaveRing = new Float32Array(RING);
const tick=()=>{
rehState.recAnalyser.getFloatTimeDomainData(data);
let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));}
const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity;
rehRenderMeter((db+60)/60,db,peak>0.98);
if(canvas&&rehState.recWaveRing){
const ring=rehState.recWaveRing; ring.copyWithin(0,ADD);
for(let i=0;i0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
const mid=h/2;
for(let i=0;i{try{if(n)n.disconnect();}catch(_){}});
if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop());
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop());
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{});
Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null});
rehRenderMeter();
const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height);
}
$('reh-rec-start')?.addEventListener('click', async () => {
try {
await startRehMic();
rehState.recChunks=[]; rehState.recSecs=0;
if($('reh-rec-time'))$('reh-rec-time').textContent='0:00';
if($('reh-rec-start'))$('reh-rec-start').disabled=true;
if($('reh-rec-stop'))$('reh-rec-stop').disabled=false;
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true;
rehState.recTimer=setInterval(()=>{
rehState.recSecs++;
if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0');
},1000);
rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000});
rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);};
rehState.mediaRec.onstop=()=>{
clearInterval(rehState.recTimer);
if($('reh-rec-start'))$('reh-rec-start').disabled=false;
if($('reh-rec-stop'))$('reh-rec-stop').disabled=true;
const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'});
const url=URL.createObjectURL(blob);
const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';}
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false;
rehState.lastRecBlob=blob;
};
rehState.mediaRec.start(100);
} catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');}
});
$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); });
$('reh-rec-keep')?.addEventListener('click', () => {
if (rehState.lastRecBlob)
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob});
stopRehMic(); hideRecOverlay();
rehState.lineIndex++;
startPlay();
});
$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); });
$('reh-skip-line')?.addEventListener('click', () => {
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'});
stopRehMic(); hideRecOverlay();
rehState.lineIndex++;
startPlay();
});
// ── Phase 4: Summary ────────────────────────────────────────────────────────
function renderSummary() {
const list = $('reh-summary-list'); if (!list) return;
if (!rehState.clips.length) { list.innerHTML='