tts-voice-creator-clone-and.../static/js/audiobook-pipeline.js

1088 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Audiobook LLM casting and recast orchestration.
// Loaded after cast helpers and before audiobook.js; dependencies resolve at call time.
(function (root) {
// Every long-running audiobook action opens with a "already running?" guard.
// They all used to `return` in silence, so clicking Recast / Verify / Export
// while something else was in flight did nothing at all and said nothing —
// indistinguishable from a broken button. Worse, casting auto-starts the
// auto-repair sequence 800ms after it finishes, so the window in which a click
// is silently swallowed opens on its own, with no visible sign anything is
// running. Confirmed live: an automated pipeline recorded Recast and Verify as
// "done" in under a second each because both returned instantly this way.
function _abBusyGuard(what) {
if (!_audiobook.running) return false;
try {
toast(`${what} can't start yet — another pass is still running. Use Stop in the casting panel first.`, 'error');
} catch (_) {}
return true;
}
async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) {
if (_abBusyGuard('Recast / verify')) return;
const segs = _audiobook.segments;
if (!segs || !segs.length) return;
const originalSegments = segs.map(s => ({...s}));
const countUnknownDialogue = arr => (arr || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
const beforeUnknownCount = countUnknownDialogue(segs);
// Mechanical cases first (colon rule, post-quote inquit) — resolved in code
// for free, so they never even reach the LLM queue below.
const preResolved = audiobookResolveUnknowns(segs, [], _audiobook.roster || []);
if (preResolved.length) toast(`${preResolved.length} Unknown line${preResolved.length !== 1 ? 's' : ''} resolved by grammar rules`, 'success');
// The verify pass (options.includeNarrator) also re-checks every Narrator
// line, not just Unknown dialogue — that's the only way to catch dialogue
// that got hidden inside narration on the first pass. Confirmed
// non-Unknown dialogue stays out of scope either way; re-litigating lines
// that are already clearly attributed isn't what was asked for.
const unknownIdxs = [];
const _abRepairSuspects = options.repairNarration ? _abSuspectNarration(segs) : null;
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
const isUnknownDialogue = s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker));
// includeNarrator re-checks EVERY narration line (thorough but expensive);
// repairNarration re-checks only the ones that actually look like speech.
const isNarratorCandidate = (options.includeNarrator && s.type === 'narration')
|| (options.repairNarration && s.type === 'narration' && _abRepairSuspects && _abRepairSuspects.has(i));
if (isUnknownDialogue || isNarratorCandidate) unknownIdxs.push(i);
}
if (!unknownIdxs.length) {
toast('No Unknown speakers found', 'success');
audiobookShowPreview();
return;
}
_audiobook.running = true; _audiobook.cancel = false;
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
const ac = new AbortController(); _audiobook.abort = () => ac.abort();
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
// repairNarration exists specifically to re-judge narration that may really be
// speech — the verification prompt is the one written for that question, so it
// is the right default here. Previously only the menu button passed it, so the
// same pass behaved differently when called from auto-repair or the console.
let promptOverride = typeof options.prompt === 'string' ? options.prompt : null;
if (!promptOverride && options.repairNarration && typeof window._abVerificationPrompt === 'string') {
promptOverride = window._abVerificationPrompt;
}
const groups = audiobookRecastGroups(unknownIdxs, segs);
const view = audiobookCastView(unknownIdxs.length, llm_url, model);
view.recountRoster(segs); // sidebar shows the whole cast's roster, not just the lines being checked
// Resolve a speaker name the LLM returns to its canonical character —
// deterministic lookup against saved aliases (e.g. "Vampire" -> "Zerwas"),
// rather than relying on the model to remember/output the canonical name
// itself. Built once up front so every segment write-back stays consistent.
const _rcAliasMap = new Map();
try {
const bookTitle = window.readerState?.title || '';
const records = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(bookTitle) : [];
for (const rec of records || []) {
if (!rec?.name) continue;
_rcAliasMap.set(rec.name.toLowerCase(), rec.name);
if (typeof clSplitIdentityTokens === 'function') {
for (const a of clSplitIdentityTokens(rec.aliases, { aliases: true })) _rcAliasMap.set(a.toLowerCase(), rec.name);
}
}
} catch (_) {}
const canonicalizeSpeaker = (name) => (name && _rcAliasMap.get(name.toLowerCase())) || name;
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
try {
await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({
text: 'Wake up.', known_characters: [], recent: '', language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
})
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
} catch (err) {
if (err.name === 'AbortError') {
_audiobook.cancel = true;
_audiobook.running = false;
_audiobook.abort = null;
view.done({ stopped: true, message: 'Character definition stopped before any lines were updated.' });
toast('Character definition stopped. Existing cast preserved.', 'info');
return;
}
}
let done = 0;
let prevIdx = -2;
let groupsSinceSave = 0;
const pendingReplacements = new Map();
const normalizeRecastSegment = (seg, fallback) => {
const type = seg?.type === 'narration' ? 'narration' : 'dialogue';
const speaker = type === 'narration' ? 'Narrator' : canonicalizeSpeaker(seg?.speaker || fallback?.speaker || 'Unknown');
return {
speaker,
type,
emotion: type === 'dialogue' ? (seg?.emotion || fallback?.emotion || '') : '',
text: seg?.text || fallback?.text || ''
};
};
try {
for (let group of groups) {
if (_audiobook.cancel) break;
const unresolvedGroup = [];
for (const idx of group) {
if (audiobookIsSpeechTagOnly(segs[idx]?.text)) {
segs[idx].type = 'narration';
segs[idx].speaker = 'Narrator';
segs[idx].emotion = '';
} else {
unresolvedGroup.push(idx);
}
}
if (!unresolvedGroup.length) {
if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]);
prevIdx = group[group.length - 1];
view.update(done, `Correcting narration tags ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`);
for (const idx of group) {
view.addSegments([segs[idx]]);
done++;
}
continue;
}
group = unresolvedGroup;
// These Unknown lines come from scattered places in the document. Mark a
// gap with "⋯" whenever this passage isn't directly after the previous one.
if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]);
prevIdx = group[group.length - 1];
const recastCtx = audiobookRecastContext(segs, group);
const passageText = recastCtx.text;
const lineLabel = group.length > 1
? `Attributing lines ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`
: `Attributing line ${done + 1} / ${unknownIdxs.length}…`;
view.update(done, lineLabel);
view.processing(passageText.trim());
let data = null;
const recastBody = audiobookAttributeBody({
text: passageText.trim(),
known_characters: _audiobook.roster.slice(-40),
recent: recastCtx.recent || '',
language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)
}, promptOverride);
try {
// Streaming first (live thinking view); blocking endpoint as fallback.
try {
data = await audiobookAttributeStream(recastBody, view, ac.signal, AUDIOBOOK_RECAST_TIMEOUT_MS);
} catch (streamErr) {
if (streamErr.name === 'AbortError') throw streamErr;
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify(recastBody)
}, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
data = await r.json();
}
} catch (err) {
if (err.name === 'AbortError') { _audiobook.cancel = true; break; }
// statusText is empty on HTTP/2, which used to leave this note blank after the colon
view.note(`API Error while checking ${group.length > 1 ? 'a group of Unknown lines' : 'Unknown line'}: ${err.message || err}`);
for (const idx of group) {
view.addSegments([segs[idx]]);
done++;
}
continue;
}
if (data && data.segments) {
const used = new Set();
for (const idx of group) {
const targetSeg = segs[idx];
const seqMatch = audiobookFindReturnedSegmentSequence(targetSeg, data.segments, used);
if (seqMatch && seqMatch.segs.length > 1) {
const repl = seqMatch.segs.map(s => normalizeRecastSegment(s, targetSeg)).filter(s => (s.text || '').trim());
const hasResolved = repl.some(s => s.type === 'narration' || (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)));
const hasUnresolvedDialogue = repl.some(s => s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)));
const hasImplausibleDialogue = repl.some(s => s.type === 'dialogue' && _abImplausibleDialoguePiece(s.text));
if (repl.length && hasResolved && !hasUnresolvedDialogue && !hasImplausibleDialogue) {
seqMatch.idxs.forEach(i => used.add(i));
pendingReplacements.set(idx, repl);
repl.forEach(s => {
if (s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker) && !_audiobook.roster.includes(s.speaker)) _audiobook.roster.push(s.speaker);
});
continue;
}
}
const match = audiobookFindReturnedSegment(targetSeg, data.segments, used);
if (match && match.seg.speaker) {
const speaker = match.seg.type === 'narration' ? 'Narrator' : canonicalizeSpeaker(match.seg.speaker);
if (!speaker || /^Unknown|Unbekannt/i.test(speaker)) continue;
used.add(match.idx);
targetSeg.type = match.seg.type === 'narration' ? 'narration' : 'dialogue';
targetSeg.speaker = speaker;
targetSeg.emotion = targetSeg.type === 'dialogue' ? (match.seg.emotion || targetSeg.emotion || '') : '';
if (targetSeg.type === 'dialogue' && !_audiobook.roster.includes(speaker)) _audiobook.roster.push(speaker);
}
}
}
for (const idx of group) {
view.addSegments(pendingReplacements.get(idx) || [segs[idx]]);
done++;
}
groupsSinceSave++;
if (groupsSinceSave >= 10 && _audiobook.lastText) {
groupsSinceSave = 0;
_abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
view.recountRoster(segs); // keep the sidebar reflecting the full cast as lines resolve
}
}
view.update(_audiobook.cancel ? done : unknownIdxs.length);
} catch (e) {
if (e.name !== 'AbortError') view.note('Error defining unknowns: ' + e.message);
} finally {
_audiobook.running = false;
_audiobook.abort = null;
_abHideCastingControl();
}
// Everything below only ever ran when nothing above threw — but nothing
// here was itself guarded, so any failure in this post-processing (dedup,
// rollback check, saving the draft) left the UI permanently stuck showing
// "Stop Casting" with no error and no way out, even though the LLM work
// itself had already genuinely finished (GPU load back to idle). Wrapping
// it means a failure here still reaches a terminal view.done()/view.note()
// call instead of silently hanging forever.
try {
if (pendingReplacements.size) {
[...pendingReplacements.entries()]
.sort((a, b) => b[0] - a[0])
.forEach(([idx, repl]) => segs.splice(idx, 1, ...repl));
}
// Splicing multi-segment replacements back in at scattered indices can
// reintroduce a passage that a neighbouring recast group's overlapping
// context window already restated correctly a few segments earlier —
// confirmed live (a paragraph appearing twice with a stray leading quote
// mark on the second copy, separated by an unrelated dialogue block).
const { segments: deduped, removed: dupRemoved } = _audiobookDedupNearbyDuplicates(segs);
if (dupRemoved) {
segs.splice(0, segs.length, ...deduped);
view.note(`Removed ${dupRemoved} duplicated line${dupRemoved !== 1 ? 's' : ''} introduced by this verification pass.`);
}
const afterUnknownCount = countUnknownDialogue(segs);
// The `!cancel` guard used to sit in front of this check, so STOPPING a run
// skipped the rollback entirely and a half-finished pass that had made the
// cast worse was kept silently — confirmed live: a stopped recast took a book
// from 80 Unknown lines to 159, with no warning and no way back. A run that
// increases Unknown speakers is never worth keeping, finished or interrupted.
if (afterUnknownCount > beforeUnknownCount) {
segs.splice(0, segs.length, ...originalSegments);
const how = _audiobook.cancel ? 'Stopped run' : 'Quality run';
view.note(`${how} rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
toast(`${how} rolled back because it increased Unknown speakers.`, 'error');
}
// Recast-unknown only fixes speakers on already-cast segments — it never adds
// new passages, so the original cast's real done/total must carry through
// unchanged rather than being overwritten with a fake "100% done" value.
const _rcDone = _audiobook.completedChunks || segs.length;
const _rcTotal = _audiobook.completedTotal || _rcDone;
if (_audiobook.cancel) {
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' });
toast('Character definition stopped. Existing cast preserved.', 'info');
return;
}
// Repair passes assign speakers of their own, so they can re-introduce the
// label variants the cast-time consolidation just removed — confirmed live:
// a clean cast gained "Alrik" alongside "Alrik von Blautann" during
// auto-repair, which would have split one character across two voices again.
{ const _ph = _audiobookDropPhantomSpeakers(segs);
if (_ph.dropped) console.info('[recast] dropped', _ph.dropped, 'phantom speaker line(s):', _ph.names.join(', ')); }
{ const _role = _audiobookResolveRoleAliases(segs, _audiobook.lastText || '');
if (_role.moved) {
console.info('[recast] resolved role labels to names:', _role.pairs.join(', '), `(${_role.moved} lines)`);
view.note(`Resolved ${_role.merged} role label${_role.merged !== 1 ? 's' : ''} to character names (${_role.moved} lines).`);
} }
{ const _ident = _audiobookConsolidateSpeakerAliases(segs);
if (_ident.merged) {
console.info('[recast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');
view.note(`Merged ${_ident.merged} duplicate character label${_ident.merged !== 1 ? 's' : ''} (${_ident.moved} lines).`);
} }
// Recast/verify have just reassigned speakers, which creates new runs of
// consecutive lines by one character that the cast-time merge never saw.
{ const _dm = _audiobookMergeAdjacentDialogue(segs);
if (_dm.merged) {
segs.splice(0, segs.length, ..._dm.segments);
console.info('[recast] merged', _dm.merged, 'consecutive same-speaker dialogue segment(s)');
view.note(`Merged ${_dm.merged} consecutive same-speaker line${_dm.merged !== 1 ? 's' : ''}.`);
} }
// Confidence last: every pass above can change a speaker, and a stale
// score is worse than none (it would send the reviewer to the wrong lines).
_abStampConfidence(segs);
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
} catch (e) {
console.error('[audiobookRecastUnknown] post-processing failed', e);
view.done({ stopped: true, message: 'Finished checking speakers, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });
toast('Casting finished but cleanup failed: ' + e.message, 'error');
}
}
// Repeatedly runs the recast-unknown + narrator-verify pass (the bounded
// { repairNarration: true } combination — narration lines that actually score
// as mislabeled speech, rather than every narration segment in the book)
// until the number of Unknown-speaker dialogue lines drops below `threshold`
// or two consecutive passes make no further progress (LLM/GPU contention or a
// genuinely irreducible residue of ambiguous lines — either way, more passes
// won't help). User-requested follow-up: this used to only be doable by
// calling audiobookRecastUnknown() directly from the browser console.
// ── Auto-repair after a full cast ───────────────────────────────────────────
// A fresh "Identify all characters" run routinely leaves a tail of Unknown
// speakers plus spoken lines mis-typed as narration. Fixing those by hand meant
// knowing which of the six menu passes to run, in which order — repair BEFORE
// identify (repair converts narration into new, still-unattributed dialogue),
// and the expensive full verify only as a last resort. This runs that sequence
// automatically and stops as soon as it stops helping, so a book that casts
// cleanly costs nothing extra.
const AB_AUTO_REPAIR_KEY = 'ab-auto-repair-enabled';
function audiobookAutoRepairEnabled() {
try { return localStorage.getItem(AB_AUTO_REPAIR_KEY) !== '0'; } catch (_) { return true; }
}
async function audiobookAutoRepairCast(threshold = 10, maxRounds = 4) {
const segs = _audiobook.segments || [];
if (!segs.length) { toast('Nothing cast yet to repair', 'error'); return; }
const countUnknown = () => (_audiobook.segments || []).filter(s =>
s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
let prev = countUnknown();
if (prev <= threshold) { toast(`Cast looks clean — ${prev} unknown speaker${prev !== 1 ? 's' : ''}`, 'success'); return; }
toast(`Auto-repair: ${prev} unknown speakers — running repair passes…`, 'info');
for (let round = 1; round <= maxRounds; round++) {
// repairNarration also re-checks Unknown dialogue in the same call, so one
// pass per round covers menu steps 2 and 3 together.
await audiobookRecastUnknown(null, null, { repairNarration: true });
if (_audiobook.cancel) { toast('Auto-repair stopped.', 'info'); return; }
const now = countUnknown();
if (now <= threshold) { toast(`Auto-repair done: ${now} unknown left after ${round} round${round !== 1 ? 's' : ''}.`, 'success'); return; }
if (now >= prev) break; // no further progress — stop burning GPU time
prev = now;
}
// 2.4 — still above target: one more verification pass.
// This used to pass includeNarrator, which queues EVERY narration segment
// (~1000 on a novel = ~190 sequential LLM calls). Auto-repair fires on its
// own after each cast, so that fallback ran unattended and never finished:
// confirmed live, it sat for ~4 hours making almost no LLM calls, changed
// not one segment, and only ended when the host process hit its 6-hour cap.
// A sweep that cannot complete is strictly worse than one that can, so this
// uses the same bounded suspect-scored pass as the rounds above.
toast(`2.4 Still ${prev} unknown — running full verification pass…`, 'info');
await audiobookRecastUnknown(null, null, { repairNarration: true });
const afterVerify = countUnknown();
// 2.5 — consistency is a different kind of check (it re-judges lines already
// attributed), so it runs last, once attributions have settled, and only if
// the passes above didn't leave the cast in a worse state than they found it.
if (typeof window._abRunConsistencyPass === 'function' && !_audiobook.cancel) {
toast('2.5 Checking voice consistency…', 'info');
try { await window._abRunConsistencyPass(); } catch (e) { console.warn('[auto-repair 2.5]', e); }
}
const final = countUnknown();
toast(final <= threshold
? `Auto-repair done: ${final} unknown left.`
: `Auto-repair finished: ${final} unknown remain (target was under ${threshold}).`,
final <= threshold ? 'success' : 'error');
}
window.audiobookAutoRepairCast = audiobookAutoRepairCast;
async function audiobookRecastUntilThreshold(threshold = 10, maxPasses = 8) {
if (_audiobook.running) { toast('Casting is already running', 'error'); return; }
const segs = _audiobook.segments;
if (!segs || !segs.length) { toast('No cast to check', 'error'); return; }
const countUnknown = () => (segs || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
let prev = countUnknown();
if (prev <= threshold) { toast(`Already at ${prev} unknown speakers (target: <${threshold})`, 'success'); return; }
toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})…`, 'info');
for (let pass = 1; pass <= maxPasses; pass++) {
// Each round re-checks Unknown dialogue AND narration lines that score as
// mislabeled speech. This used to pass includeNarrator, which re-sent every
// narration segment (~1000 on a novel) to the LLM on EVERY iteration —
// enormously expensive and it re-litigated lines that were already correct.
await audiobookRecastUnknown(null, null, { repairNarration: true });
if (_audiobook.cancel) { toast('Stopped — cancelled mid-pass.', 'info'); return; }
const now = countUnknown();
if (now <= threshold) { toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass !== 1 ? 'es' : ''}).`, 'success'); return; }
if (now >= prev) { toast(`Stopped after ${pass} pass${pass !== 1 ? 'es' : ''}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`, 'error'); return; }
prev = now;
}
toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`, 'error');
}
window.audiobookRecastUntilThreshold = audiobookRecastUntilThreshold;
async function audiobookOpenCastView() {
if (_audiobook.running) return;
const text = audiobookScopeText();
if (!text) { toast('Import a document first', 'error'); return; }
// Bind this casting session to the open library book so its draft is restored
// by identity on reopen. Clearing the in-memory cast when the book changes
// prevents a stale in-memory cast from masking the correct per-book draft.
const _curBook = (window.readerState && readerState.savedId) || null;
if (_curBook && _audiobook.bookId && _audiobook.bookId !== _curBook) {
_audiobook.segments = null; _audiobook.lastText = null; _audiobook.roster = null;
}
_audiobook.bookId = _curBook;
const llm_url = audiobookLlmUrl();
const model = audiobookLlmModel();
const chunks = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text];
// 1. In-memory restore — user navigated away and came back in the same session
if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) {
const view = audiobookCastView(chunks.length, llm_url, model, false);
view.rebuild(_audiobook.segments);
const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
return;
}
// Helper: apply a draft object into the view
const _applyDraft = (draft, view, source) => {
const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0;
const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0;
_abStampSegmentPages(draft.segments, draft.pageMarks, text); // pre-.page drafts: stamp once so the renderer stays single-path
_audiobook.segments = draft.segments;
_audiobook.roster = draft.roster || [];
_audiobook.lastText = text;
_audiobook.pageMarks = draft.pageMarks || [];
_audiobook.rehId = draft.rehId || null;
// Always keep the raw progress the original cast reached, even once it's
// finished or a later edit re-saves the draft — later saves (manual speaker
// fixes, recast-unknown) must not clobber this with a "looks 100%" sentinel,
// or an interrupted cast becomes silently unresumable and misreported as done.
_audiobook.completedChunks = draftDone > 0 ? draftDone : 0;
_audiobook.completedTotal = draftTotal > 0 ? draftTotal : 0;
view.rebuild(draft.segments);
const ageMs = Date.now() - (draft.savedAt || 0);
const ageMins = Math.round(ageMs / 60000);
const ageStr = ageMins < 1 ? 'gerade eben' : ageMins < 60 ? `vor ${ageMins} Min.` : `vor ${Math.round(ageMins/60)} Std.`;
const pct = draftTotal > 0 ? Math.max(0, Math.min(100, Math.round((draftDone / draftTotal) * 100))) : 100;
const wasDone = draftTotal > 0 && draftDone >= draftTotal;
const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal;
const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt';
view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Identify all characters" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`);
const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
};
// 2. localStorage draft restore — fastest, but NOT automatically authoritative.
// The local copy used to win outright and was then pushed to the server as a
// "backfill", which meant a stale browser draft silently overwrote newer work
// saved from anywhere else — confirmed live: a cast improved from 79 Unknown
// lines to 25 and stored server-side was destroyed simply by opening the book
// in a tab holding the older draft. Whichever copy was saved most recently
// wins, and the server is only backfilled when the local copy is genuinely
// newer.
const _localDraft = _abLoadDraft(text);
if (_localDraft && _localDraft.segments && _localDraft.segments.length > 0) {
const view = audiobookCastView(chunks.length, llm_url, model, false);
const bookId = _abBookId();
let chosen = _localDraft, source = 'local', serverDraft = null;
if (bookId) {
try { serverDraft = await _abLoadDraftServer(bookId); } catch (_) {}
if (serverDraft && (serverDraft.savedAt || 0) > (_localDraft.savedAt || 0)) {
chosen = serverDraft; source = 'server';
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch (_) {}
}
}
_applyDraft(chosen, view, source);
// Only push upwards when the local copy really is the newer one.
if (bookId && source === 'local' && (!serverDraft || (_localDraft.savedAt || 0) > (serverDraft.savedAt || 0))) {
fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ ..._localDraft, bookId, title: window.readerState?.title || _localDraft.title || '' })
}).catch(() => {});
}
return;
}
// 3. Server draft restore — for other browsers/devices or cleared localStorage
const bookId = _abBookId();
if (bookId) {
const view = audiobookCastView(chunks.length, llm_url, model, false);
view.loadingRestore();
try {
const serverDraft = await _abLoadDraftServer(bookId);
if (serverDraft && serverDraft.segments && serverDraft.segments.length > 0) {
// Cache locally so next open is instant
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch(_) {}
_applyDraft(serverDraft, view, 'server');
} else {
view.setFreshState('No saved cast was found for this saved book. Starting a new cast will create a new draft.');
}
} catch (_) {
view.setFreshState('Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.');
}
return;
}
// 4. Fresh start (no book ID, no draft)
audiobookCastView(chunks.length, llm_url, model, true);
}
// Sentence splitter mirroring splitTextIntoChunks's own boundaries, so
// "did this sentence make it into the cast" is judged at the same
// granularity the rest of the pipeline already uses.
function _abSourceSentences(text) {
const out = [];
for (const line of String(text || '').split('\n')) {
if (!line.trim()) continue;
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
for (const s of sentences) { const t = s.trim(); if (t) out.push(t); }
if (rest) out.push(rest);
}
return out;
}
function _abNormForCoverage(t) {
return String(t || '').replace(/[«»„“”"'’‚‹›]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
}
// Safety net for a known LLM failure mode: given a chunk of source text and
// the segments the LLM attributed for it, some passages come back with a
// sentence or whole paragraph silently missing from the JSON response —
// not mis-attributed, just never mentioned at all. Nothing downstream
// (recast, quality-run, merge passes) re-examines the *source* text against
// what actually made it into `segs`, so a dropped paragraph used to vanish
// from the cast permanently and the audiobook would jump straight to the
// next one (confirmed live). Recover any such gap as a Narrator segment,
// spliced back in next to the nearest sentence that DID make it through, so
// content is never silently lost — worst case it's a paragraph attributed
// to Narrator that a human later moves to the right speaker.
function _abRecoverDroppedText(sourceText, segs) {
if (!segs.length) return { segs, recovered: 0 };
const sentences = _abSourceSentences(sourceText).filter(s => _abNormForCoverage(s).length >= 8);
if (!sentences.length) return { segs, recovered: 0 };
const segNorm = segs.map(s => _abNormForCoverage(s.text));
const matchIdx = sentences.map(s => segNorm.findIndex(t => t.includes(_abNormForCoverage(s))));
const out = [];
let copiedUpTo = -1;
let gap = [];
let recovered = 0;
const flushGap = (beforeSegIdx) => {
if (!gap.length) return;
while (copiedUpTo + 1 < beforeSegIdx) out.push(segs[++copiedUpTo]);
out.push({ speaker: 'Narrator', type: 'narration', text: gap.join(' '), emotion: '', _recovered: true });
recovered++;
gap = [];
};
sentences.forEach((s, i) => {
if (matchIdx[i] === -1) { gap.push(s); return; }
flushGap(matchIdx[i]);
});
while (copiedUpTo + 1 < segs.length) out.push(segs[++copiedUpTo]);
if (gap.length) { out.push({ speaker: 'Narrator', type: 'narration', text: gap.join(' '), emotion: '', _recovered: true }); recovered++; }
return { segs: out, recovered };
}
async function audiobookCast(overrideUrl, overrideModel, resume) {
if (_abBusyGuard('Casting')) return;
const text = audiobookScopeText();
if (!text) { toast('Import a document first', 'error'); return; }
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
_audiobook.running = true;
const hadServerAutosaveTarget = !!_abBookId();
if (!hadServerAutosaveTarget) {
const ready = await _abEnsureLibraryBook();
if (!ready && typeof toast === 'function') {
toast('Autosave will use this browser only until the book is saved to the library.', 'info');
}
}
const chunks = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text];
// Resuming an interrupted cast: pick up at the passage the autosave stopped
// on instead of reprocessing everything from passage 1. Chunking is a pure
// function of the text, so the same text always re-splits into the same
// chunk boundaries, making the saved passage index safe to resume at.
const startIndex = (resume && resume.startIndex > 0 && resume.startIndex < chunks.length) ? resume.startIndex : 0;
const isResume = startIndex > 0;
_audiobook.cancel = false;
if (!isResume) {
// Deliberately does NOT null _audiobook.rehId here. It used to, on the
// theory that a fresh (non-resume) cast should always start a brand-new
// Rehearsal Library record — but "fresh cast" also covers "Recast all"
// and simply retrying after a failed/timed-out run for a book that
// already has a rehId (restored from its draft in _applyDraft above).
// Nulling it there meant every retry silently orphaned the previous
// record and created a new one, piling up near-duplicate scripts in the
// Rehearsal Library for the same book. Keeping whatever rehId is already
// known — null only for a genuinely first-ever cast — makes retries/
// recasts overwrite that one record instead.
_abClearDraft();
}
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
const ac = new AbortController();
_audiobook.abort = () => ac.abort();
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(text);
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
const view = audiobookCastView(chunks.length, llm_url, model, false);
if (isResume && resume.segments && resume.segments.length) view.rebuild(resume.segments);
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
try {
await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({
text: 'Wake up.', known_characters: [], recent: '', language,
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
})
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
} catch (err) {
if (err.name === 'AbortError') {
_audiobook.cancel = true;
_audiobook.running = false;
_audiobook.abort = null;
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
toast('Casting stopped before any passages were saved.', 'info');
return;
}
}
const allSegments = isResume ? resume.segments.slice() : [];
_audiobook.liveSegments = allSegments; // expose so split works during casting
const roster = isResume ? (resume.roster || []).slice() : [];
let narrationOnly = isResume ? (resume.narrationOnly || 0) : 0; // passages with no quotes at all — legitimately all narration
let degraded = isResume ? (resume.degraded || 0) : 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
let recoveredGaps = isResume ? (resume.recoveredGaps || 0) : 0; // passages where the LLM silently dropped a sentence/paragraph, recovered as Narrator
let completedChunks = startIndex;
const saveCheckpoint = () => {
if (allSegments.length) _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
};
_abStartDraftAutosave(saveCheckpoint);
// Page-mark tracking for visual page-break rows in the casting feed. Segments
// are stamped with the resolved page number as they're created (see _curPageNum
// below) so the feed can be redrawn later purely from that stored field instead
// of re-guessing offsets from text — re-guessing is what used to silently merge
// pages after navigating away from the casting panel and back.
const _pgMarks = (_audiobook.pageMarks || []).slice();
if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0
let _pgMarkIdx = 0, _pgCharPos = 0, _curPageNum = 1;
if (isResume) {
// Fast-forward the page-mark cursor past the passages already cast so
// page-break rows aren't re-emitted into the feed on resume.
for (let k = 0; k < startIndex; k++) _pgCharPos += chunks[k].length + 1;
while (_pgMarkIdx < _pgMarks.length && _pgMarks[_pgMarkIdx].offset <= _pgCharPos) _pgMarkIdx++;
if (_pgMarkIdx > 0) _curPageNum = _pgMarks[_pgMarkIdx - 1].page + 1;
}
try {
for (let i = startIndex; i < chunks.length; i++) {
if (_audiobook.cancel) break;
view.update(i);
// Insert page-break markers in the feed when the source PDF page changes.
while (_pgMarkIdx < _pgMarks.length && _pgCharPos >= _pgMarks[_pgMarkIdx].offset) {
_curPageNum = _pgMarks[_pgMarkIdx].page + 1;
view.pagemark(_curPageNum);
_pgMarkIdx++;
}
_pgCharPos += chunks[i].length + 1; // +1 for joining space between chunks
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
if (!audiobookHasDialogue(chunks[i])) {
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '', page: _curPageNum };
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
completedChunks = i + 1;
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
continue;
}
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker))
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
view.processing(chunks[i]);
// ── Helper: run one attribution call and return parsed segments (or null on error) ──
const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => {
const body = { text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) };
// Streaming first — shows the LLM's live thinking in the processing
// card. Any stream failure falls through to the blocking endpoint.
try {
const d = await audiobookAttributeStream(body, view, ac.signal, timeoutMs);
return Array.isArray(d.segments) ? d.segments : null;
} catch (err) {
if (err.name === 'AbortError') throw err; // propagate cancel
}
try {
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify(body),
}, timeoutMs + 5000);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
const d = await r.json();
return Array.isArray(d.segments) ? d.segments : null;
} catch (err) {
if (err.name === 'AbortError') throw err; // propagate cancel
return { error: err.message };
}
};
// The whole per-chunk attribution + cleanup pipeline is guarded so that any
// unexpected error for ONE passage (bad response shape, network hiccup mid
// retry, etc.) degrades gracefully to quote-splitting instead of throwing an
// uncaught error that kills the entire cast loop and strands the panel in a
// broken, un-navigable state. Abort (Stop Casting) still propagates normally.
let segs;
try {
// ── First attempt ──
let result = await attributeChunk(chunks[i], recent);
let data = null;
if (result && !result.error) {
data = result;
} else if (result && result.error) {
// ── Retry: split the failing chunk in half and run both halves ──
view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`);
const half = Math.floor(chunks[i].length / 2);
const splitAt = chunks[i].lastIndexOf(' ', half) || half;
const chunkA = chunks[i].slice(0, splitAt).trim();
const chunkB = chunks[i].slice(splitAt).trim();
const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
const segsA = Array.isArray(resA) ? resA : null;
const segsB = Array.isArray(resB) ? resB : null;
if (segsA || segsB) {
const arrA = segsA || audiobookSplitByQuotes(chunkA);
const arrB = segsB || audiobookSplitByQuotes(chunkB);
data = [...arrA, ...arrB];
if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); }
if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); }
} else {
// Both halves also failed
view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`);
data = null;
}
}
segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []);
const hasDialogue = segs.some(s => s.type === 'dialogue');
if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) {
// LLM completely unavailable — extract dialogue from quotes without speaker attribution
segs = audiobookSplitByQuotes(chunks[i]);
degraded++;
const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length;
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
} else {
// Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration
let cleanedSegs = [];
for (let s of segs) {
s.text = s.text || '';
if (s.type === 'dialogue' && audiobookIsSpeechTagOnly(s.text)) {
s.type = 'narration';
s.speaker = 'Narrator';
s.emotion = '';
}
// Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination.
if (s.type === 'dialogue' && s.text.trim().length > 10) {
const tText = s.text.trim();
// Try to find the text in the original chunk to check its surroundings
const idx = chunks[i].indexOf(tText);
if (idx !== -1) {
const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8);
if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) {
s.type = 'narration';
s.speaker = 'Narrator';
s.emotion = '';
}
}
}
// The check above only covers text over 10 chars, so it never
// catches the shorter case: a »quoted title/name« word that DOES
// have real quote marks around it in the source (so the check
// above finds nothing wrong) but isn't actually a spoken line at
// all — see _abImplausibleDialoguePiece.
if (s.type === 'dialogue' && _abImplausibleDialoguePiece(s.text)) {
s.type = 'narration';
s.speaker = 'Narrator';
s.emotion = '';
}
if (cleanedSegs.length > 0) {
let last = cleanedSegs[cleanedSegs.length - 1];
if (s.text.trim() === last.text.trim()) {
if (s.type === 'dialogue' && last.type !== 'dialogue') {
cleanedSegs[cleanedSegs.length - 1] = s; continue;
} else if (s.type !== 'dialogue' && last.type === 'dialogue') {
continue;
} else { continue; }
}
if (s.type === 'dialogue' && last.type === 'narration') {
const tS = s.text.trim(), tL = last.text.trim();
if (tL.endsWith(tS)) {
last.text = tL.slice(0, -tS.length).trim();
if (!last.text) cleanedSegs.pop();
}
}
// The mirror image of the endsWith() case above, which was the
// only overlap this handled: the LLM commonly returns a quote
// as its own segment AND the full original sentence (quote +
// speech tag) as the next one, so the FOLLOWING segment starts
// with a repeat of the previous one rather than ending with it.
// Nothing caught that, so the same words were spoken twice and
// the leftover was re-split again downstream into a third
// fragment (confirmed live: "»Wir werden sehen«" appearing as
// two dialogue lines plus a stray ", murmelte der Ork."; and
// narration paragraphs restated verbatim before continuing).
// Strip the duplicated prefix and keep only the genuinely new
// remainder — usually the speech tag, which is narration.
const _cut = _abPrefixDupCut(s.text, last.text);
if (_cut > 0) {
s.text = s.text.slice(_cut).replace(/^[\s«»„“”"‟‚‘’›‹]*,?\s*/, '').trim();
// What remains after the quote is the inquit ("murmelte der
// Ork."), which belongs to the narrator, never to the
// character who just spoke.
if (s.text && last.type === 'dialogue' && s.type === 'dialogue') {
s.type = 'narration'; s.speaker = 'Narrator'; s.emotion = '';
}
}
}
if (s.text.trim()) cleanedSegs.push(s);
}
// Merge consecutive narration segments to preserve paragraph flow
let mergedSegs = [];
for (let s of cleanedSegs) {
if (mergedSegs.length > 0) {
let last = mergedSegs[mergedSegs.length - 1];
if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') {
// Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence)
// But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence.
// A safe heuristic: if it ends with punctuation, use double newline (paragraph break).
if (/[.!?]$/.test(last.text.trim())) {
last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart();
} else {
last.text = last.text.trimEnd() + ' ' + s.text.trimStart();
}
continue;
}
}
mergedSegs.push(s);
}
// The LLM can correctly split SOME dialogue in a chunk while
// leaving OTHER »...« lines merged into a narration segment right
// next to it — the `hasDialogue` check above only asks "did this
// chunk produce ANY dialogue at all", so it's satisfied by the
// first correct split and never re-examines the rest (confirmed
// live: "»Ich glaube, ich bin in dich verliebt.«" got attributed
// to Alrik correctly while "»Halt, bleib stehen.«" and "»Ich liebe
// dich«" a few lines later stayed silently merged into narration
// with no speaker at all). Re-scan every leftover narration
// segment individually and pull out anything still embedded —
// audiobookResolveUnknowns right after this already exists to
// firm up any "Unknown" speaker this isolated re-split can't infer
// from the surrounding chunk context.
let respiltSegs = [];
for (const s of mergedSegs) {
if (s.type === 'narration' && audiobookHasDialogue(s.text)) {
respiltSegs.push(...audiobookSplitByQuotes(s.text));
} else {
respiltSegs.push(s);
}
}
// This re-split runs AFTER the per-segment cleanup above, so the
// brand-new dialogue segments it invents never passed the
// plausibility check up there — which is exactly how a »quoted
// title« kept surviving as dialogue despite that fix (confirmed
// live: the "»Lilo« and »Zerwas«" byline still split into
// one-word dialogue segments). Re-apply it to this pass's output.
for (const s of respiltSegs) {
if (s.type === 'dialogue' && _abImplausibleDialoguePiece(s.text)) {
s.type = 'narration'; s.speaker = 'Narrator'; s.emotion = '';
}
}
segs = respiltSegs;
}
} catch (chunkErr) {
if (chunkErr.name === 'AbortError') throw chunkErr; // propagate Stop Casting
degraded++;
segs = audiobookSplitByQuotes(chunks[i]);
view.note(`❌ Passage ${i + 1} — unexpected error (${chunkErr.message}) — auto-detected dialogue instead`);
}
{ const _rec = _abRecoverDroppedText(chunks[i], segs);
if (_rec.recovered) {
segs = _rec.segs;
recoveredGaps += _rec.recovered;
view.note(`⚠️ Passage ${i + 1} — recovered ${_rec.recovered} sentence/paragraph gap${_rec.recovered !== 1 ? 's' : ''} the LLM dropped`);
} }
// Deterministic pass over what the LLM left Unknown — colon rule and
// post-quote inquit are mechanical enough to apply in code (with the
// previous chunk's tail as lookback context across the boundary).
audiobookResolveUnknowns(segs, allSegments.slice(-2), roster);
// harvest speaker names (from LLM or tag heuristic) into the running roster
segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); });
segs.forEach(s => { s.page = _curPageNum; allSegments.push(s); });
view.addSegments(segs);
completedChunks = i + 1;
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
}
view.update(_audiobook.cancel ? completedChunks : chunks.length);
} catch (err) {
if (err.name === 'AbortError') {
_audiobook.cancel = true;
view.note('Casting stopped. Completed passages were preserved.');
} else {
// Surface unexpected errors instead of throwing an uncaught rejection —
// that used to strand the panel mid-cast with no way to recover short of
// reloading the whole app. Treat it like a stop: keep whatever completed
// and let the normal "stopped early" / recast flow take over below.
_audiobook.cancel = true;
view.note(`❌ Casting stopped — unexpected error: ${err.message}`);
}
} finally {
_abStopDraftAutosave();
_audiobook.running = false;
_audiobook.abort = null;
_abHideCastingControl();
}
if (!allSegments.length) {
if (_audiobook.cancel) {
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
toast('Casting stopped before any passages were saved.', 'info');
return;
}
view.done();
toast('No segments produced', 'error');
return;
}
// Everything below only ever ran when nothing above threw, but wasn't
// itself guarded — a failure here (e.g. in the quote-fix/merge passes or
// saving the draft) used to leave the panel stuck showing "Stop Casting"
// forever, with no error and the LLM work already genuinely finished
// (confirmed live: GPU load back to idle, button never resets). Wrapping
// it means a failure here still reaches a terminal view.done() instead of
// silently hanging.
try {
// allSegments is declared const further up — replace its contents in
// place rather than rebinding, since it's captured by closures above.
// Fix stray quote-mark boundaries BEFORE merging same-speaker segments,
// so a narration row that's about to be merged away doesn't carry a
// misplaced guillemet into its neighbour first.
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
const { segments: _rejoined, merged: _mergedCount } = _audiobookMergeSplitSentences(_quoteFixedSegs);
if (_mergedCount) console.info('[cast] rejoined', _mergedCount, 'sentence(s) split by quoted terms');
// Preserve dialogue boundaries during the initial cast. Recast/verify
// uses the narrower dialogue-only merge; initial casting should retain
// each returned turn so segmentation errors remain visible and fixable.
const _mergedSegs = _audiobookMergeAdjacentNarration(_rejoined);
allSegments.length = 0;
allSegments.push(..._mergedSegs);
{ const _ph = _audiobookDropPhantomSpeakers(allSegments);
if (_ph.dropped) console.info('[cast] dropped', _ph.dropped, 'phantom speaker line(s):', _ph.names.join(', ')); }
// Role/epithet labels -> proper names FIRST, so the subset consolidation
// below then folds any remaining name variants together ("Sharraz" into
// "Sharraz Garthai") on the already-resolved labels.
{ const _role = _audiobookResolveRoleAliases(allSegments, text);
if (_role.moved) {
console.info('[cast] resolved role labels to names:', _role.pairs.join(', '), `(${_role.moved} lines)`);
try { toast(`Resolved ${_role.merged} role label${_role.merged !== 1 ? 's' : ''} to character names (${_role.moved} lines)`, 'success'); } catch (_) {}
} }
{ const _ident = _audiobookConsolidateSpeakerAliases(allSegments);
if (_ident.merged) {
console.info('[cast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');
try { toast(`Merged ${_ident.merged} duplicate character label${_ident.merged !== 1 ? 's' : ''} (${_ident.moved} lines)`, 'success'); } catch (_) {}
} }
// Confidence last — see the note at the recast site: any pass above can
// change a speaker, so scoring earlier would leave stale colours behind.
_abStampConfidence(allSegments);
_audiobook.segments = allSegments;
_audiobook.lastText = text;
_audiobook.roster = roster;
_audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded;
_audiobook.recoveredGaps = recoveredGaps;
_audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length;
_audiobook.completedTotal = chunks.length;
_abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length);
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
// A fresh cast usually leaves a tail of Unknown speakers and some spoken
// lines typed as narration. Rather than leaving the user to work out which
// of the six passes to run and in what order, chain the repair sequence
// automatically — but only when the cast actually finished and the count is
// genuinely high, and never after a stopped run (the partial state is not a
// fair basis for it). Switch it off via localStorage 'ab-auto-repair-enabled'.
if (!stoppedEarly && !_audiobook.cancel && audiobookAutoRepairEnabled()) {
const unknownNow = allSegments.filter(s =>
s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
if (unknownNow > 10) {
setTimeout(() => { audiobookAutoRepairCast().catch(e => console.warn('[auto-repair]', e)); }, 800);
}
}
} catch (e) {
console.error('[audiobookCast] post-processing failed', e);
view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });
toast('Casting finished but cleanup failed: ' + e.message, 'error');
}
}
root._abBusyGuard = _abBusyGuard;
root.audiobookRecastUnknown = audiobookRecastUnknown;
root.audiobookAutoRepairEnabled = audiobookAutoRepairEnabled;
root.audiobookAutoRepairCast = audiobookAutoRepairCast;
root.audiobookRecastUntilThreshold = audiobookRecastUntilThreshold;
root.audiobookOpenCastView = audiobookOpenCastView;
root.audiobookCast = audiobookCast;
root.TTVC=root.TTVC||{};
root.TTVC.audiobook=root.TTVC.audiobook||{};
Object.assign(root.TTVC.audiobook,{
startCast:audiobookCast,
openCastView:audiobookOpenCastView,
recastUnknown:audiobookRecastUnknown,
pipeline:{
recastUnknown:audiobookRecastUnknown,
cast:audiobookCast,
openCastView:audiobookOpenCastView,
autoRepair:audiobookAutoRepairCast,
},
});
})(window);