diff --git a/CHANGELOG.md b/CHANGELOG.md index daaee0a..6315f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Audiobook casting-pipeline extraction.** LLM casting, unknown-speaker recast, automatic repair, and cast-view + orchestration now live in `audiobook-pipeline.js`. - **Rehearser I/O extraction.** Session serialization, library save/load/autosave, REH/Fountain/FDX/OSF export, and PDF/session import now live in `rehearser-io.js`. - **Rehearser playback extraction.** WebAudio playback, transport controls, bounded decoded-audio prefetch, persistent diff --git a/TODO.md b/TODO.md index df705a5..1eb3797 100644 --- a/TODO.md +++ b/TODO.md @@ -122,7 +122,7 @@ architecture change: the larger Audiobook/Rehearser/style split. - [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training, voice design, I/O, library, and stage blocks are now isolated, with import/event wiring and remaining Audiobook orchestration - still in the monolith. + still in the monolith; Audiobook LLM orchestration is now isolated from the Reader/export UI. - [x] Extract Rehearser stage display and cast-sidebar controls into `rehearser-stage.js`. - [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`; a compatibility namespace bridge now exposes the existing Audiobook, Rehearser, and job APIs while responsibility-by-responsibility migration continues. diff --git a/VERSION b/VERSION index 9be6d61..e7a3fa9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.51 +1.24.52 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 34b4763..55cc557 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,6 +41,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. - ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`. - ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, deterministic speaker/sentence/duplicate/alias normalization helpers, and the live cast-view renderer. +- ✅ `audiobook-pipeline.js` — LLM casting, unknown-speaker recast, automatic repair, and cast-view orchestration. - ✅ `static/css/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles. - ✅ `static/css/background-jobs.css` — isolated background-job history and retry-panel styles. - ✅ `static/css/voice-design.css` — isolated Voice Design form, samples, and preview-match styles. diff --git a/static/index.html b/static/index.html index 3782835..c268e86 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook-pipeline.js b/static/js/audiobook-pipeline.js new file mode 100644 index 0000000..1793844 --- /dev/null +++ b/static/js/audiobook-pipeline.js @@ -0,0 +1,1073 @@ +// Audiobook LLM casting and recast orchestration. +// Loaded after cast helpers and before audiobook.js; dependencies resolve at call time. + +// 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'); + const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_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'); + } +} + + +window.TTVC=window.TTVC||{}; +window.TTVC.audiobook=window.TTVC.audiobook||{}; +Object.assign(window.TTVC.audiobook,{ + pipeline:{ + recastUnknown:audiobookRecastUnknown, + cast:audiobookCast, + openCastView:audiobookOpenCastView, + autoRepair:audiobookAutoRepairCast, + }, +}); + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index fa11de2..f314b9c 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1570,1064 +1570,6 @@ function _abScrollIntoView(el, opts) { } -// 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'); - const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_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'); - } -} - // ── Editable attribution preview ───────────────────────────────────────────── async function audiobookOpenCurrentInRehearser() { diff --git a/static/module-manifest.json b/static/module-manifest.json index 3022999..0db7ad7 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -5,8 +5,8 @@ ], "lazy": { "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } } diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js index 037473c..971b1ad 100644 --- a/tests/lazy-loading.spec.js +++ b/tests/lazy-loading.spec.js @@ -34,7 +34,8 @@ test('Audiobook lazy modules publish their namespace APIs after loading', async castView: typeof window.TTVC?.audiobook?.cast?.castView, stream: typeof window.TTVC?.audiobook?.attributeStream, startCast: typeof window.TTVC?.audiobook?.startCast, - }))).toEqual({ castView: 'function', stream: 'function', startCast: 'function' }); + pipelineCast: typeof window.TTVC?.audiobook?.pipeline?.cast, + }))).toEqual({ castView: 'function', stream: 'function', startCast: 'function', pipelineCast: 'function' }); }); test('Rehearser playback module publishes transport APIs after loading', async ({ page }) => {