`;
}).join('');
// Click book → open
list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => {
if (e.target.closest('.reh-book-act')) return;
const id = parseInt(book.dataset.id);
// rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no
// longer exist — rehearsals are server-backed now (rehDbGetById).
const rec = await rehDbGetById(id);
if (rec) loadRecord(rec);
else toast('Rehearsal not found', 'error');
}));
list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
const rec = await rehDbGetById(id);
if (!rec) { toast('Rehearsal not found', 'error'); return; }
rec.version = 1;
rec.clips = await clipsToJson(rec.clips || []);
const json = JSON.stringify(rec, null, 2);
const a = document.createElement('a');
const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
a.download = t + '.reh';
a.click();
}));
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const bookEl = btn.closest('.reh-book');
if (bookEl.querySelector('.reh-book-del-confirm')) return;
const confirmOverlay = document.createElement('div');
confirmOverlay.className = 'reh-book-del-confirm';
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
confirmOverlay.innerHTML = `
Delete Rehearsal?
This cannot be undone.
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
await rehDbDelete(parseInt(btn.dataset.id));
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
renderLibraryList();
});
bookEl.appendChild(confirmOverlay);
}));
}
$('reh-lib-view-toggle')?.addEventListener('click', () => {
rehLibView = rehLibView === 'shelf' ? 'list' : 'shelf';
localStorage.setItem('reh-lib-view', rehLibView);
renderLibraryList();
});
// ── Helpers ────────────────────────────────────────────────────────────────
function getVoiceData(voiceId) {
return (window._voices || []).find(v => v.id === voiceId) || null;
}
function _rehVoiceVisibleId(voiceId, include = '') {
if (!voiceId) return false;
if (include && voiceId === include) return true;
const v = getVoiceData(voiceId);
return !v || v.enabled !== false;
}
function voiceAvatarHtml(voiceId, color, size = 32) {
const v = getVoiceData(voiceId);
const s = size + 'px';
const radius = Math.round(size / 2);
if (v?.has_picture) {
return ``;
}
const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
if (ic) {
return ``;
}
const initial = (voiceId || '?')[0].toUpperCase();
return `${initial}`;
}
// Stage line rows used a generic voice icon/initial for the play-avatar
// button — a plain "?" for any speaker whose assigned voice has no picture
// of its own, even when the CHARACTER already has a real portrait in the
// Library (same one shown everywhere else: Cast Audiobook, Assign Voices,
// the Cast sidebar strip right next to this same line). Prefer that.
function _rehCharAvatarHtml(speaker, voiceId, color, size = 32) {
const rec = (_rehLibCharsCache || []).find(r => String(r.name || '').trim().toLowerCase() === String(speaker || '').trim().toLowerCase());
if (rec && rec.image) {
const s = size + 'px'; const radius = Math.round(size / 2);
// Reference the image by URL, never inline the raw data: URL here — this
// renders once PER DIALOGUE LINE, and a character can speak hundreds of
// lines; embedding a multi-KB/MB base64 blob that many times ballooned a
// real 1968-line script's HTML to hundreds of megabytes and silently
// failed to render at all (confirmed live). The browser fetches/caches
// the URL once regardless of how many lines reference it.
return ``;
}
return voiceAvatarHtml(voiceId, color, size);
}
// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──
// ── Phase navigation ───────────────────────────────────────────────────────
function showPhase(n) {
// Hide the import/export panel when switching to a numbered phase
const impex = $('reh-impex-panel'); if (impex) impex.hidden = true;
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
syncPhaseTabs(n);
if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n);
}
window.showRehImpEx = function () {
stopPlay(); stopRehMic();
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; }
const panel = $('reh-impex-panel'); if (panel) panel.hidden = false;
// Show export note based on state
const note = $('reh-impex-export-note');
if (note) note.textContent = rehState.lines.length
? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines`
: 'No active session — load a script first to export.';
};
// Keep the sub-tab bar in sync: highlight current, enable reachable phases
function syncPhaseTabs(n) {
const hasScript = rehState.lines.length > 0;
const hasClips = rehState.clips.length > 0;
document.querySelectorAll('.reh-subtab').forEach(tab => {
const p = parseInt(tab.dataset.phase);
tab.classList.toggle('active', p === n);
// Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it)
let enabled = p === 1
|| (p === 2 && hasScript)
|| (p === 3 && hasScript)
|| (p === 4 && (hasClips || n === 4));
tab.disabled = !enabled;
});
}
document.querySelectorAll('.reh-subtab').forEach(tab => {
tab.addEventListener('click', () => {
if (tab.disabled) return;
const p = parseInt(tab.dataset.phase);
// Leaving the stage: stop playback/mic so audio doesn't keep running
if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); }
if (p === 1) renderLibraryList();
if (p === 3) {
// (Re)build the stage if we have a cast/script
if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; }
}
if (p === 4) { renderSummary(); }
showPhase(p);
});
});
// ── Phase 1 ────────────────────────────────────────────────────────────────
$('reh-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; if (!f) return;
const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
const r = new FileReader();
r.onload = async e => {
$('reh-script-text').value = e.target.result;
await rehAutoSaveImport(guess);
toast('Script loaded & saved to your library — click Parse & cast', 'success');
};
r.readAsText(f); this.value = '';
});
$('reh-pdf-input')?.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
this.value = '';
try {
const text = await importPDFScript(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim();
await rehAutoSaveImport(guess);
toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success');
} catch(e) { toast('PDF import failed: ' + e.message, 'error'); }
});
$('reh-fdx-input')?.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
this.value = '';
try {
const text = await importFDX(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
if (!$('reh-script-title')?.value.trim()) {
const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim();
if ($('reh-script-title')) $('reh-script-title').value = guess;
}
toast('FDX imported — click Parse & cast', 'success');
} catch(e) { toast('FDX import failed: ' + e.message, 'error'); }
});
// ── Unified import router (used by file pickers + drag & drop) ──────────────
async function rehImportAnyFile(f) {
if (!f) return;
const name = f.name.toLowerCase();
const setTitle = strip => {
if (!$('reh-script-title')?.value.trim()) {
const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim();
if ($('reh-script-title')) $('reh-script-title').value = guess;
}
};
try {
if (name.endsWith('.reh') || name.endsWith('.json')) {
await importFromFile(f); // full session → loads & jumps to cast
return;
}
if (name.endsWith('.pdf')) {
toast('Reading PDF…', 'success');
const text = await importPDFScript(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.pdf$/i);
await rehAutoSaveImport();
toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success');
return;
}
if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) {
const text = await importFDX(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.(fdx|osf|xml)$/i);
await rehAutoSaveImport();
toast('Script imported & saved to your library — click Parse & cast', 'success');
return;
}
// .txt / .md / .fountain / anything else → plain text
const text = await f.text();
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.(txt|md|fountain)$/i);
await rehAutoSaveImport();
toast('Script loaded & saved to your library — click Parse & cast', 'success');
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
}
// Drag & drop over the dropzone AND the whole Phase-1 panel
(function setupRehDropzone() {
const dz = $('reh-dropzone');
const phase = $('reh-phase-1');
if (!dz || !phase) return;
let depth = 0;
const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
phase.addEventListener('dragenter', e => {
if (!isFileDrag(e)) return;
e.preventDefault(); depth++; dz.classList.add('dragover');
dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); });
phase.addEventListener('dragleave', e => {
if (!isFileDrag(e)) return;
depth = Math.max(0, depth - 1);
if (depth === 0) dz.classList.remove('dragover');
});
phase.addEventListener('drop', async e => {
if (!isFileDrag(e)) return;
e.preventDefault(); depth = 0; dz.classList.remove('dragover');
const f = e.dataTransfer.files?.[0];
await rehImportAnyFile(f);
});
})();
$('reh-parse-btn')?.addEventListener('click', () => {
const text = $('reh-script-text')?.value.trim();
if (!text) { toast('Paste or upload a script first', 'error'); return; }
rehState.lines = parseScript(text);
if (!rehState.lines.filter(l => l.type === 'dialog').length) {
toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return;
}
// Preserve cast for returning characters
const detected = detectCharacters(rehState.lines);
Object.entries(detected).forEach(([sp]) => {
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
});
rehState.cast = detected;
// Keep the imported/loaded library entry on the first parse so casting updates it
// instead of orphaning it; otherwise start a fresh entry.
if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false;
else rehState.savedId = null;
rehState.clips = [];
rehState.lineIndex = 0;
rehState.synthCache.clear(); rehDecodedBuffers.clear();
renderCastList();
showPhase(2);
refreshRehBackends();
});
// ── Phase 2 ────────────────────────────────────────────────────────────────
function castAvatarHtml(sp) {
const c = rehState.cast[sp]; if (!c) return '';
if (c.voice && c.voice !== 'me') {
const vd = getVoiceData(c.voice);
if (vd?.has_picture) return ``;
const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
if (ic) return ``;
}
const initial = sp[0].toUpperCase();
return `${initial}`;
}
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 (_) { logErr('reh-character-analysis', _); }
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)];
rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0));
cands = rotated;
}
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 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.