1097 lines
61 KiB
JavaScript
1097 lines
61 KiB
JavaScript
// 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;
|
||
// A span-mode cast keeps its text exactly; every repair/verify entry point re-asks only who speaks.
|
||
if (typeof root.audiobookVerifySpans === 'function' && root.audiobookSpanModeEnabled()
|
||
&& await root.audiobookVerifySpans(overrideUrl, overrideModel, { unknownOnly: !(options.includeNarrator || options.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 });
|
||
// 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;
|
||
_audiobook.castMode = draft.mode || 'classic'; // window indices only mean something to the engine that made them
|
||
// 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 (_) { logErr('draft-server-load', _); }
|
||
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;
|
||
// Span mode (deterministic quote spans, LLM only names the speakers) is the
|
||
// default; a resume must stay on the engine that produced the draft.
|
||
if (typeof root.audiobookCastSpans === 'function' && root.audiobookSpanModeEnabled()) {
|
||
const canResume = !resume || _audiobook.castMode === 'spans';
|
||
if (canResume && await root.audiobookCastSpans(overrideUrl, overrideModel, resume)) return;
|
||
}
|
||
_audiobook.castMode = 'classic';
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
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);
|