// ── Book → multi-speaker audiobook ────────────────────────────────────────── // // Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the // document (in the current scope — selection / page range / whole book), // attributes every segment to a speaker ("Narrator" or a character) with an // emotion, then hands the result to the Script Rehearser as a cast-able script // so each character gets its own voice. The rehearser is the editable preview: // you fix any mis-attribution, cast voices, and synthesise there. // // Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices() // (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl // (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js). const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call const AUDIOBOOK_WARMUP_TIMEOUT_MS = 240000; // A chunk's max_tokens (routes/conversation.py) can reach ~4000 on slow local models // (~15 tok/s on modest hardware), i.e. up to ~4.5 minutes of pure generation time — // and large (100B+) models can take even longer just to produce a first token. // 600s is also the server-side cap (_request_timeout_seconds in routes/conversation.py). const AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS = 600000; const AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS = 300000; const AUDIOBOOK_RECAST_TIMEOUT_MS = 240000; const AUDIOBOOK_RECAST_CONTEXT_CHARS = 4200; const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6; const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000; const _audiobook = { running: false, cancel: false }; // Studio relocates this control out of the audiobook panel. That means a // normal panel rebuild can leave an old copy visible even after the async cast // has finished or failed. Always clear every live copy when the worker state // reaches a terminal state. function _abHideCastingControl() { document.querySelectorAll('#ab-cv-cancel').forEach(btn => { btn.hidden = true; btn.disabled = false; btn.innerHTML = ' Cancel'; }); } // The casting footer is physically moved into Studio and is also rebuilt by // the live view. A listener attached only to the original panel can therefore // disappear with a rebuilt footer. Keep one delegated listener on document so // Stop Casting always reaches the current button. if (!window._abDelegatedCancelHandler) { window._abDelegatedCancelHandler = true; document.addEventListener('click', event => { const btn = event.target.closest?.('#ab-cv-cancel'); if (!btn || btn.hidden) return; if (_audiobook.running) { _audiobook.cancel = true; btn.disabled = true; btn.innerHTML = ' Stopping...'; if (typeof _audiobook.abort === 'function') _audiobook.abort(); } else { _abHideCastingControl(); } }); } window._audiobook = _audiobook; let _abDraftAutosaveTimer = null; let _abLastServerDraftErrorAt = 0; function audiobookDialogueKey(text) { return String(text || '') .normalize('NFKC') .replace(/[»«„“”"‘’'`]+/g, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function audiobookSegmentText(seg) { if (!seg) return ''; if (seg.type === 'narration' || !seg.speaker || /^Unknown|Unbekannt/i.test(seg.speaker)) return seg.text || ''; return `"${seg.text || ''}"`; } // Word-under-cursor for the casting feed WITHOUT wrapping every word in its // own — an earlier version did exactly that, and at book scale // (1500+ segments × dozens of words) the ~100k extra DOM nodes with hover // styles froze the page on every feed redraw. Instead this asks the browser // which text position sits under the pointer (caretRangeFromPoint / // caretPositionFromPoint — both native and cheap), then expands to word // boundaries. Returns { word, range } or null; the Range provides the rect // for the hover highlight overlay and popup anchoring. function _abWordRangeAtPoint(x, y) { let node = null, offset = 0; if (document.caretRangeFromPoint) { const r = document.caretRangeFromPoint(x, y); if (!r) return null; node = r.startContainer; offset = r.startOffset; } else if (document.caretPositionFromPoint) { const p = document.caretPositionFromPoint(x, y); if (!p) return null; node = p.offsetNode; offset = p.offset; } else return null; if (!node || node.nodeType !== 3) return null; const text = node.nodeValue || ''; const isW = ch => ch != null && /[\p{L}\p{N}'’-]/u.test(ch); if (offset >= text.length) offset = text.length - 1; if (!isW(text[offset])) { if (offset > 0 && isW(text[offset - 1])) offset--; else return null; } let a = offset, b = offset; while (a > 0 && isW(text[a - 1])) a--; while (b + 1 < text.length && isW(text[b + 1])) b++; const word = text.slice(a, b + 1).trim(); if (!word || word.length < 2) return null; const range = document.createRange(); range.setStart(node, a); range.setEnd(node, b + 1); return { word, range }; } function audiobookJoinSegmentText(a, b) { const left = String(a || ''); const right = String(b || ''); if (!left) return right; if (!right) return left; if (/\s$/.test(left) || /^\s/.test(right)) return left + right; if (/^[,.;:!?»«”"')\]]/.test(right)) return left + right; if (/[([{„“"']$/.test(left)) return left + right; return left + ' ' + right; } // ── Mislabeled-dialogue detector ──────────────────────────────────────────── // Spoken lines routinely come out of PDF extraction typed as `narration` — // usually because the » « quote marks were lost. Those segments are invisible // to "Identify unknown characters" (it only scans type === 'dialogue'), so no // number of re-runs can ever fix them, and the only pass that could ("Verify // all characters") re-sends every narration segment to the LLM: 1036 of them // on a single novel, which is slow, GPU-hungry and risks disturbing lines that // were already right. This scores narration segments for direct-speech // signals instead, so only the genuinely suspicious ones get spent on an LLM // call — measured on a real book: 36 candidates instead of 1036 (29x fewer), // with the true positives ranked at the top. const _AB_INQUIT_VERBS = '(?:sagte|rief|fragte|antwortete|erwiderte|murmelte|fl[üu]sterte|br[üu]llte|entgegnete|meinte|knurrte|zischte|stammelte|schrie|dr[äa]ngte|herrschte|seufzte|wisperte|grunzte|befahl|erkl[äa]rte|fuhr fort|setzte hinzu|f[üu]gte hinzu)'; function _abSuspectNarration(segs, minScore) { // Threshold 2, not 3: measured against the hand-corrected book, 3 catches // 28 of the 51 real "dialogue swallowed into narration" cases while 2 // catches 37, for only ~7 extra LLM calls. A false flag merely costs one // call (the verification prompt still judges the line on its merits and // usually leaves it alone), so erring generous is the cheap direction. minScore = minScore || 2; const norm = i => String(segs[i]?.text || '').replace(/\s+/g, ' ').trim(); const out = new Set(); for (let i = 0; i < segs.length; i++) { if (segs[i]?.type !== 'narration') continue; const t = norm(i), nxt = i + 1 < segs.length ? norm(i + 1) : ''; let score = 0; // Strongest tell: the NEXT segment is a bare inquit ", drängte Marcian // ungeduldig." — which means THIS segment was the quote it belongs to. if (new RegExp('^[\\s,»«"\'\\-–]{0,4}(?:\\w+\\s+){0,2}' + _AB_INQUIT_VERBS + '\\b', 'i').test(nxt)) score += 5; if (t.includes('»') || t.includes('«')) score += 4; // typed narration yet carries quote marks // Count, don't just test: narration is 3rd-person past, so ONE stray "ich" // can be free-indirect thought, but a passage sustaining first/second // person is a speech that lost its quote marks. Long speeches used to // score only 1 here (the <160-char bonus below can't apply to them), so // they fell under any useful threshold — that was 24 of the 51 missed // "dialogue swallowed into narration" cases in the measured book. const p12n = (t.match(/\b(ich|du|wir|ihr|mich|dich|uns|euch|mein|dein|unser|euer)\b/gi) || []).length; const p12 = p12n >= 1; const excl = /[!?]\s*$/.test(t); // Allow a leading discourse particle before the imperative — "Nun schafft // diese Knochen fort" is every bit as much a command as "Schafft ...". const imper = /^(?:(?:Nun|Jetzt|Dann|Also|Und|Aber|So|Doch|Hier|Da|Los)\s+)?(Bringt|Los|Mach|Macht|Kommt|Komm|Geht|Geh|Nehmt|Nimm|Haltet|Halt|Schafft|Schaff|T[öo]tet|Sieh|Seht|H[öo]rt|H[öo]r|Wartet|Warte|La[sß]|La[sß]t|Weist|Zeigt|Sagt|Sag|Gebt|Gib)\b/.test(t); if (p12) score += 1; if (p12n >= 3) score += 2; if (excl) score += 1; if (imper) score += 3; if (p12 && excl) score += 1; if (t.length < 160 && (p12 || excl || imper)) score += 1; if (score >= minScore) out.add(i); } return out; } window._abSuspectNarration = _abSuspectNarration; function audiobookRecastGroups(unknownIdxs, segs) { const groups = []; let cur = []; for (const idx of unknownIdxs) { const prev = cur.length ? cur[cur.length - 1] : -Infinity; const shortGap = idx <= prev + 2 && segs.slice(prev + 1, idx).every(s => !s || s.type === 'narration' || /^Unknown|Unbekannt/i.test(s.speaker || '')); if (!cur.length || (shortGap && cur.length < AUDIOBOOK_RECAST_TARGETS_PER_CALL)) cur.push(idx); else { groups.push(cur); cur = [idx]; } } if (cur.length) groups.push(cur); return groups; } function audiobookRecastContext(segs, group) { const targetStart = group[0]; const targetEnd = group[group.length - 1] + 1; let start = Math.max(0, targetStart - 5); let end = Math.min(segs.length, targetEnd + 4); const render = () => segs.slice(start, end).map(audiobookSegmentText).join(' ').trim(); let text = render(); while (text.length > AUDIOBOOK_RECAST_CONTEXT_CHARS && (start < targetStart || end > targetEnd)) { if (end > targetEnd) end--; text = render(); if (text.length <= AUDIOBOOK_RECAST_CONTEXT_CHARS) break; if (start < targetStart) start++; text = render(); } const recent = audiobookRecastRecent(segs, start, end); return { text, start, end, recent }; } function audiobookRecastRecent(segs, start, end) { const lines = []; for (let i = Math.max(0, start - 10); i < Math.min(segs.length, end + 8); i++) { const s = segs[i]; if (!s || s.type !== 'dialogue' || !s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)) continue; lines.push(`${i < start ? 'before' : i >= end ? 'after' : 'near'}: ${s.speaker}: ${(s.text || '').slice(0, 100)}`); } return lines.slice(-12).join('\n'); } function audiobookFindReturnedSegment(targetSeg, returned, used) { const targetKey = audiobookDialogueKey(targetSeg?.text); if (!targetKey) return null; let loose = null; for (let i = 0; i < returned.length; i++) { if (used.has(i)) continue; const cand = returned[i]; if (!cand) continue; const candKey = audiobookDialogueKey(cand.text); if (!candKey) continue; if (candKey === targetKey) return { idx: i, seg: cand }; const minLen = Math.min(candKey.length, targetKey.length); if (minLen >= 18 && (candKey.includes(targetKey) || targetKey.includes(candKey))) { loose = loose || { idx: i, seg: cand }; } } return loose; } function audiobookFindReturnedSegmentSequence(targetSeg, returned, used) { const targetKey = audiobookDialogueKey(targetSeg?.text); if (!targetKey) return null; for (let i = 0; i < returned.length; i++) { if (used.has(i) || !returned[i]) continue; let joined = ''; const idxs = []; for (let j = i; j < returned.length; j++) { if (used.has(j) || !returned[j]) break; joined = audiobookJoinSegmentText(joined, returned[j].text || ''); idxs.push(j); const key = audiobookDialogueKey(joined); if (key === targetKey) return { idxs, segs: idxs.map(k => returned[k]) }; if (key.length > targetKey.length * 1.35 + 40) break; } } return null; } // String coercer (mirrors library.js _libStr) function _abStr(v) { if (v == null) return ''; if (typeof v === 'string') return v; if (Array.isArray(v)) return v.filter(Boolean).join(', '); return String(v); } // ── Autosave / draft recovery ───────────────────────────────────────────────── // Saves the accumulated segments to localStorage after every chunk so a page // refresh or browser crash doesn't lose hours of casting work. const _AB_DRAFT_KEY = 'ttsvc_ab_draft'; // The book this casting session belongs to. When a saved library book is open, // the draft is keyed by its id so reopening the SAME book always restores its // cast — independent of any drift in the extracted text fingerprint (the old // text-only key silently lost the cast whenever PDF re-extraction differed even // slightly). Falls back to the global key for unsaved/ad-hoc documents. function _abBookId() { return (window.readerState && readerState.savedId) || _audiobook.bookId || null; } function _abDraftKey(bookId) { return bookId ? (_AB_DRAFT_KEY + '_' + bookId) : _AB_DRAFT_KEY; } function _abTextId(text) { // Cheap fingerprint — no need for a full hash; length + first/last chars is enough // to detect doc changes without storing the source text itself. const s = (text.slice(0, 300) + text.slice(-300)).replace(/\s+/g, ''); let h = 5381; for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; return h.toString(36) + '_' + text.length; } function _abSafeFilename(name, fallback = 'cast') { const s = String(name || fallback || 'cast') .replace(/[\\/:*?"<>|]+/g, '_') .replace(/\s+/g, '_') .replace(/^_+|_+$/g, ''); return (s || fallback || 'cast').slice(0, 120); } // Called after essentially every ~80-segment chunk during a full-book cast // run — with the ENTIRE accumulated segments array re-stringified, written // to localStorage, AND sent over the network EVERY time. Both the string // size and the work involved grow with total segments cast so far, so this // alone is an O(n) cost repeated dozens of times over a long book — an O(n²) // total, confirmed live as a real contributor (alongside the feed's own // per-batch full-DOM query) to a tab crash partway through a 235-page book. // Always save on the FINAL call for a given run (so nothing real is lost on // completion) but throttle the constant stream of intermediate autosaves — // losing a few seconds of draft progress on an actual crash is a clear // trade against causing that crash in the first place. let _abLastDraftSaveAt = 0; const AB_DRAFT_SAVE_THROTTLE_MS = 4000; function _abSaveDraft(segs, roster, text, done, total) { const isFinal = total > 0 && done >= total; const now = Date.now(); if (!isFinal && now - _abLastDraftSaveAt < AB_DRAFT_SAVE_THROTTLE_MS) return; _abLastDraftSaveAt = now; const bookId = _abBookId(); const payload = { bookId: bookId, title: window.readerState?.title || '', textId: _abTextId(text), segments: segs, roster: roster, pageMarks: _audiobook.pageMarks || [], rehId: _audiobook.rehId || null, done: done, total: total, savedAt: Date.now() }; try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(payload)); } catch (_) {} // Mirror to server when a library book is open (fire-and-forget) if (bookId) { fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => { if (!r.ok) throw new Error(r.statusText || `HTTP ${r.status}`); }).catch(err => { console.warn('[audiobook] server draft autosave failed:', err); const now = Date.now(); if (now - _abLastServerDraftErrorAt > 60000) { _abLastServerDraftErrorAt = now; if (typeof toast === 'function') toast('Server autosave failed. Browser draft is still saved.', 'error'); } }); } } let _abExportBusy = false; async function audiobookExportCastMd() { // Re-entry guard: while the page is busy, a user's queued-up clicks can all // fire at once when the main thread unblocks — without this, that meant a // burst of identical downloads and one save-dialog per copy to cancel. if (_abExportBusy) return; _abExportBusy = true; setTimeout(() => { _abExportBusy = false; }, 2000); const segs = _audiobook.segments || []; if (!segs.length) { toast('No cast to export', 'error'); return; } const bookId = _abBookId(); if (_audiobook.lastText) { _abSaveDraft(segs, _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0); } const title = window.readerState?.title || _audiobook.title || 'audiobook'; if (bookId) { try { const r = await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast/export`); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); const blob = await r.blob(); const disp = r.headers.get('Content-Disposition') || ''; const match = disp.match(/filename="([^"]+)"/i); const name = match?.[1] || `${_abSafeFilename(title)}_cast.zip`; if (typeof readerDownload === 'function') readerDownload(blob, name); else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 500); } toast('Exported cast + character sheets (.zip)', 'success'); return; } catch (err) { toast('Server export failed, downloading local cast copy', 'error'); } } const payload = { bookId, title, textId: _audiobook.lastText ? _abTextId(_audiobook.lastText) : '', segments: segs, roster: _audiobook.roster || [], pageMarks: _audiobook.pageMarks || [], rehId: _audiobook.rehId || null, done: _audiobook.completedChunks || 0, total: _audiobook.completedTotal || 0, savedAt: Date.now(), }; const blob = new Blob([_abCastMarkdown(payload)], { type: 'text/markdown;charset=utf-8' }); if (typeof readerDownload === 'function') readerDownload(blob, `${_abSafeFilename(title)}_cast.md`); else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${_abSafeFilename(title)}_cast.md`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 500); } } function _abStartDraftAutosave(saveNow) { _abStopDraftAutosave(); if (typeof saveNow !== 'function') return; _abDraftAutosaveTimer = setInterval(saveNow, AUDIOBOOK_DRAFT_AUTOSAVE_MS); const saveOnLeave = () => saveNow(); _audiobook._draftSaveOnLeave = saveOnLeave; window.addEventListener('pagehide', saveOnLeave); window.addEventListener('beforeunload', saveOnLeave); } function _abStopDraftAutosave() { if (_abDraftAutosaveTimer) clearInterval(_abDraftAutosaveTimer); _abDraftAutosaveTimer = null; if (_audiobook._draftSaveOnLeave) { window.removeEventListener('pagehide', _audiobook._draftSaveOnLeave); window.removeEventListener('beforeunload', _audiobook._draftSaveOnLeave); _audiobook._draftSaveOnLeave = null; } } async function _abEnsureLibraryBook() { const rs = window.readerState; if (!rs || rs.savedId || !rs.sentences?.length) return !!rs?.savedId; if (rs.mode === 'pdf' && !rs.fileBlob) return false; const meta = { title: rs.title || 'Untitled', kind: rs.mode, idx: rs.idx, voice: $('reader-voice-select')?.value || '', backend: $('reader-backend-select')?.value || '', speed: rs.speed, instruct: $('reader-instruct')?.value.trim() || '', chunkMode: rs.chunkMode, seed: $('reader-seed')?.value.trim() || '', temperature: $('reader-temp')?.value.trim() || '', tts_speed: parseFloat($('reader-tts-speed')?.value) || 1, normalize: rs.normalize, sentenceCount: rs.sentences.length, pageCount: rs.pages?.length || 0, synthCount: 0, updated: new Date().toISOString(), }; try { const r = await fetch('/api/reader/docs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta), }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); rs.savedId = (await r.json()).id; _audiobook.bookId = rs.savedId; const ext = rs.mode === 'pdf' ? 'pdf' : 'txt'; const body = rs.mode === 'pdf' ? rs.fileBlob : new Blob([rs.docText || audiobookScopeText() || ''], { type: 'text/plain' }); const sr = await fetch(`/api/reader/docs/${encodeURIComponent(rs.savedId)}/source?ext=${ext}`, { method: 'PUT', body }); if (sr.ok) rs.sourceUploaded = true; if (typeof readerRenderLibrary === 'function') readerRenderLibrary(); return true; } catch (err) { console.warn('[audiobook] could not create reader-library autosave target:', err); return false; } } async function _abLoadDraftServer(bookId) { if (!bookId) return null; for (let attempt = 0; attempt < 2; attempt++) { try { const r = await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`); if (!r.ok) { if (attempt === 0 && (r.status === 404 || r.status >= 500)) { await new Promise(resolve => setTimeout(resolve, 650)); continue; } return null; } const d = await r.json(); if (d && Array.isArray(d.segments) && d.segments.length) return d; return null; } catch (_) { if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 650)); } } return null; } function _abLoadDraft(text) { const bookId = _abBookId(); const textId = _abTextId(text); const title = String(window.readerState?.title || '').trim().toLowerCase(); try { // 1. Per-book localStorage — fastest, no network if (bookId) { const raw = localStorage.getItem(_abDraftKey(bookId)); if (raw) { const d = JSON.parse(raw); if (d && Array.isArray(d.segments) && d.segments.length) return d; } } // 2. Global draft (ad-hoc docs) — must match the text fingerprint const raw = localStorage.getItem(_AB_DRAFT_KEY); if (raw) { const d = JSON.parse(raw); if (d && Array.isArray(d.segments) && d.segments.length && d.textId === textId) return d; } // 3. Recovery fallback: the same saved book can receive a new id after an // import/save cycle. Scan older per-book drafts with the same title so a good // cast does not appear lost just because the storage key changed. let best = null; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith(_AB_DRAFT_KEY + '_')) continue; if (bookId && key === _abDraftKey(bookId)) continue; try { const cand = JSON.parse(localStorage.getItem(key) || 'null'); if (!cand || !Array.isArray(cand.segments) || !cand.segments.length) continue; const candTitle = String(cand.title || '').trim().toLowerCase(); const exactText = cand.textId && cand.textId === textId; const sameTitle = title && candTitle && candTitle === title; if (!exactText && !sameTitle) continue; const score = (exactText ? 1000000 : 0) + (sameTitle ? 100000 : 0) + cand.segments.length; if (!best || score > best.score) best = { score, draft: cand }; } catch (_) {} } if (best?.draft) return best.draft; } catch (_) { return null; } return null; } function _abClearDraft() { const bookId = _abBookId(); try { localStorage.removeItem(_abDraftKey(bookId)); } catch (_) {} if (bookId) { fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, { method: 'DELETE' }).catch(() => {}); } } // Opening/closing quote glyphs across book conventions: English "..."/“...”, // German »...«/„...“, French «»/curly single '...'/‘...’, CJK 「...」『...』, // em-dash speech. Single guillemets ›...‹ are deliberately excluded — German // prose uses ›...‹ as a narrator scare-quote/emphasis mark WITHIN narration // (e.g. ein Zelt — ›Zelt‹ war eine schmeichelhafte Bezeichnung...), not as a // dialogue delimiter; treating it as one split scare-quoted words out as // speakerless "dialogue" fragments (confirmed live: "Zelt", "Skipperedikt", // "Seulaslintan" etc.). // Deterministic Unknown-resolution — the LLM keeps missing mechanical // patterns no matter how explicitly the prompt spells them out (measured: // ~44% Unknown on a full book WITH the rules in the prompt), so apply them // in code where they cannot be ignored: // A. Colon rule: the narration right before a quote ends with ":" → its // last-mentioned character (roster name, whitelisted person noun, or the // "Name … und sagte:" subject) speaks. // B. Post-quote inquit: the narration right after a quote starts with a // speech verb + Name, "Der X, der gesprochen hatte", or the impersonal // "ertönte es … . Name …" formula → that character spoke. // C. Two-person alternation: an Unknown whose two nearest preceding dialogue // lines have two different known speakers gets the earlier of the two // (strict turn-taking) — only when both neighbours are unambiguous. // Only fills segments still Unknown; never overrides an LLM attribution. function audiobookResolveUnknowns(segs, prevTail, roster) { const isUnknown = s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)); const isNamed = s => s?.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt|Narrator$/i.test(s.speaker); const names = (roster || []).filter(n => n && !/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a, b) => b.length - a.length); const lastNameIn = (text) => { let best = null, bestAt = -1; for (const n of names) { const at = text.lastIndexOf(n); if (at > bestAt) { bestAt = at; best = n; } } if (best) return best; // "Marcian unterdrückte seinen Ärger und sagte:" — subject of the final // speech clause, provided it isn't a sentence-opener adverb/article. const clause = text.match(new RegExp(_AB_NAME + "[^.!?:]{0,80}\\b(?:und\\s+)?(?:" + AB_SPEECH_VERBS + ")[^:]{0,60}:\\s*$")); if (clause && !AB_NOTNAME.has(clause[1]) && !AB_PERSON_NOUNS.has(clause[1])) return clause[1]; // whitelisted person nouns only — never arbitrary capitalized nouns const m = [...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)] .map(x => x[1]).filter(n => AB_PERSON_NOUNS.has(n)); return m.length ? m[m.length - 1] : null; }; const all = [...(prevTail || []), ...segs]; const offset = (prevTail || []).length; const resolved = []; for (let i = offset; i < all.length; i++) { const s = all[i]; if (!isUnknown(s)) continue; const prev = all[i - 1], next = all[i + 1]; let who = null; // Self-introduction: the line itself names its own speaker ("Man nennt // mich Andra", "Ich bin X", "Mein Name ist X", "my name is X", "call me // X") — the strongest possible signal, checked before anything context- // dependent. Matched against the actual roster, never a bare capitalized // word, so an unrelated proper noun right after the phrase can't misfire. if (!who) { const dt = String(s.text || ''); for (const n of names) { const esc = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (new RegExp('\\b(?:man\\s+nennt\\s+mich|nennt\\s+mich|ich\\s+(?:bin|hei(?:ß|ss)e)|mein\\s+name\\s+ist|my\\s+name\\s+is|i\\s+am|call\\s+me|they\\s+call\\s+me)\\s+' + esc + '\\b', 'i').test(dt)) { who = n; break; } } } if (!who && prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim())) { who = lastNameIn(String(prev.text || '')); } // "Garbaz rief von unten herauf." / "...Ork legte den Kopf in den Nacken // und schrie seinen Triumph zum Himmel." — a Name+speech-verb inquit // ending the PRECEDING narration in a plain period (not the ":" the rule // above requires) still reliably names who's about to speak, even with // extra words (object/adverbial phrases) between the verb and the // period. Matched against the actual roster (not a generic capitalized- // word regex) so multi-word names/aliases match correctly, and anchored // to end at the segment's literal end so an earlier, unrelated name // mention in a longer paragraph can't win over the one right before the // dialogue that follows it. if (!who && prev?.type === 'narration') { const pt = String(prev.text || '').trim(); for (const n of names) { const esc = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (new RegExp('\\b' + esc + '\\b[^.!?:]{0,80}\\b(?:und\\s+)?(?:' + AB_SPEECH_VERBS + ')\\b[^.!?]{0,60}[.!?]\\s*$').test(pt)) { who = n; break; } } } // Idiomatic "about to speak" narration that isn't a literal speech verb — // "X fand als erster seine Stimme wieder" (found his voice first), "X // brach das Schweigen" (broke the silence), "ihre ersten Worte waren" // (her first words were) — confirmed live as a real gap: these read as // an obvious cue to a human but the fixed AB_SPEECH_VERBS list above // only matches literal verbs of speech, so this class of narration // silently left the following line Unknown. if (!who && prev?.type === 'narration') { const pt = String(prev.text || '').trim(); for (const n of names) { const esc = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (new RegExp('\\b' + esc + '\\b[^.!?]{0,60}\\b(?:fand|found)\\b[^.!?]{0,20}\\b(?:seine|ihre|his|her)\\s+(?:Stimme|voice)\\b[^.!?]{0,40}[.!?]\\s*$', 'i').test(pt)) { who = n; break; } if (new RegExp('\\b' + esc + '\\b[^.!?]{0,60}\\bbrach\\b[^.!?]{0,20}\\b(?:das\\s+)?Schweigen\\b[^.!?]{0,40}[.!?]\\s*$', 'i').test(pt)) { who = n; break; } } // "Erst als Andra sich angekleidet hatte, ... und ihre ersten Worte // waren ..." — the pronoun refers back to whoever was last named // earlier in the SAME narration, not a name sitting next to the // idiom itself, so resolve it the same way the colon-rule does. if (!who && /\b(?:ihre|seine|his|her)\s+ersten\s+Worte\s+waren\b/i.test(pt)) who = lastNameIn(pt); if (!who) { const m2 = pt.match(new RegExp(_AB_NAME + "(?:'s)?\\s+first\\s+words\\s+were\\b", 'i')); if (m2 && !AB_NOTNAME.has(m2[1])) who = m2[1]; } } // A segment that OPENS with a lower-case speech verb is an inquit by // construction ("grunzte der Ork und hob den Dolch."), even when the caster // mistyped it as dialogue — confirmed live: the quote before such a segment // stayed Unknown purely because the inquit carried the wrong type. Accept // either type here and repair the type while we are at it. // A segment holding several turns separated by a blank line is TWO speakers // fused into one ("...ausliefern und ...\n\nEr hat es nicht so gemeint" — // Lysandra, then Perdia interrupting). A trailing inquit only names the LAST // speaker, so applying it to the whole block silently mis-credits the earlier // turn. Leaving it Unknown is the honest outcome: visibly unresolved beats // confidently wrong, and the split can then be made by hand or by the LLM pass. const _isFusedTurns = /\n\s*\n/.test(String(s.text || '').trim()); const _nextIsInquitText = !_isFusedTurns && next && new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\b').test(String(next.text || '').trim()); if (!who && !_isFusedTurns && (next?.type === 'narration' || _nextIsInquitText)) { const nt = String(next.text || '').trim(); // Split quote whose inquit uses a PRONOUN rather than a name: // "»Ja, ungewöhnlich«" + ", antwortete er knapp." The rules below all // require a literal name after the speech verb, so this whole class stayed // Unknown even though the antecedent is unambiguous — resolve it the same // way the colon rule does, to the last character named in the narration // immediately before the quote. if (new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:er|sie|es)\\b', 'i').test(nt) && prev?.type === 'narration') { const byPronoun = lastNameIn(String(prev.text || '')); if (byPronoun) who = byPronoun; } // "Darrags Stimme klang tonlos." / "Marcians Stimme war heiser." — a // genitive name attached to Stimme in the narration right AFTER a quote // names who just spoke. The existing voice-announcement rule only looked // at the narration BEFORE the line, so this mirror image stayed Unknown. if (!who) { for (const n of names) { const esc = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (new RegExp('^\\W{0,3}' + esc + "(?:s|'s)?\\s+Stimme\\b", 'i').test(nt)) { who = n; break; } } } let m = who ? null : nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:(?:der|die|das|den|dem)\\s+)?(?:[a-zäöüß]+e[nrms]?\\s+){0,2}' + _AB_NAME)); if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte')); // "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula, // the next sentence's subject is the speaker. if (!m) m = nt.match(new RegExp('^\\W{0,3}(?:ertönte|erklang|tönte|drang|kam)\\b[^.!?]{0,60}\\bes\\b[^.!?]{0,60}[.!?]\\s*' + _AB_NAME)); if (m && !AB_NOTNAME.has(m[1])) who = m[1]; } const prevEndsColon = prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim()); if (!who && !prevEndsColon) { // Two-person alternation, strictly: the two nearest preceding dialogue // lines carry two DIFFERENT known names → this line belongs to the one // who didn't just speak. Guards: never when the preceding narration // ends with ":" (that colon introduces someone the rules above already // failed to identify — alternation would be a guess, not a deduction), // never across page boundaries, and only within a short window. const prevDialogues = []; for (let j = i - 1; j >= 0 && j >= i - 6 && prevDialogues.length < 2; j--) { if (all[j]?.page != null && s.page != null && all[j].page !== s.page) break; if (all[j]?.type !== 'dialogue') continue; if (!isNamed(all[j])) { prevDialogues.length = 0; break; } // an unresolved line breaks the chain prevDialogues.push(all[j].speaker); } if (prevDialogues.length === 2 && prevDialogues[0] !== prevDialogues[1]) who = prevDialogues[1]; } if (who && _nextIsInquitText && next && next.type !== 'narration') { next.type = 'narration'; next.speaker = 'Narrator'; next.emotion = ''; } if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) { s.speaker = who; resolved.push(who); } } return resolved; } const _AB_TAG_NAME = "[A-ZÄÖÜ][A-Za-zäöüß'\\-]+"; const AB_SPEECH_TAG_ONLY_RE = new RegExp( '^\\s*[,.;:!?–-]*\\s*(?:' + '(?:' + AB_SPEECH_VERBS + ')\\b\\s+(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|' + _AB_TAG_NAME + ')' + '|' + '(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|' + _AB_TAG_NAME + ')\\s+(?:' + AB_SPEECH_VERBS + ')\\b' + ')' + '(?:\\s+(?:mit|in|leise|laut|kalt|heiser|erstickt|ruhig|zornig|wütend|ängstlich|spöttisch|verächtlich|fragend|flüsternd|schrill|dumpf|slowly|coldly|quietly|softly|angrily|hoarsely)\\b[\\s\\S]*)?' + '[.!?…]*\\s*$', 'i' ); function audiobookIsSpeechTagOnly(text) { const t = String(text || '').trim(); if (!t || t.length > 180) return false; if (/[»«„“”"‟‚‘’『「]/.test(t)) return false; return AB_SPEECH_TAG_ONLY_RE.test(t); } function audiobookGuessSpeaker(after, before) { let m; const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null; // NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs // after a quote are lowercase, so this rejects pronouns like "sagte er". // after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name) if ((m = new RegExp('^[\\s,;–-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; } // after the quote: ", Riskan sagte" (name → verb) if ((m = new RegExp('^[\\s,;–-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; } // before the quote: "Riskan sagte:" / "Riskan fragte" if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,–-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; } return null; } // Deterministic fallback: split a passage into narration + dialogue by quotation // spans and attribute speakers from the surrounding speech tags. Used when the LLM // is unavailable so dialogue — and as many speakers as possible — are never lost. // ›...‹ deliberately excluded — see AB_DIALOGUE_RE above. const AB_QUOTE_PAIRS = { '»': '«', '«': '»', '„': '“', '“': '”', '"': '"', '「': '」', '『': '』', '‘': '’', '‚': '‘' }; // Constant pattern — compiled once, not per sentence-ending character scanned. const _AB_UNCLOSED_TAG_RE = new RegExp( '^\\s*(?:[,;:–-]\\s*)?(?:' + AB_SPEECH_VERBS + '|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+' + AB_SPEECH_VERBS + ')\\b', 'i' ); function _abUnclosedQuoteEnd(raw, closeIdx) { const limit = closeIdx >= 0 ? closeIdx : raw.length; for (let i = 0; i < limit; i++) { if (!/[.!?]/.test(raw[i])) continue; const quote = raw.slice(0, i + 1).trim(); if (quote.length < 2) continue; const after = raw.slice(i + 1, Math.min(limit, i + 140)); if (_AB_UNCLOSED_TAG_RE.test(after)) return i + 1; } if (closeIdx < 0) { const m = raw.match(/^([\s\S]{2,180}?[.!?])(?:\s|$)/); if (m && raw.slice(m[1].length).trim().length > 40) return m[1].length; } return closeIdx >= 0 ? closeIdx : raw.length; } function _abOrphanClosingQuoteSpan(text, closeIdx, minStart) { if (!'«”’‹」』'.includes(text[closeIdx])) return null; const before = text.slice(minStart, closeIdx); const prev = before.match(/\S(?=\s*$)/)?.[0] || ''; if (!/[.!?]/.test(prev)) return null; const trimmedLen = before.trimEnd().length; let localStart = 0; const boundaryRe = /[\n\r]|[.!?:]\s+/g; let m; while ((m = boundaryRe.exec(before))) { const next = m.index + m[0].length; if (next < trimmedLen - 1) localStart = next; } const quote = before.slice(localStart).trim(); if (!/^[\s\S]{2,220}[.!?]$/.test(quote)) return null; return { start: minStart + localStart, end: closeIdx + 1, quote }; } // German typography opens a quote with » and closes it with « (the reverse // of French) — this app's own casting prompt already spells that out // ("»Nein«, sagte sie, »halt.«"). A dialogue turn's CLOSING « sometimes // lands on the FRONT of the narration segment right after it instead of at // the end of the dialogue turn itself (an off-by-one boundary slip in the // LLM's own segmentation, not a policy question — the prompt already says // to keep both marks). Symmetric case: an OPENING » can land on the END of // a narration segment instead of the front of the dialogue turn that // follows. Worst case, the whole narration "segment" is nothing but the // stray mark on its own — an empty-looking NARRATOR row with nothing to // read. Fixed mechanically instead of relying on the LLM to never mis-slice: // move the mark to where it actually belongs, or leave it alone if there's // no sensible home for it (safer than deleting a mark that might be // legitimate narration content, e.g. narration quoting a title or saying). const AB_QUOTE_OPENERS = '»„‚›'; const AB_QUOTE_CLOSERS = '«"\'‹'; function _audiobookFixOrphanedQuoteMarks(segments) { const isQuoteChar = ch => ch && (AB_QUOTE_OPENERS + AB_QUOTE_CLOSERS).includes(ch); for (let i = 0; i < segments.length; i++) { const s = segments[i]; if (s.type !== 'narration') continue; const trimmed = s.text.trim(); if (!trimmed) continue; // A CLOSING mark stuck on the FRONT of narration belongs at the END of // the dialogue turn right before it. const lead = trimmed[0]; if (AB_QUOTE_CLOSERS.includes(lead)) { const prev = segments[i - 1]; if (prev && prev.type === 'dialogue' && !isQuoteChar(prev.text.trim().slice(-1))) { prev.text = prev.text.trimEnd() + lead; s.text = trimmed.slice(1).trimStart(); } continue; } // An OPENING mark stuck on the END of narration belongs at the START of // the dialogue turn right after it (mirror image of the case above). const tail = trimmed[trimmed.length - 1]; if (AB_QUOTE_OPENERS.includes(tail)) { const next = segments[i + 1]; if (next && next.type === 'dialogue' && !isQuoteChar(next.text.trim()[0])) { next.text = tail + next.text.trimStart(); s.text = trimmed.slice(0, -1).trimEnd(); } } } return segments.filter(s => s.text.trim()); } // Removes a segment whose text is a near-duplicate of another segment within // a nearby window — confirmed live: "Verify all characters" (audiobookRecastUnknown // with includeNarrator:true) can re-splice a passage's already-correct text back // into the array a second time when its context-window overlaps the neighbouring // recast group (each group pads ±5/+4 segments of surrounding context so the LLM // has continuity, and adjacent groups' windows can overlap by that much). The // combined segment text is supposed to reconstruct the source exactly once — the // source book only has each sentence once — so any repeat within a realistic // overlap range is always wrong, regardless of which internal step produced it. // Normalizes away stray leading/trailing quote marks before comparing, since the // duplicate copy often carries one of those artifacts and an exact string // comparison would miss it. // German uses » « for quoted TERMS and titles as well as for speech — "Himgi war // der Überzeugung, daß es sich dabei um »den gewundenen Weg der Krieger und // Häuptlinge« handelte, von dem die Inschriften berichteten." The casting model // reads those marks as a dialogue boundary and cuts the sentence into three // pieces: a narration fragment ending mid-clause, the quoted phrase alone, and a // fragment resuming in lower case. Nothing there is speech, so no attribution // pass can repair it — the sentence simply has to be put back together. Only // merges NARRATION neighbours, and only when the break is grammatically // impossible (no sentence-ending punctuation before, lower-case continuation // after), so a genuine narration→dialogue→narration sequence is never touched. // ── Speaker identity consolidation ────────────────────────────────────────── // The caster refers to one character by several labels across a book — a bare // name and a full one ("Sharraz" / "Sharraz Garthai"), a title on its own // ("Baronin" / "Baronin Ira von Seewiesen"), a stray fragment ("Von" / // "Oberst Alrik von Blautann"). Each variant becomes its own cast entry and so // gets its OWN VOICE, which is far more damaging in the finished audiobook than // an unresolved line: the same person audibly changes voice mid-scene. // Measured against a hand-corrected book, label fragmentation accounted for a // large share of the disagreements — more than genuine misattributions. // A variant is only folded into another when its words are a strict SUBSET of // the other's after stripping articles and titles, so "Weber" and "Alter Weber" // (equal after stripping) stay separate, and two unrelated names never merge. // Speaker labels that are really fragments of a German noun phrase, not people: // "in seinem Inneren" -> Inneren, "die Hand" -> Hand, "mein Geliebter" -> // Geliebten, "der Wind" -> Wind. The caster emits these as characters, each then // gets a cast entry and its own voice, and the real speaker's line is lost to a // phantom — measured against a hand-corrected book, they were a large part of // why a fresh cast produced 73 speakers where the human had 62. A line credited // to one of these is better left Unknown: visibly unresolved, and still // recoverable by the repair passes, instead of silently wrong. // Grammatical words the caster mistakes for names because they open the sentence // that introduces a quote: "Hinter ihm raunte Oberst von Blautann:" -> Hinter, // "Keiner sagte mehr etwas." -> Keiner, a dangling "Von" from "von Blautann". // Confirmed by a reader hand-correcting a whole book: every one of these was // deleted as a bogus character. window._audiobookDropPhantomSpeakers = _audiobookDropPhantomSpeakers; window._audiobookConsolidateSpeakerAliases = _audiobookConsolidateSpeakerAliases; // A bare one-word "dialogue" piece with no sentence-ending punctuation is // essentially never a real spoken turn ("Nein!", "Halt." always carry // punctuation) — it's the signature of »quoted title/name« text (German // uses guillemets for emphasis and titles, not just speech) getting split // as if each quoted word were its own line of dialogue (confirmed live: a // dedication byline "»Lilo« and »Zerwas«" split into three one-word // segments, two of them "dialogue" attributed to guessed speakers — both in // the initial casting pass's own per-chunk output AND in a later // verification-pass re-split of an existing line). Shared by both call // sites rather than duplicated, since it's the same failure mode either way. // If `text` begins with a repeat of `prefixText` (comparing only significant // characters — quote marks and whitespace differ freely between the LLM's two // renderings of the same words), return the index in `text` just past that // duplicated content, else -1. Deliberately requires a reasonable length so // two segments that merely start with the same short common word ("Und …") // are never treated as duplicates. function _abPrefixDupCut(text, prefixText) { const INSIG = /[«»„“”"‟‚‘’›‹\s]/; const d = String(prefixText || '').replace(/[«»„“”"‟‚‘’›‹\s]/g, '').toLowerCase(); if (d.length < 12) return -1; const t = String(text || ''); let j = 0; for (let i = 0; i < t.length; i++) { const ch = t[i]; if (INSIG.test(ch)) continue; if (ch.toLowerCase() !== d[j]) return -1; j++; if (j === d.length) return i + 1; } return -1; } // Resolve role/epithet speaker labels to the proper name they denote, using // German appositive constructions found in the book's own text: "Darrag der // Schmied" / "der Schmied Darrag", "Zerwas den Henker", "der Inquisitor // Marcian". In both word orders the ROLE is the noun immediately after the // article and the NAME is the other side, so the pair can be read off the // grammar without knowing anything about a particular book. // // This is the "also known as" problem measured against the hand-corrected // book: the caster routinely credits a line to what a character IS ("Schmied", // "Henker", "Inquisitor") rather than who they ARE, and every such label // becomes its own cast entry with its own voice. `Darrag -> Schmied` alone // accounted for 27 wrong attributions. // // Deliberately conservative: a pair is only ever considered when BOTH sides // are already speaker labels in this cast. A stray "die Männer der Garnison" // can't invent a mapping, because "Garnison" never speaks. function _audiobookResolveRoleAliases(segments, sourceText) { const labels = new Map(); for (const s of segments) { if (s?.type !== 'dialogue') continue; const sp = String(s.speaker || '').trim(); if (!sp || /^(Narrator|Unknown|Unbekannt)$/i.test(sp)) continue; labels.set(sp.toLowerCase(), sp); } if (labels.size < 2) return { merged: 0, moved: 0, pairs: [] }; const votes = new Map(); // roleLower -> Map(nameLower -> hits) const vote = (role, name) => { const r = String(role || '').toLowerCase(), n = String(name || '').toLowerCase(); if (!r || !n || r === n) return; if (!labels.has(r) || !labels.has(n)) return; if (!votes.has(r)) votes.set(r, new Map()); const m = votes.get(r); m.set(n, (m.get(n) || 0) + 1); }; const W = '([A-ZÄÖÜ][A-Za-zÄÖÜäöüß-]{2,})'; const ART = '(?:der|die|das|den|dem)'; const txt = String(sourceText || ''); let m; const nameFirst = new RegExp('\\b' + W + ',?\\s+' + ART + '\\s+' + W + '\\b', 'g'); while ((m = nameFirst.exec(txt))) vote(m[2], m[1]); // "Darrag der Schmied" const roleFirst = new RegExp('\\b' + ART + '\\s+' + W + '\\s+' + W + '\\b', 'g'); while ((m = roleFirst.exec(txt))) vote(m[1], m[2]); // "der Schmied Darrag" // A single stray appositive is not evidence: German pairs capitalised nouns // constantly ("die Männer der Garnison", "der Elfe Zerwas" in a passage // where the elf is someone else), and one such accident is enough to bind a // role to the wrong character for the whole book — measured live, allowing // single hits produced Inquisitor→Lysandra and Vampir→Sartassa when the // book's own repeated usage says Marcian and Zerwas. Require the pairing to // recur, and to be an outright winner rather than tied with a rival. const MIN_VOTES = 2; const mapping = new Map(); for (const [role, names] of votes) { let best = null, bestN = 0, runnerUp = 0; for (const [n, c] of names) { if (c > bestN) { runnerUp = bestN; best = n; bestN = c; } else if (c > runnerUp) runnerUp = c; } if (best && bestN >= MIN_VOTES && bestN > runnerUp) mapping.set(role, labels.get(best)); } // Never chain a rename (A->B while B->C) — collapse to the final target. for (const [role, target] of mapping) { let t = target, guard = 0; while (mapping.has(t.toLowerCase()) && mapping.get(t.toLowerCase()) !== t && guard++ < 5) t = mapping.get(t.toLowerCase()); mapping.set(role, t); } let moved = 0; const pairs = []; for (const s of segments) { if (s?.type !== 'dialogue') continue; const to = mapping.get(String(s.speaker || '').trim().toLowerCase()); if (to && to !== s.speaker) { pairs.push(`${s.speaker}→${to}`); s.speaker = to; moved++; } } return { merged: mapping.size, moved, pairs: [...new Set(pairs)] }; } // ── Attribution confidence ────────────────────────────────────────────────── // 100% correct attribution is not reachable on a real novel, but knowing WHICH // lines are doubtful is nearly as valuable: it lets a human skip the ones that // are already right. Scored purely from local evidence (no extra LLM calls). // // Measured against the hand-corrected book, the score genuinely separates // right from wrong — it is not decoration: // >=80 (green) 45% of lines, 95.1% actually correct // 50-79 (orange) 23% of lines, 77.3% actually correct // <50 (red) 32% of lines, 53.7% actually correct // Reviewing only orange+red means reading 55% of the book and still catching // 90% of every real attribution error. const _AB_GENERIC_ROLE_WORDS = new Set(('mann frau junge mädchen menge männer frauen krieger kriegerin ' + 'wache wachen reiter schreiber adepten elfe zwerg zauberer magier magierin ork orks vampir inquisitor ' + 'schmied oberst fremde fremder gestalt stimme sklave sklaven nachbar bote soldat soldaten ritter ' + 'ritterin unsicher alter junger bäckerstochter weber kutscher druide amazone henker').split(' ')); function _abIsGenericRoleLabel(name) { const n = String(name || '').trim().toLowerCase().replace(/^(der|die|das|den|dem|ein|eine|alter|alte|junger|junge)\s+/, ''); const toks = n.match(/[\wäöüß]+/gu) || []; return toks.length > 0 && toks.every(t => _AB_GENERIC_ROLE_WORDS.has(t)); } function _abSegmentConfidence(segs, i) { const s = segs[i] || {}; const t = String(s.text || ''); const sp = String(s.speaker || '').trim(); const why = []; let score; if (s.type === 'narration') { score = 88; why.push('narration'); if (/[»«]/.test(t)) { score -= 25; why.push('carries quote marks'); } const p12 = (t.match(/\b(ich|du|wir|ihr|mich|dich|uns|euch)\b/gi) || []).length; if (p12 >= 3) { score -= 30; why.push('sustained 1st/2nd person — may be speech'); } else if (p12 >= 1) { score -= 8; why.push('1st/2nd person present'); } if (/[!?]\s*$/.test(t)) { score -= 8; why.push('ends with ! or ?'); } return { score: Math.max(2, Math.min(99, score)), why }; } if (!sp || /^(unknown|unbekannt)/i.test(sp)) return { score: 3, why: ['speaker unknown'] }; score = 60; if (_abIsGenericRoleLabel(sp)) { score -= 28; why.push('speaker is a generic role label'); } const nxt = i + 1 < segs.length ? String(segs[i + 1].text || '') : ''; const prv = i > 0 ? String(segs[i - 1].text || '') : ''; const first = (sp.split(/\s+/)[0] || '').replace(/[^\wäöüß]/gu, ''); const V = _AB_INQUIT_VERBS; if (first && new RegExp(V + '\\W+' + first, 'i').test(nxt)) { score += 32; why.push('speech tag names this speaker'); } else if (first && new RegExp(first + '\\W+\\w*\\s*' + V, 'i').test(nxt)) { score += 30; why.push('speaker + speech verb follows'); } else if (first && new RegExp(first, 'i').test(prv)) { score += 12; why.push('speaker named just before'); } else if (new RegExp('^\\s*[,»«]?\\s*' + V, 'i').test(nxt)) { score += 6; why.push('unnamed speech tag follows'); } else { score -= 12; why.push('no nearby speech tag'); } if (!/[»«]/.test(t)) { score -= 14; why.push('no quote marks'); } if (/\n\s*\n/.test(t.trim())) { score -= 22; why.push('fused: several turns in one segment'); } if (t.trim().length < 12) { score -= 10; why.push('very short line'); } return { score: Math.max(2, Math.min(99, score)), why }; } // The raw heuristic score is NOT a probability — measured against the // hand-corrected book, raw 35-49 lines were right 69% of the time, not 42%. // Showing the raw number would systematically understate how good the guess // is. This maps each raw band onto the correctness actually observed for it, // so the displayed percentage means "this is how often an attribution scoring // like this turned out to be right". // // Wording is deliberately restrained: even the best band is wrong about 1 line // in 25, so nothing is labelled "certain" or "definite" — a label the reviewer // learns to distrust is worse than no label. Bands are coarse on purpose; // six is as many reliability levels as this evidence can honestly separate. const _AB_CONF_BANDS = [ { max: 19, pct: 16, label: 'Guess' }, // 16.4% measured (n=55) { max: 34, pct: 47, label: 'Doubtful' }, // 47.1% measured (n=210) { max: 49, pct: 69, label: 'Probably' }, // 68.8% measured (n=240) { max: 64, pct: 75, label: 'Likely' }, // 75.4% measured (n=175) { max: 79, pct: 79, label: 'Likely' }, // 78.9% measured (n=175) { max: 100, pct: 96, label: 'Confident' }, // 95.6% measured (n=663) ]; function _abCalibrateConfidence(raw) { for (const b of _AB_CONF_BANDS) if (raw <= b.max) return b; return _AB_CONF_BANDS[_AB_CONF_BANDS.length - 1]; } // A reviewer working through 1400 flagged lines needs to see what they have // already dealt with — otherwise they lose their place and re-read the same // lines. Marking a line checked is a human verdict that outranks the score: // it turns the badge green and drops the line out of the review queue for // good, whether the speaker was corrected or simply confirmed as fine. function _abCheckBtnHtml(s) { if (typeof s.conf !== 'number') return ''; const on = (s.confMark || (s.confChecked ? 'ok' : null)) === 'ok'; return ``; } function _abConfClass(conf) { if (typeof conf !== 'number') return ''; return conf >= 80 ? 'ab-conf-green' : conf >= 50 ? 'ab-conf-orange' : 'ab-conf-red'; } function _abConfBadgeHtml(s) { if (typeof s.conf !== 'number') return ''; const cls = _abConfClass(s.conf); const label = s.conf >= 80 ? 'Confident' : s.conf >= 50 ? 'Check this' : 'Likely wrong'; const why = s.confWhy ? ' — ' + s.confWhy : ''; // Integer, not a decimal: these are band averages over a few hundred lines // each, so "95.4%" would imply a precision the measurement does not have. const mark = s.confMark || (s.confChecked ? 'ok' : null); if (mark === 'ok') return `✓ Correct`; if (mark === 'wrong') return `✗ Wrong`; const word = s.confLabel || label; return `${word} ${s.conf}%`; } // Stamp every segment with its confidence so the UI can colour-code and the // "next uncertain" navigation can skip what is already trustworthy. function _abStampConfidence(segs) { for (let i = 0; i < segs.length; i++) { const { score, why } = _abSegmentConfidence(segs, i); const band = _abCalibrateConfidence(score); // Store the CALIBRATED percentage as `conf`, so every threshold in the UI // (badge colour, "below 80%" review filter) is expressed in the same // honest units the reviewer sees, rather than in raw heuristic points. segs[i].conf = band.pct; segs[i].confLabel = band.label; segs[i].confRaw = score; segs[i].confWhy = why.join('; '); } return segs; } function audiobookIsRouterModel(model) { return /^auto[-_ ]?router/i.test(String(model || '').trim()); } function audiobookSafeLlmModel(model) { const m = String(model || '').trim(); const saved = String((typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_model) || '').trim(); if (audiobookIsRouterModel(m) && saved && !audiobookIsRouterModel(saved)) return saved; return m || saved; } function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof _appSettings !== 'undefined' && _appSettings?.llm_url) || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); } function audiobookLlmModel() { return audiobookSafeLlmModel($('reh-llm-model')?.value || ''); } // Book language for the attribution LLM: the manual dropdown wins, otherwise // detect it from the text itself (stop-word heuristic, utils.js detectLang) so // the server's "keep names and wording in " hint and grammar rules // are applied even when nobody touched the dropdown. function audiobookLang(text) { const manual = $('reh-design-lang')?.value || ''; if (manual) return manual; const sample = String(text || _audiobook.lastText || '').slice(0, 4000); return (sample && typeof detectLang === 'function') ? (detectLang(sample) || '') : ''; } function audiobookAttributeBody(fields, promptOverride = null) { const prompt = typeof promptOverride === 'string' ? promptOverride.trim() : ''; return prompt ? { ...fields, audiobook_prompt: prompt } : fields; } function audiobookCurrentCastLlm(panel) { const url = panel?.querySelector('#ab-cv-llm-url')?.value.trim() || audiobookLlmUrl(); const rawModel = panel?.querySelector('#ab-cv-llm-select')?.value || audiobookLlmModel(); return { url, model: audiobookSafeLlmModel(rawModel) }; } function audiobookSaveLlmChoice(url, model) { const cleanUrl = String(url || '').trim(); const cleanModel = audiobookSafeLlmModel(model); if (typeof _appSettings !== 'undefined' && _appSettings) { if (cleanUrl) _appSettings.llm_url = cleanUrl; if (cleanModel) _appSettings.llm_model = cleanModel; } const patch = {}; if (cleanUrl) patch.llm_url = cleanUrl; if (cleanModel) patch.llm_model = cleanModel; if (Object.keys(patch).length) { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => {}); } const rehModel = $('reh-llm-model'); if (rehModel && cleanModel && [...rehModel.options].some(o => o.value === cleanModel)) rehModel.value = cleanModel; return { url: cleanUrl, model: cleanModel }; } // Gather the plain text of the current reader scope (selection > page range > all). // Also records PDF page boundaries as character offsets in the returned text // (_audiobook.pageMarks) so saving to the Rehearser can reconstruct page breaks. function audiobookScopeText() { _audiobook.pageMarks = []; if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return ''; const idxs = readerScopeIndices(); const anchors = []; // {page, anchor} — first sentence of each new page let lastPage = null; for (const i of idxs) { const u = readerState.sentences[i]; const pg = u?.words?.[0]?.page; if (pg != null && pg !== lastPage) { anchors.push({ page: pg, anchor: (u.text || '').trim().slice(0, 40) }); lastPage = pg; } } // Join units with a blank line wherever one starts a real paragraph (per // readerMarkParagraphBreaks/.paraStart) instead of unconditionally with a // single space — otherwise chapter headings and every paragraph break in // the book collapse into one run-on blob before the LLM ever sees the text. let raw = ''; for (const i of idxs) { const u = readerState.sentences[i]; if (!u?.text) continue; raw += !raw ? u.text : (u.paraStart ? '\n\n' : ' ') + u.text; } // Collapse only horizontal whitespace runs and cap excessive blank lines — // a plain /\s+/ collapse here would erase the \n\n paragraph markers just inserted. raw = raw.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching // Locate each page anchor in the final text → page-break offsets. let from = 0; for (const a of anchors) { const probe = audiobookDehyphenate(a.anchor).slice(0, 24); if (!probe) continue; const pos = text.indexOf(probe, from); if (pos >= 0) { _audiobook.pageMarks.push({ offset: pos, page: a.page }); from = pos + probe.length; } } return text; } // ── Progress overlay ───────────────────────────────────────────────────────── function audiobookProgress(total) { let ov = document.getElementById('audiobook-overlay'); if (!ov) { ov = document.createElement('div'); ov.id = 'audiobook-overlay'; ov.className = 'audiobook-overlay'; ov.innerHTML = `
Casting audiobook
Analysing…
`; document.body.appendChild(ov); ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; }); } ov.hidden = false; const fill = ov.querySelector('#audiobook-fill'); const msg = ov.querySelector('#audiobook-msg'); return { update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; }, done() { ov.hidden = true; }, }; } // Delegates to the character library's canonical colour implementation // (clNormalizeColor / clHslToHex / clNameHue in characters-library.js) so // avatar colours can't drift between views; only the 'Narrator' default for a // missing name lives here. function _abNormalizeColor(color, fallbackName) { return clNormalizeColor(color, fallbackName || 'Narrator'); } function _abDefaultCharacterColor(name) { if (/^Unknown|Unbekannt/i.test(name || '')) return '#a83232'; return _abNormalizeColor(null, name); } function _abRecordColor(rec, name) { return _abNormalizeColor(rec?.color || rec?.sheet?.color, name || rec?.name); } // Escape a segment's text and underline any known character names. Module-level // so the review/preview overlay (audiobookShowPreview) can use it too — the cast // view (audiobookCastView) defines its own roster-coloured version that shadows // this inside its closure. Names default to the current run's roster. // Guillemets are the clearest visual cue for "this is spoken" — and in German // prose narration is never quoted, so showing them makes a mislabeled line // obvious at a glance. Extraction strips them from most segments, so they are // rendered rather than stored: the saved text is left exactly as cast, and the // marks are added for DISPLAY only on dialogue rows that lack them. let _abShowQuotes = (() => { try { return localStorage.getItem('ab-show-quotes') !== '0'; } catch (_) { return true; } })(); function _abDisplayText(s) { const t = String(s?.text || ''); if (s?.type !== 'dialogue' || !t.trim()) return t; if (/^\s*[»„"']/.test(t)) return t; // already carries its own marks return '»' + t.trim() + '«'; } // Wrap guillemets in their own element AFTER escaping/highlighting, so hiding // them is a pure CSS class flip on the container rather than a text rewrite and // a full redraw of every row — CSS cannot target a character inside a text node, // only an element. The marks stay in the DOM (and in the stored text, which is // never touched); they are merely display:none when the toggle is off. function _abMarkQuotes(html) { return String(html || '').replace(/([»«„“”])/g, '$1'); } function audiobookToggleQuotes(on) { _abShowQuotes = (on === undefined) ? !_abShowQuotes : !!on; try { localStorage.setItem('ab-show-quotes', _abShowQuotes ? '1' : '0'); } catch (_) {} document.querySelectorAll('.ab-cv-quote-toggle').forEach(b => { b.classList.toggle('is-on', _abShowQuotes); b.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; }); // One class on the root — instant, and cheap even with a couple of thousand rows. document.body.classList.toggle('ab-hide-quotes', !_abShowQuotes); return _abShowQuotes; } try { document.body.classList.toggle('ab-hide-quotes', !_abShowQuotes); } catch (_) {} window.audiobookToggleQuotes = audiobookToggleQuotes; function highlightText(text, names) { if (!text) return ''; let html = escHtml(text); const list = (names || (_audiobook && _audiobook.roster) || []) .filter(n => n && n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n)) .sort((a, b) => b.length - a.length); list.forEach((name) => { if (name.length < 2) return; const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const color = _abDefaultCharacterColor(name); html = html.replace(new RegExp(`\\b(${safe})\\b`, 'gi'), m => `${m}`); }); return html; } // Small dropdown anchored to the "Character options" caret next to "View // Characters", once a book's already been cast before — offers the existing // full character-definition pass plus a targeted "define just these characters" flow. // Lightweight "working on it" overlay for operations that block the main // thread for a noticeable moment (e.g. rewriting + redrawing thousands of // segments on a character merge) — without this, a long synchronous chunk // of work just makes the page stop responding with no visible cause, which // reads as a crash rather than "still working". Returns the overlay element; // call .remove() on it when the work finishes. function _abShowBusyOverlay(message, withProgress) { const ov = document.createElement('div'); ov.className = 'audiobook-overlay ab-busy-overlay'; ov.innerHTML = `
${escHtml(message)}
${withProgress ? '
' : ''}
`; document.body.appendChild(ov); ov.setProgress = (done, total) => { const fill = ov.querySelector('.ab-busy-fill'); if (fill && total) fill.style.width = Math.round((done / total) * 100) + '%'; const msgEl = ov.querySelector('.ab-busy-msg'); if (msgEl) msgEl.textContent = `${message} (${done} / ${total})`; }; return ov; } // Generic flyout menu for grouped toolbar/footer buttons — fixed-position, // appended to , anchored above its trigger via getBoundingClientRect // (not nested inside a clipping/overflow:hidden ancestor). Earlier casting // footer had a dropdown that opened in the wrong place because it wasn't // positioned this way; this mirrors the alias popup's proven approach instead. let _abFootMenuEl = null; function _abCloseFootMenu() { if (!_abFootMenuEl) return; document.getElementById(_abFootMenuEl.dataset.forId)?.classList.remove('is-menu-open'); _abFootMenuEl.remove(); _abFootMenuEl = null; document.removeEventListener('click', _abFootMenuOutside, true); document.removeEventListener('keydown', _abFootMenuKey, true); } function _abFootMenuOutside(e) { if (_abFootMenuEl && !_abFootMenuEl.contains(e.target) && !e.target.closest('.ab-foot-trigger')) _abCloseFootMenu(); } function _abFootMenuKey(e) { if (e.key === 'Escape') _abCloseFootMenu(); } function _abToggleFootMenu(triggerBtn, items) { const reopening = _abFootMenuEl && _abFootMenuEl.dataset.forId === triggerBtn.id; _abCloseFootMenu(); if (reopening) return; const el = document.createElement('div'); el.className = 'ab-foot-menu'; el.dataset.forId = triggerBtn.id; el.innerHTML = items.map((it, i) => it.divider ? '
' : `` ).join(''); document.body.appendChild(el); const rect = triggerBtn.getBoundingClientRect(); el.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - el.offsetWidth - 8)) + 'px'; // These buttons used to live only in the casting panel's footer at the bottom // of the screen, so the menu was hard-anchored to open upwards. In Studio the // same toolbar sits near the TOP of the page, where opening upwards pushed the // menu off-screen and silently clipped its first entries (confirmed live: the // first two items were unreachable). Pick the side with room, preferring down. const menuH = el.offsetHeight; const spaceBelow = window.innerHeight - rect.bottom; const spaceAbove = rect.top; if (spaceBelow >= menuH + 12 || spaceBelow >= spaceAbove) { el.style.top = Math.min(rect.bottom + 6, Math.max(8, window.innerHeight - menuH - 8)) + 'px'; el.style.bottom = 'auto'; } else { el.style.bottom = (window.innerHeight - rect.top + 6) + 'px'; el.style.top = 'auto'; } el.querySelectorAll('.ab-foot-menu-item').forEach((btn) => { btn.addEventListener('click', () => { const it = items[Number(btn.dataset.idx)]; _abCloseFootMenu(); it?.onClick?.(); }); }); _abFootMenuEl = el; triggerBtn.classList.add('is-menu-open'); setTimeout(() => { document.addEventListener('click', _abFootMenuOutside, true); document.addEventListener('keydown', _abFootMenuKey, true); }, 0); } // Checkbox picker for the selected-character sheet generation flow. // csForReaderSelective narrows extraction down to just the passages mentioning // the picked character(s) (plus neighbouring context) instead of the whole book. // The helper it calls is alias-aware, so selecting Darrag also catches evidence // windows where the text uses one of his known alternate names. function _abOpenRecastSelectPopup(existingChars) { const ov = document.createElement('div'); ov.className = 'audiobook-overlay'; // Same avatar/name/line-count list style as the "Characters found" sidebar // in the live casting view, instead of a bare alphabetical checkbox wall — // picking a character to re-cast is much easier when you can see their // portrait and how much dialogue they actually have. Selection state lives // in `selected` (not just checkbox DOM state) so it survives re-render on // search/sort. const selected = new Set(); let sort = 'lines', filter = ''; try { sort = localStorage.getItem('ttsvc_ab_recast_sort') || 'lines'; } catch (_) {} const rowHtml = (c) => { const lineCount = c?.sheet?.line_count || 0; const color = _abRecordColor(c, c.name); const avatar = c?.image ? `` : `${escHtml((c?.name || '?')[0].toUpperCase())}`; return ``; }; const render = () => { const q = filter.trim().toLowerCase(); const items = existingChars .filter(c => !q || (c.name || '').toLowerCase().includes(q)) .sort(sort === 'alpha' ? (a, b) => (a.name || '').localeCompare(b.name || '') : (a, b) => (b.sheet?.line_count || 0) - (a.sheet?.line_count || 0)); const list = ov.querySelector('.ab-recast-select-list'); if (!list) return; list.innerHTML = items.length ? items.map(rowHtml).join('') : '
No matches
'; list.querySelectorAll('.ab-recast-cb').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) selected.add(cb.value); else selected.delete(cb.value); }); }); }; ov.innerHTML = '
' + '
Select characters to cast or recast roles
' + '
Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context. Everyone else\'s sheet stays as-is.
' + '
' + '' + '' + '
' + '
' + '
' + '' + '' + '' + '' + '
' + '
'; document.body.appendChild(ov); render(); const sortSel = ov.querySelector('#ab-recast-sort'); sortSel.value = sort; sortSel.addEventListener('change', () => { sort = sortSel.value; try { localStorage.setItem('ttsvc_ab_recast_sort', sort); } catch (_) {} render(); }); let _searchT = null; ov.querySelector('#ab-recast-search').addEventListener('input', (e) => { clearTimeout(_searchT); const v = e.target.value; _searchT = setTimeout(() => { filter = v; render(); }, 120); }); ov.querySelector('#ab-recast-cancel').addEventListener('click', () => ov.remove()); ov.querySelector('#ab-recast-select-all').addEventListener('click', (e) => { const allSelected = existingChars.length > 0 && existingChars.every(c => selected.has(c.name)); if (allSelected) selected.clear(); else existingChars.forEach(c => selected.add(c.name)); e.currentTarget.textContent = allSelected ? 'Select all' : 'Select none'; render(); }); ov.querySelector('#ab-recast-confirm').addEventListener('click', async () => { // The DOM checkboxes are the source of truth at confirmation time. The // Set is only a render/search convenience and could become stale after a // filtered list was rebuilt, which previously allowed a one-character // selection to fall through as a full-roster recast. const names = [...ov.querySelectorAll('.ab-recast-cb:checked')].map(cb => cb.value).filter(Boolean); if (!names.length) { toast('Select at least one character', 'error'); return; } ov.remove(); if (typeof window.audiobookRecastSelectedCharacters === 'function') await window.audiobookRecastSelectedCharacters(names); else if (typeof window.csForReaderSelective === 'function') await window.csForReaderSelective(names); }); } async function audiobookRecastSelectedCharacters(selectedNames) { if (typeof window.csForReaderSelective === 'function') return window.csForReaderSelective(selectedNames); toast('Character-sheet recast is unavailable right now', 'error'); return null; } window.audiobookRecastSelectedCharacters = audiobookRecastSelectedCharacters; // Live casting view: a scrolling feed of attributed lines + a character roster // that fills up as speakers are discovered. Far clearer than a bare bar. // content-visibility:auto on .ab-cv-page (added to keep a long book's DOM // cheap to render) means every off-screen page's height is only an ESTIMATE // (contain-intrinsic-size) until the browser actually measures it — so a // single scrollIntoView() computed against a target many pages away lands // wherever the SUM of all those estimation errors puts it, sometimes // thousands of pixels off. Confirmed live: focusing a character's first // line scrolled to `top: -4098px`, nowhere near the visible feed. A second, // corrective call after the browser has had a chance to actually lay out // whatever came into view during the first (rough) pass fixes this without // giving up the performance win — RAF x2 reliably waits past style/layout. function _abScrollIntoView(el, opts) { if (!el) return; el.scrollIntoView(Object.assign({ behavior: 'auto' }, opts)); requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollIntoView(Object.assign({ behavior: 'smooth' }, 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() { const segs = _audiobook.segments || []; if (!segs.length) { toast('No cast to open', 'error'); return false; } // Build the record straight from the CURRENT in-memory segments and load // it directly — a prior version saved this to IndexedDB and then // re-fetched it by id before loading, which could hand back a stale // record (a read-after-write race, or a stale `_audiobook.rehId` left // over from an earlier pass) instead of the just-corrected data. Building // once and loading that same object removes the possibility entirely; // the DB write below is now just for persistence, not the source of what // gets loaded. if (typeof window.rehLoadRecord === 'function' && typeof _audiobookBuildRehRecord === 'function') { try { const rec = await _audiobookBuildRehRecord(segs); if (_audiobook.rehId) rec.id = _audiobook.rehId; document.getElementById('audiobook-preview')?.remove(); if (typeof navTo === 'function') navTo('s-rehearser'); window.rehLoadRecord(rec); // rehLoadRecord always lands on the Cast phase, but by the time you're // opening the Rehearser from the Audiobook pipeline, voices were already // assigned in Assign Voices — Cast there is pure redundant re-work. // Jump straight to Stage (phase 3), the line-by-line editing view. // showPhase() alone only toggles which phase
is visible — the // actual script/character panes are built by buildScriptPage(), same // as the Stage sub-tab's own click handler does. if (rehState.lines.length && typeof buildScriptPage === 'function') { buildScriptPage(); if (typeof showPhase === 'function') showPhase(3); if (typeof highlightCurrentLine === 'function') highlightCurrentLine(); } else if (typeof showPhase === 'function') showPhase(3); toast('Opened cast in Script Rehearser', 'success'); audiobookSaveAsRehearsal({ silent: true }).catch(() => {}); return true; } catch (err) { console.warn('[audiobook] failed to build rehearser record directly, falling back:', err); } } await audiobookSaveAsRehearsal({ silent: true }); const { script, emotions } = audiobookBuildScript(segs); await audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions); if (rehState.lines.length && typeof buildScriptPage === 'function') { buildScriptPage(); if (typeof showPhase === 'function') showPhase(3); if (typeof highlightCurrentLine === 'function') highlightCurrentLine(); } else if (typeof showPhase === 'function') showPhase(3); return true; } function audiobookShowPreview() { audiobookOpenCurrentInRehearser(); return; const segs = _audiobook.segments || []; const roster = _audiobook.roster || []; document.getElementById('audiobook-preview')?.remove(); const ov = document.createElement('div'); ov.id = 'audiobook-preview'; ov.className = 'audiobook-overlay'; // datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown) const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])]; const charCount = speakerSet.filter(n => !/^Unknown|Unbekannt/i.test(n)).length; const opts = speakerSet.map(n => `