tts-voice-creator-clone-and.../static/js/studio.js
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

421 lines
22 KiB
JavaScript

// ── Studio ────────────────────────────────────────────────────────────────
// A unified 4-phase view (Source → Characters → Voices → Perform & Export)
// over the same PDF-to-audiobook pipeline that Read Aloud (s-reader) and
// Script Rehearser (s-rehearser) already implement. Studio is a thin shell:
// it borrows the DOM subtrees those sections already render into (and that
// reader.js/audiobook.js/rehearser.js/character-sheets.js/library-characters.js
// already attach their listeners to) via runtime reparenting, rather than
// duplicating any business logic. See the Studio build plan for the full
// rationale — in short: every section's HTML is present in the live DOM at
// all times (loader.js injects all of them up front), so a second COPY of
// the same element ids would collide; physically relocating the existing
// node (same id, same listeners) does not.
let _stuActive = 1;
// id -> { parent, next } — where a borrowed node came from, so it can go home.
const _stuHomes = new Map();
// Moves the real DOM node `id` into `slotId`. Safe to call repeatedly; only
// records the original location once (first borrow), so returning always
// goes back to the true original spot even after several borrow/re-borrows.
function _stuBorrow(id, slotId) {
const el = document.getElementById(id);
const slot = document.getElementById(slotId);
if (!el || !slot) return;
if (!_stuHomes.has(id)) _stuHomes.set(id, { parent: el.parentNode, next: el.nextSibling });
slot.appendChild(el);
}
// Returns every currently-borrowed node to its original parent/position.
// Called unconditionally whenever navigation leaves Studio (see nav.js),
// so any other section that owns one of these subtrees always finds it
// back in place before that section's own onShow logic runs.
function _stuReturnAll() {
_stuIsActive = false;
if (typeof _stuRestoreCastFoot === 'function') _stuRestoreCastFoot();
_stuHomes.forEach(function (home, id) {
const el = document.getElementById(id);
if (el && home.parent) home.parent.insertBefore(el, home.next);
});
_stuHomes.clear();
}
window._stuReturnAll = _stuReturnAll;
// Borrowed entry points scattered across reader.js/audiobook.js/rehearser.js
// call navTo('s-reader')/navTo('s-library')/navTo('s-rehearser') themselves
// as a "make sure the right section is showing" safety measure — reasonable
// when invoked from those sections directly, but while Studio is the one
// orchestrating the call it would snap the user away from Studio (and, via
// nav.js's return-on-leave hook, immediately un-borrow whatever was just
// relocated there). These calls come from all over — a click handler wired
// at panel-render time (library.js's book cards), deep inside a multi-second
// PDF parse (readerOpenLibraryDoc), or buried in a multi-passage LLM casting
// pipeline that can run for minutes (audiobookCast's "Continue casting") —
// confirmed live, each one surfaced only after chasing and patching the
// previous one individually, which doesn't scale to arbitrary future call
// sites the same way.
//
// Instead of chasing each function, gate on DESTINATION: while Studio is the
// user's current section, any navTo call targeting one of the three sections
// Studio borrows from is presumed to be one of these internal "stay on my
// home section" reflexes and is dropped — UNLESS the user just clicked that
// section's own OLD sidebar entry, which is unambiguous real intent to leave
// (tracked as a one-shot allow flag set by a capture-phase listener on those
// specific sidebar links, so it can never itself be the thing that needs
// suppressing). This covers every current and future delayed/nested
// call site uniformly, with no timing window to get wrong.
let _stuIsActive = false;
let _stuAllowNextNav = false;
const _STU_BORROWED_FROM = { 's-reader': 1, 's-library': 1, 's-rehearser': 1 };
// Installed lazily (on first entry to Studio) rather than at script-load time:
// nav.js loads AFTER studio.js in loader.js's batch order and unconditionally
// does `window.navTo = function (sectionId) {...}` — patching eagerly here
// got silently clobbered the instant nav.js finished loading, since that
// happens strictly later (confirmed live: the wrapper was installed, then
// overwritten before the user could ever click anything). By the time
// studioOnShow() first runs, the whole page (including nav.js) has finished
// loading, so window.navTo is guaranteed to already be its real, final self.
let _stuNavGuardInstalled = false;
function _stuInstallNavGuardOnce() {
if (_stuNavGuardInstalled) return;
_stuNavGuardInstalled = true;
const realNavTo = window.navTo;
window.navTo = function (id) {
if (_stuIsActive && !_stuAllowNextNav && _STU_BORROWED_FROM[id]) return; // internal reflex, ignored
_stuAllowNextNav = false;
return realNavTo(id);
};
// showReaderView('cast'|'chars') toggles .hidden directly on the real
// reader-audiobook-panel/reader-charsheets-panel elements — but Studio
// shows/hides those same (borrowed) elements one level up, via its OWN
// slot visibility (_stuShowCastView). Any code that calls showReaderView
// directly (csForReader's "New recast", cast-from-rehearser, the emotion
// dialogue's fallback nav, ...) — not just navTo — can flip the panel
// visible while Studio's slot around it is still hidden, or vice versa.
// Confirmed live: clicking "New recast (discard & rebuild all)" while
// Studio's Characters tab was on "Identify" left the ENTIRE tab blank —
// the panel showReaderView just unhid (charsheets, with the live
// progress UI) sat inside a slot Studio still had hidden, while the
// slot the user could actually see had just had its own content hidden.
// Keep Studio's own tab UI in lockstep whenever this happens, on top of
// whatever the real function already does.
const realShowReaderView = window.showReaderView;
let _stuInShowReaderView = false;
window.showReaderView = function (view) {
const result = typeof realShowReaderView === 'function' ? realShowReaderView(view) : undefined;
// INFINITE RECURSION, confirmed live (renderer pegged at steady high CPU,
// then crashing/going blank): audiobookCastView() itself calls
// navReaderView('cast') -> this wrapper -> _stuShowCastView('identify')
// -> audiobookOpenCastView() -> audiobookCastView() again -> forever.
// Two guards: never re-enter this wrapper while already inside a call it
// triggered, AND skip entirely if Studio's sub-tab already matches (the
// overwhelmingly common case — most showReaderView('cast') calls are
// just the underlying code re-asserting a view that's already showing).
if (_stuIsActive && !_stuInShowReaderView && (view === 'cast' || view === 'chars')) {
const target = view === 'chars' ? 'sheets' : 'identify';
if (_stuCastView !== target) {
_stuInShowReaderView = true;
try { _stuShowCastView(target); } finally { _stuInShowReaderView = false; }
}
}
return result;
};
// Sub-items (Casting, Stage, Summary, Books, Theater Plays, Cast, …) call
// navTo for the same section but don't carry data-nav-section themselves —
// only their parent head does — so cover each section's whole nav-tree,
// not just its head.
document.querySelectorAll(
'[data-nav-section="s-reader"], #nav-reader-tree, ' +
'[data-nav-section="s-library"], #nav-library-tree, ' +
'[data-nav-section="s-rehearser"], #nav-rehearser-tree'
).forEach(function (el) {
el.addEventListener('click', function () { _stuAllowNextNav = true; }, true);
});
}
// Kept as a thin no-op-friendly shim: callers elsewhere in this file already
// wrap borrowed function calls with this name; with the persistent guard
// above, no actual patch/restore is needed per call, so this now just runs
// the function.
function _stuCallSuppressingNav(fn) { return fn(); }
// Per-phase borrow wiring — called by showStudioPhase whenever a phase
// becomes active. Each case borrows the real subtree that section already
// renders into/wires listeners on; no logic is duplicated here.
function _stuEnterPhase(n) {
if (n === 1) {
_stuBorrow('reader-main-view', 'stu-source-slot');
if (typeof window.readerOnShow === 'function') _stuCallSuppressingNav(window.readerOnShow);
_stuBorrow('lib-books-list', 'stu-books-slot');
_stuCallSuppressingNav(function () {
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
});
} else if (n === 2) {
_stuShowCastView(_stuCastView);
} else if (n === 3) {
_stuBorrow('lib-chars-list', 'stu-voices-slot');
// Scope the grid to the current book, same mechanism the old "Assign
// Voices" WF_STEPS stop already uses (utils.js). readerState.title can
// go stale — it's left pointing at whichever book was last opened via
// readerOpenLibraryDoc, which isn't necessarily the book whose cast is
// actually active (confirmed live: navigating here from a book 2 "View
// cast" action expanded book 1's section instead, because readerState
// still pointed at book 1 from earlier in the session). _audiobook.bookId
// is the field audiobookOpenCastView() actually keeps in sync with
// whichever book's cast session is active, so resolve the title from
// that via the server instead of trusting readerState.title first.
(async () => {
let title = null;
if (window._audiobook && window._audiobook.bookId) {
try {
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
} catch (_) {}
}
window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null;
_stuCallSuppressingNav(function () {
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
});
})();
} else if (n === 4) {
_stuBorrow('reh-phase-3', 'stu-stage-slot');
_stuBorrow('reh-cast-list', 'stu-mecast-slot');
_stuCallSuppressingNav(async function () {
if (window.rehState && rehState.lines && rehState.lines.length) {
if (typeof buildScriptPage === 'function') buildScriptPage();
if (typeof showPhase === 'function') showPhase(3);
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
return;
}
// _audiobook.segments is a purely in-memory cache — it's only ever
// populated by actually visiting the Characters phase first (which
// triggers audiobookOpenCastView()'s own draft-restore). Jumping
// straight to Perform & Export in a fresh session (confirmed live)
// left it empty even though a perfectly good, fully-cast draft was
// sitting on the server the whole time — Stage just rendered blank
// instead of loading it. Fetch it directly rather than requiring a
// detour through Characters first.
if (!(window._audiobook && window._audiobook.segments && window._audiobook.segments.length)
&& typeof _abLoadDraftServer === 'function' && typeof _abBookId === 'function') {
const bookId = _abBookId();
const draft = bookId ? await _abLoadDraftServer(bookId) : null;
if (draft) {
_audiobook.segments = draft.segments || [];
_audiobook.roster = draft.roster || [];
_audiobook.pageMarks = draft.pageMarks || [];
_audiobook.rehId = draft.rehId || _audiobook.rehId || null;
}
}
if (typeof window.audiobookOpenCurrentInRehearser === 'function' && (window._audiobook && window._audiobook.segments || []).length) {
return window.audiobookOpenCurrentInRehearser();
}
});
// audiobookOpenCurrentInRehearser's own navTo('s-rehearser') is now
// suppressed, but its DOM effects (rehLoadRecord -> renderCastList,
// buildScriptPage, showPhase(3)) still ran against the real elements —
// just re-assert reh-phase-3 isn't left hidden after all that.
const rp3 = document.getElementById('reh-phase-3');
if (rp3) rp3.hidden = false;
_stuSyncModeToggle();
}
}
// The Rehearse ⇄ Audiobook toggle — reads/writes the exact same rehState
// fields the old Script Rehearser toolbar already exposes (#reh-skip-desc-toggle,
// cast[sp].voice==='me'); no new synthesis/export codepath.
function _stuSyncModeToggle() {
const cb = document.getElementById('stu-mode-audiobook');
const details = document.getElementById('stu-mecast-details');
if (!cb || !window.rehState) return;
cb.checked = !rehState.skipDescriptions;
if (details) details.hidden = cb.checked;
}
document.getElementById('stu-mode-audiobook')?.addEventListener('change', function () {
const audiobookMode = this.checked;
if (window.rehState) {
rehState.skipDescriptions = !audiobookMode;
const t = document.getElementById('reh-skip-desc-toggle');
if (t) t.checked = rehState.skipDescriptions;
}
const details = document.getElementById('stu-mecast-details');
if (details) details.hidden = audiobookMode;
});
// Phase 2 (Characters) has its own two sub-views, mirroring Read Aloud's
// existing 'cast' (speaker attribution) and 'chars' (character sheets)
// views — same panels, same entry points (audiobookOpenCastView/csForReader),
// just borrowed into Studio's slots instead of shown in place.
let _stuCastView = 'identify';
function _stuShowCastView(view) {
_stuCastView = view;
document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(function (t) {
t.classList.toggle('active', t.dataset.stuCastView === view);
});
const identifySlot = document.getElementById('stu-cast-slot');
const sheetsSlot = document.getElementById('stu-castchars-slot');
if (identifySlot) identifySlot.hidden = (view !== 'identify');
if (sheetsSlot) sheetsSlot.hidden = (view !== 'sheets');
if (view === 'identify') {
_stuBorrow('reader-audiobook-panel', 'stu-cast-slot');
_stuCallSuppressingNav(function () {
if (typeof window.audiobookOpenCastView === 'function') return window.audiobookOpenCastView();
else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
});
_stuRelocateCastFoot();
} else if (view === 'sheets') {
_stuBorrow('reader-charsheets-panel', 'stu-castchars-slot');
const panel = document.getElementById('reader-charsheets-panel');
// This tab is the old page's WF_STEPS step 3 ("Cast Characters ·
// optional — let the AI fill out full character profiles"), i.e. the
// passage-by-passage PROFILE-generation pass (appearance/backstory/voice
// notes, live "Passage N / M…" progress) — a completely different thing
// from the dialogue SPEAKER-attribution pass on the Identify Characters
// tab. An earlier version of this empty-state wrongly pointed at that
// other pass's menu instead. The real entry point is `csForReader()`
// (character-sheets.js) — it locates its own render target via
// `csReaderPageHost()` (`$('reader-charsheets-panel')`), so calling it
// with no args renders inline into wherever this panel currently lives,
// borrowed or not.
if (panel && !panel.innerHTML.trim()) {
panel.innerHTML = '<div class="card" style="padding:24px; min-height:220px; display:flex; flex-direction:column; gap:12px; justify-content:center; align-items:flex-start;">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>'
+ '<p class="card-subtitle" style="margin:0;">Optional — let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.</p>'
+ '<button class="btn-primary btn-sm" id="stu-goto-cast-menu"><span class="mdi mdi-creation"></span> Generate character sheets</button>'
+ '</div>';
const goBtn = document.getElementById('stu-goto-cast-menu');
if (goBtn) goBtn.addEventListener('click', function () {
if (typeof window.csForReader === 'function') window.csForReader();
});
}
}
}
document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(function (tab) {
tab.addEventListener('click', function () { _stuShowCastView(tab.dataset.stuCastView); });
});
// The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" /
// "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify,
// Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's
// plain Identify/Cast Characters tabs above just duplicated by label without
// offering any of that. Its flyout menus are DOM-position-independent
// (_abToggleFootMenu in audiobook.js appends to document.body and positions
// via the trigger button's own getBoundingClientRect, not any ancestor), so
// the buttons can be physically relocated up next to Studio's own tabs
// instead of living in a separate bar at the bottom. "Open Script
// Rehearser" is dropped — Studio already has its own Perform & Export tab.
//
// audiobookCastView() does a full `panel.innerHTML = ...` on EVERY call —
// not just the first time the view opens, but also after in-place edits
// (e.g. "Split text to Unknown Speaker") and casting-progress updates — so a
// fresh #ab-cv-foot (same id) reappears inside the panel each time. A single
// poll-and-move only caught the first rebuild; confirmed live that a later
// rebuild left a second, un-relocated, un-hidden footer (with "Open Script
// Rehearser" still showing) sitting back at the bottom. Watch for it instead
// of relocating once.
let _stuCastFootObserver = null;
function _stuRelocateCastFoot() {
_stuTryRelocateCastFoot();
if (_stuCastFootObserver) return;
const slot = document.getElementById('stu-cast-slot');
if (!slot) return;
_stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); });
_stuCastFootObserver.observe(slot, { childList: true, subtree: true });
}
function _stuTryRelocateCastFoot() {
const slot = document.getElementById('stu-cast-slot');
const tabs = document.getElementById('stu-cast-inner-tabs');
if (!tabs) return;
const freshFoot = slot ? slot.querySelector('#ab-cv-foot') : null;
const alreadyRelocated = tabs.querySelector('#ab-cv-foot');
// Transient states (e.g. "Checking saved cast before starting a new
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
// plain tabs stayed hidden from an earlier relocation with nothing having
// replaced them, leaving no navigation visible whatsoever. Fall back to
// showing the plain tabs any time there's genuinely no footer to relocate
// (neither a fresh one nor an already-relocated one), rather than only
// ever hiding them and never re-checking.
if (!freshFoot && !alreadyRelocated) {
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
return;
}
if (!freshFoot || freshFoot.parentElement === tabs) return;
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
freshFoot.style.borderTop = 'none';
freshFoot.style.padding = '0';
freshFoot.style.justifyContent = 'flex-start';
tabs.appendChild(freshFoot);
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = true; });
const openReh = freshFoot.querySelector('#ab-cv-open-reh');
if (openReh) openReh.hidden = true;
}
// The footer isn't a whole-panel borrow tracked by _stuHomes (only the
// bigger #reader-audiobook-panel is), so leaving Studio would otherwise
// carry the relocated node's origin nowhere — the old Read Aloud page would
// come back missing its own footer. Called from _stuReturnAll before the
// panel itself goes home, so the footer rides back inside it intact.
function _stuRestoreCastFoot() {
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
const tabs = document.getElementById('stu-cast-inner-tabs');
const panel = document.getElementById('reader-audiobook-panel');
const foot = tabs ? tabs.querySelector('#ab-cv-foot') : null;
if (foot && panel) {
foot.style.borderTop = '';
foot.style.padding = '';
foot.style.justifyContent = '';
const openReh = foot.querySelector('#ab-cv-open-reh');
if (openReh) openReh.hidden = false;
panel.appendChild(foot);
}
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
}
function showStudioPhase(n) {
_stuActive = n;
for (let i = 1; i <= 4; i++) {
const el = document.getElementById('stu-phase-' + i);
if (el) el.hidden = (i !== n);
}
document.querySelectorAll('.stu-subtab').forEach(function (tab) {
tab.classList.toggle('active', parseInt(tab.dataset.stuPhase, 10) === n);
});
document.querySelectorAll('#nav-caststudio-tree [data-stu-phase]').forEach(function (item) {
item.classList.toggle('is-active', parseInt(item.dataset.stuPhase, 10) === n);
});
const prevBtn = document.getElementById('stu-phase-prev'), nextBtn = document.getElementById('stu-phase-next');
if (prevBtn) prevBtn.disabled = (n <= 1);
if (nextBtn) nextBtn.disabled = (n >= 4);
if (typeof _stuEnterPhase === 'function') _stuEnterPhase(n);
}
window.showStudioPhase = showStudioPhase;
document.querySelectorAll('.stu-subtab').forEach(function (tab) {
tab.addEventListener('click', function () {
showStudioPhase(parseInt(tab.dataset.stuPhase, 10));
});
});
document.getElementById('stu-phase-prev')?.addEventListener('click', function () { if (_stuActive > 1) showStudioPhase(_stuActive - 1); });
document.getElementById('stu-phase-next')?.addEventListener('click', function () { if (_stuActive < 4) showStudioPhase(_stuActive + 1); });
// Called once when Studio becomes the active section (see nav.js showSection).
function studioOnShow() {
// We're here because nav.js's navTo()/showSection() called us — meaning
// window.navTo is already nav.js's real, final function no matter what
// order scripts loaded in, so it's safe to capture it now.
_stuInstallNavGuardOnce();
// Set BEFORE showStudioPhase/_stuEnterPhase run — those synchronously
// borrow subtrees and call into reader.js/audiobook.js code that may
// itself call navTo, and the guard above needs _stuIsActive true for
// that first entry too, not just on subsequent clicks once Studio's own
// .is-active class has actually been applied by nav.js (which happens
// AFTER this hook runs).
_stuIsActive = true;
showStudioPhase(_stuActive);
}
window.studioOnShow = studioOnShow;