// ── 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 }; window._audiobook = _audiobook; let _abDraftAutosaveTimer = null; let _abLastServerDraftErrorAt = 0; async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) { const parentSignal = options.signal; const ac = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; ac.abort(); }, timeoutMs); const onParentAbort = () => ac.abort(parentSignal?.reason); if (parentSignal) { if (parentSignal.aborted) onParentAbort(); else parentSignal.addEventListener('abort', onParentAbort, { once: true }); } try { return await fetch(url, { ...options, signal: ac.signal }); } catch (err) { if (timedOut) { const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`); timeoutErr.name = 'TimeoutError'; throw timeoutErr; } throw err; } finally { clearTimeout(timer); if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort); } } function audiobookTimeoutSeconds(timeoutMs) { return Math.max(5, Math.round(timeoutMs / 1000)); } // Streamed attribution: reads the SSE feed from /api/attribute-dialogue/stream, // pushing each delta into view.thinking() so the user can watch the LLM work, // and resolves with the final parsed result. Uses an INACTIVITY timeout (reset // on every received chunk) rather than an overall one — a passage legitimately // takes minutes on a slow model, but silence that long means the stream died. // Throws on any failure; callers fall back to the blocking endpoint. A user // Stop (outer signal) propagates as AbortError; a stalled stream does not. async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) { const ctl = new AbortController(); const onAbort = () => ctl.abort(); if (outerSignal) { if (outerSignal.aborted) ctl.abort(); else outerSignal.addEventListener('abort', onAbort, { once: true }); } let idleTimer = null; const armIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs); }; try { armIdle(); const r = await fetch('/api/attribute-dialogue/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }), }); if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status); const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = '', result = null; for (;;) { const { done, value } = await reader.read(); if (done) break; armIdle(); buf += dec.decode(value, { stream: true }); let at; while ((at = buf.indexOf('\n\n')) >= 0) { const line = buf.slice(0, at).trim(); buf = buf.slice(at + 2); if (!line.startsWith('data:')) continue; let d; try { d = JSON.parse(line.slice(5)); } catch (_) { continue; } if (d.t && view?.thinking) view.thinking(d.t); if (d.error) throw new Error(d.error); if (d.done) result = d.result || null; } } if (!result) throw new Error('stream ended without result'); return result; } catch (err) { // An idle-timeout abort is a stream failure (fall back), not a user Stop. if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) { throw new Error('stream idle timeout'); } throw err; } finally { clearTimeout(idleTimer); if (outerSignal) outerSignal.removeEventListener('abort', onAbort); } } 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) { minScore = minScore || 3; 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 const p12 = /\b(ich|du|wir|ihr|mein|dein|dich|euch)\b/i.test(t); // narration is 3rd-person past const excl = /[!?]\s*$/.test(t); const imper = /^(Bringt|Los|Mach|Kommt|Geht|Nehmt|Haltet|Schafft|T[öo]tet|Sieh|Hört|Wartet)\b/.test(t); if (p12) score += 1; 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); } // Mirrors routes/reader.py's _cast_to_md — a readable Markdown script (plain // paragraphs for narration, **SPEAKER** (emotion): "line" for dialogue, // grouped under page headings), used when there's no server-side bookId to // export through. One-way export for reading/sharing, not meant to round-trip. function _abCastMarkdown(data) { const payload = data || {}; const segments = Array.isArray(payload.segments) ? payload.segments : []; const speakers = [...new Set( segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker)) )].sort(); const lines = [`# ${payload.title || 'Cast Script'}`, '']; const metaBits = []; if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' ')); metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`); metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`); lines.push(`*${metaBits.join(' · ')}*`, ''); if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, ''); let lastPage = null; for (const seg of segments) { const text = String(seg?.text || '').trim(); if (!text) continue; if (seg.page != null && seg.page !== lastPage) { lines.push('---', '', `## Page ${seg.page}`, ''); lastPage = seg.page; } if (seg.type === 'dialogue') { const speaker = String(seg.speaker || 'Narrator'); const tag = seg.emotion ? ` *(${seg.emotion})*` : ''; lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`); } else { lines.push(text); } lines.push(''); } return lines.join('\n'); } // One-time migration for drafts saved before segments carried a .page number: // re-derive each segment's page from the draft's pageMarks via the old // forward-only offset search and stamp it on. Mirrors the legacy render-time // fallback exactly (segments before the first mark stay unstamped → unlabeled // leading card), but runs once at draft load instead of on every redraw, so // the feed renderer stays single-path. function _abStampSegmentPages(segs, pageMarks, text) { if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return; const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0)); const src = text || ''; if (!marks.length || !src) return; let markIdx = 0, searchPos = 0, cur = null; if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; } for (const s of segs) { const probe = String(s.text || '').trim().slice(0, 24); const at = probe ? src.indexOf(probe, searchPos) : -1; const pos = at >= 0 ? at : searchPos; if (at >= 0) searchPos = at + probe.length; while (markIdx < marks.length && pos >= marks[markIdx].offset) { cur = marks[markIdx].page + 1; markIdx++; } if (cur != null) s.page = cur; } } // 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.). const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’『「<]|(?:^|\n)\s*[—–]\s/; function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); } // Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes") // so the audiobook reads cleanly and speaker tags aren't split. function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); } // Speech-tag heuristic so the book still casts with REAL names when the LLM is down. const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|grunzte|versetzte|unterbrach|drängte|herrschte|keuchte|hauchte|ächzte|stöhnte|jammerte|spottete|höhnte|warf\\s+ein|setzte\\s+hinzu|erklärte|verkündete|widersprach|beharrte|gestand|bestätigte|erinnerte|dachte|überlegte|sinnierte|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)'; const AB_NOTNAME = new Set([ 'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man', 'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now', // common sentence-initial adverbs / interjections / abstractions that are NOT characters 'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort', 'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich', 'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']); const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)"; // Person/role nouns that are plausible speakers when no proper name is found. // A generic "der/die + any capitalized noun" fallback assigned scenery words // as characters (PLATZ from "auf dem Platz", GESICHTER, KLEINIGKEIT) — a // closed whitelist can't make that class of mistake. const AB_PERSON_NOUNS = new Set(['Mann','Frau','Junge','Mädchen','Alte','Alter','Fremde','Fremder','Krieger','Kriegerin', 'Wächter','Wache','Soldat','Hauptmann','Ork','Ritter','Magier','Magierin','Zwerg','Elf','Elfe','Händler','Wirt','Wirtin', 'Bauer','Priester','Priesterin','Nachbar','Nachbarin','Sklave','Sklavin','Verweser','Inquisitor','General','König','Königin', 'Prinz','Prinzessin','Fürst','Fürstin','Baron','Baronin','Bote','Diener','Dienerin','Knabe','Kind','Reiter','Reiterin', 'Bogenschütze','Schmied','Schmiedin','Heiler','Heilerin','Gelehrte','Gelehrter','Kapitän','Anführer','Anführerin']); // 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()); } // Two adjacent segments assigned to the exact same speaker (narrator or a // character) with no page break between them are either a casting mistake // (a line that should never have been split off on its own — attribution // noise from the per-passage LLM pass, especially near dialogue tags) or // genuinely one continuous block that just got chopped into several tiny // rows in the casting feed. Either way, splicing them back into one segment // is the safe, mechanical fix: it can't make a correct attribution worse, // and it turns a wall of one-line "NARRATOR" cards into the actual // paragraph flow. Runs once over the whole book after casting finishes // (not just within one passage chunk, unlike the narration-only merge // further up) so it also catches passage-boundary duplicates. function _audiobookMergeAdjacentSameSpeaker(segments) { const out = []; for (const s of segments) { const last = out[out.length - 1]; if (last && last.type === s.type && last.page === s.page) { const lastSpeaker = (last.speaker || 'Narrator').trim().toLowerCase(); const curSpeaker = (s.speaker || 'Narrator').trim().toLowerCase(); // Dialogue keeps its emotion tag distinct — merging "happy Marcus" text // into "angry Marcus" text would silently discard one of the two moods. const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase(); // Two adjacent UNKNOWN lines are not evidence of one speaker — "Unknown" // means precisely that we do not know who spoke. Both normalise to the // same string here, so they compared as equal and were fused into a single // block: confirmed live, Lysandra's line and Perdia's interruption (two // separate paragraphs in the book) became one segment joined by a blank // line, after which no attribution pass could ever separate them again and // one voice would read both sides of the exchange. Never merge on Unknown. const unknownSpeaker = !lastSpeaker || lastSpeaker === 'unknown' || lastSpeaker === 'unbekannt'; if (lastSpeaker === curSpeaker && sameEmotion && !(s.type === 'dialogue' && unknownSpeaker)) { const lastText = last.text.trimEnd(), curText = s.text.trimStart(); last.text = /[.!?…»«”"’']$/.test(lastText) ? lastText + '\n\n' + curText : lastText + ' ' + curText; continue; } } out.push(Object.assign({}, s)); } return out; } // 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. const _AB_TITLE_PREFIX = /^(?:der|die|das|den|dem|ein|eine|einer|herr|frau|oberst|obrist|graf|gräfin|ritter|hauptmann|kommandant|meister|bruder|schwester|prinz|prinzessin|könig|königin|alter|alte|junger|junge|blonder|blonde)\s+/i; function _abIdentityTokens(name) { let n = String(name || '').trim().toLowerCase(), prev = null; while (prev !== n) { prev = n; n = n.replace(_AB_TITLE_PREFIX, ''); } return n.split(/[\s\-]+/).filter(Boolean); } function _audiobookConsolidateSpeakerAliases(segments) { const count = new Map(); for (const s of segments) { if (s?.type !== 'dialogue') continue; const sp = String(s.speaker || '').trim(); if (!sp || /^(unknown|unbekannt|narrator)/i.test(sp)) continue; count.set(sp, (count.get(sp) || 0) + 1); } const names = [...count.keys()]; const tokens = new Map(names.map(n => [n, new Set(_abIdentityTokens(n))])); const mapping = new Map(); for (const a of names) { const ta = tokens.get(a); if (!ta.size) continue; let best = null; for (const b of names) { if (a === b) continue; const tb = tokens.get(b); if (!tb.size || tb.size <= ta.size) continue; let subset = true; for (const t of ta) if (!tb.has(t)) { subset = false; break; } if (!subset) continue; // strict subset only if (!best || count.get(b) > count.get(best) || (count.get(b) === count.get(best) && b.length > best.length)) best = b; } if (best) mapping.set(a, best); } let moved = 0; if (mapping.size) { for (const s of segments) { if (s?.type !== 'dialogue') continue; const to = mapping.get(String(s.speaker || '').trim()); if (to) { s.speaker = to; moved++; } } } return { merged: mapping.size, moved, mapping }; } window._audiobookConsolidateSpeakerAliases = _audiobookConsolidateSpeakerAliases; function _audiobookMergeSplitSentences(segments) { const out = []; let merged = 0; for (const s of segments) { const prev = out[out.length - 1]; const cur = String(s?.text || '').trim(); if (prev && prev.type === 'narration' && s?.type === 'narration' && cur) { const prevText = String(prev.text || '').trimEnd(); const endsOpen = prevText && !/[.!?:;…»"'\)\]]$/.test(prevText); const startsLower = /^[a-zäöüß]/.test(cur); // Never merge across an inquit (", flüsterte der Geist,") — that is a // SPLIT QUOTE whose second half is really dialogue; fusing it into // narration would bury a line the attribution passes could still fix. const prevIsInquit = new RegExp(_AB_INQUIT_VERBS + "[^.!?]{0,40},\\s*$", 'i').test(prevText); if (endsOpen && startsLower && !prevIsInquit) { prev.text = prevText + ' ' + cur; merged++; continue; } } out.push(s); } return { segments: out, merged }; } function _audiobookDedupNearbyDuplicates(segments) { const QUOTE_CHARS = '»«„"‘’›‹'; const stripRe = new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`, 'g'); const norm = (t) => String(t || '').replace(stripRe, '').toLowerCase().replace(/\s+/g, ' ').trim(); const WINDOW = 30; // generous vs. the ~9-segment max context overlap between adjacent recast groups const MIN_LEN = 20; // skip short common lines ("Ja." "Nein.") that can legitimately repeat const out = []; let removed = 0; for (const s of segments) { const nt = norm(s?.text); if (nt.length >= MIN_LEN) { let isDup = false; for (let k = out.length - 1; k >= 0 && out.length - k <= WINDOW; k--) { if (out[k].type === s.type && norm(out[k].text) === nt) { isDup = true; break; } } if (isDup) { removed++; continue; } } out.push(s); } return { segments: out, removed }; } function audiobookSplitByQuotes(text) { const spans = []; for (let i = 0; i < text.length; i++) { const open = text[i]; const close = AB_QUOTE_PAIRS[open]; if (!close) continue; const minStart = spans.length ? spans[spans.length - 1].end : 0; const orphan = _abOrphanClosingQuoteSpan(text, i, minStart); if (orphan) { spans.push(orphan); i = orphan.end - 1; continue; } if ((open === '«' || open === '»') && i > 0 && !/[\s([{—–-]/.test(text[i - 1])) continue; const raw = text.slice(i + 1); const closeIdx = raw.indexOf(close); const endInRaw = _abUnclosedQuoteEnd(raw, closeIdx); const quote = raw.slice(0, endInRaw).trim(); if (!quote) continue; const consumedClose = closeIdx >= 0 && endInRaw === closeIdx; spans.push({ start: i, end: i + 1 + endInRaw + (consumedClose ? 1 : 0), quote }); i = spans[spans.length - 1].end - 1; } if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }]; const out = []; let last = 0; for (let k = 0; k < spans.length; k++) { const sp = spans[k]; const pre = text.slice(last, sp.start); if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' }); if (sp.quote) { const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length); const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown'; out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' }); } last = sp.end; } const tail = text.slice(last).trimEnd(); if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' }); return audiobookTurnTaking(out); } // Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO // distinct named speakers are established nearby (conservative: won't guess in a // monologue, so it rarely invents a wrong name). function audiobookTurnTaking(segs) { let a = null, b = null; // two most recent distinct named speakers (b = latest) for (const s of segs) { if (s.type !== 'dialogue') continue; if (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) { if (s.speaker !== b) { a = b; b = s.speaker; } } else if (a && b && a !== b) { s.speaker = a; // the other of the two → alternate const t = a; a = b; b = t; // rotate so the next Unknown alternates back } } 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; } } 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 = '
' + '
Cast selected character 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 () => { const names = [...selected]; 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)); })); } function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) { const panel = document.getElementById('reader-audiobook-panel'); if (!panel) return; if (typeof window.navReaderView === 'function') window.navReaderView('cast'); else if (typeof window.showReaderView === 'function') window.showReaderView('cast'); else { const mainView = document.getElementById('reader-main-view'); if (mainView) mainView.hidden = true; panel.hidden = false; } panel.className = 'ab-castpanel-inline card'; panel.style.display = ''; panel.style.flexDirection = ''; panel.style.minHeight = ''; panel.innerHTML = `
Casting audiobook passage 0 / ${total}
Characters found
${[38,62,45,28,54,35].map(w => `
`).join('')}
`; const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen. WICHTIGE DEFINITION VON DIALOG: Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden. Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog). ABSATZ- UND KAPITELSTRUKTUR: Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text. GRAMMATIK-REGELN FÜR NARRATION: - Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt."). - Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht. - Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue. - Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext. ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ein Lektor): 1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) IMMER zum tatsächlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts. 2. Doppelpunkt-Regel: Endet ein Erzählersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter spricht das nächste Zitat; "Ein anderer fragte verschlafen:" → dieser andere spricht). 3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei unpersönlicher Formel: "»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach das Zitat. 4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist — ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (spürte, hörte, fühlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUHÖRER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau). 5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" → X spricht das nächste Zitat, und Y ist der wahrscheinlichste Antwortende. 6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab — auch über viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette lückenlos zurück zur letzten eindeutigen Nennung und führe sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch. 7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters? 8. Namenlose Sprecher: Auch Rollenbezeichnungen aus dem Text sind gültige Sprecher — nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'Wächter', 'Junge'). 9. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens'). 10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln absolut unmöglich ist. Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung gegenüber 'Unknown'. 11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint. 12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht 'Unknown'. 13. ZUHÖRER-AUSSCHLUSS (negative Evidenz — sehr wichtig): Ein Name im Erzählsatz neben einem Zitat ist HÄUFIG der Zuhörer, nicht der Sprecher. Schließe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian spürte ihre Tränen auf seiner Brust; Marcian hörte darüber hinweg → Marcian HÖRT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: Mögen die Götter dich schützen, Marcian. → Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr übrig bleibt, suche WEITER ZURÜCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) — der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt für sich allein NIEMALS 'Unknown'. 16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER — das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." → das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13). 17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") — niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt. 18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe → "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt. 19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. »Ich werde jetzt ...«"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf — siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergnügen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen. 14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er → letzter Mann, sie → letzte Frau). Solche Zeilen dürfen niemals 'Unknown' bleiben. 15. MEHRERE KANDIDATEN in der Nähe: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuhörer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegenüber jemandem in Objekt- oder Präpositionalstellung; (c) prüfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zulässig. FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS: - speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge. - type: 'narration' oder 'dialogue' - text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' BEHÄLTST du die umschließenden Anführungszeichen als Teil des Texts (z.B. »Hallo!« bleibt »Hallo!«) — entferne sie NICHT und lasse niemals nur eines der beiden übrig. - emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen (''). STRIKTE FORMAT- UND TEXTREGELN: - Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("»Nein«"), narration (", sagte sie, "), dialogue ("»halt.«"). - »Text?« und »Text!« sind vollständige Dialoge — das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht. - PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("flüsterte er", "sagte sie", "rief Uriens") oder normale Erzählerhandlung weitergeht. - Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. "Der Tod trägt rot. «"), behandle den Satz davor als Dialog und hänge das schließende « an dessen Ende an, statt es als eigenes Segment stehen zu lassen — ein Anführungszeichen darf NIE ein eigenes Segment für sich bilden. - Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'. - Anführungszeichen gehören IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schließende « am Ende einer Figurenrede gehört ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das öffnende » am Anfang einer Figurenrede gehört an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen », „, ‚ oder › beginnen oder enden, und NIE mit einem einzelnen «, ", ' oder ‹ enden oder beginnen — verschiebe das Zeichen ins richtige Nachbar-Segment. - Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!`; const normalizeCastingPrompt = (prompt) => { let p = prompt || AB_DEFAULT_PROMPT; p = p.replace( "- Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'.", "- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt (\"flüsterte er\", \"sagte sie\", \"rief Uriens\") oder normale Erzählerhandlung weitergeht.\n- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.\n- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'." ); p = p.replace( "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen.", "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen)." ); p = p.replace( "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!", "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!" ); // Policy flip: quote marks used to be stripped out of dialogue text // entirely, which was landing as stray orphaned »/« characters on their // own narrator-only segments when the model didn't fully comply. Keeping // them attached to the dialogue text (as they already are in the source) // sidesteps that failure mode instead of just asking more firmly. p = p.replace( "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen).", "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' BEHÄLTST du die umschließenden Anführungszeichen als Teil des Texts (z.B. »Hallo!« bleibt »Hallo!«) — entferne sie NICHT und lasse niemals nur eines der beiden übrig." ); p = p.replace( "- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.", "- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und hänge das schließende « an dessen Ende an, statt es als eigenes Segment stehen zu lassen — ein Anführungszeichen darf NIE ein eigenes Segment für sich bilden." ); p = p.replace( "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!", "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!" ); // Upgrade older saved prompts to the deduction-rule set (colon rule, // post-quote inquits, pronoun resolution, strict ping-pong, role names) // that cut down false "Unknown"/"Narrator" attributions. if (!/Doppelpunkt-Regel/.test(p)) { p = p.replace( `ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv): 1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf. 2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausführt und direkt davor/danach wörtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. »Es regnet.«"). 3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette lückenlos zurück zur letzten eindeutigen Nennung. 4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters? 5. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens'). 6. Unbekannte Sprecher: Nur wenn eine Zuordnung durch Kontext absolut nicht möglich ist, verwende 'Unknown'. Rate nicht blind, aber bevorzuge immer einen namentlich genannten Charakter gegenüber 'Unknown'.`, `ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ein Lektor): 1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) IMMER zum tatsächlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts. 2. Doppelpunkt-Regel: Endet ein Erzählersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter spricht das nächste Zitat; "Ein anderer fragte verschlafen:" → dieser andere spricht). 3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei unpersönlicher Formel: "»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach das Zitat. 4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist — ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (spürte, hörte, fühlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUHÖRER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau). 5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" → X spricht das nächste Zitat, und Y ist der wahrscheinlichste Antwortende. 6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab — auch über viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette lückenlos zurück zur letzten eindeutigen Nennung und führe sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch. 7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters? 8. Namenlose Sprecher: Auch Rollenbezeichnungen aus dem Text sind gültige Sprecher — nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'Wächter', 'Junge'). 9. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens'). 10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln absolut unmöglich ist. Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung gegenüber 'Unknown'.` ); } if (!/GRAMMATIK-REGELN FÜR NARRATION/.test(p)) { p = p.replace( 'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).', 'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.' ); } // Paragraph/heading structure is now preserved as blank lines upstream // (readerMarkParagraphBreaks); tell older saved prompts how to read it. if (!/ABSATZ- UND KAPITELSTRUKTUR/.test(p)) { p = p.replace( 'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).', 'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nABSATZ- UND KAPITELSTRUKTUR:\nEine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer \'narration\'/\'Narrator\', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text.' ); } // Add the mid-quote-dash and voice-announcement rules to prompts saved // before these existed — a passage like '»...« - und weiter...' was // getting cut into a new (wrong) speaker at the dash, and narration like // "Marcians Stimme wirkte nicht mehr so fest" right before an unattributed // line wasn't being used to resolve it away from 'Unknown'. if (!/Gedankenstrich-Pause-Regel/.test(p) || !/ZUHÖRER-AUSSCHLUSS/.test(p) || !/FORTSETZUNGS-REGEL/.test(p) || !/EPITHETA/.test(p)) { const newRules = '11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint.\n12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht \'Unknown\'.\n13. ZUHÖRER-AUSSCHLUSS (negative Evidenz — sehr wichtig): Ein Name im Erzählsatz neben einem Zitat ist HÄUFIG der Zuhörer, nicht der Sprecher. Schließe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian spürte ihre Tränen auf seiner Brust; Marcian hörte darüber hinweg → Marcian HÖRT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: Mögen die Götter dich schützen, Marcian. → Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr übrig bleibt, suche WEITER ZURÜCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) — der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt für sich allein NIEMALS \'Unknown\'.\n16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER — das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." → das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).\n17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") — niemals \'Unknown\'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.\n18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe → "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals \'Unknown\', damit die Figur durchgehend dieselbe Stimme behaelt.\n19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. »Ich werde jetzt ...«"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker \'Narrator\' und type \'narration\', das Zitat ein eigenes Segment mit type \'dialogue\' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf — siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes \'Unknown\' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergnügen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als \'Unknown\' zu lassen.\n14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er → letzter Mann, sie → letzte Frau). Solche Zeilen dürfen niemals \'Unknown\' bleiben.\n15. MEHRERE KANDIDATEN in der Nähe: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuhörer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegenüber jemandem in Objekt- oder Präpositionalstellung; (c) prüfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist \'Unknown\' zulässig.'; const unknownRuleRe = /(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/; if (unknownRuleRe.test(p)) { p = p.replace(unknownRuleRe, `$1\n${newRules}`); } else { p += `\n${newRules}`; } } // Quote marks belong to the dialogue segment, never the adjacent // narration segment — fixes a stray »/« ending up as its own orphaned // "NARRATOR" row right before/after a real dialogue turn. if (!/gehören IMMER zum Dialog-Segment/.test(p)) { p = p.replace( "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!", "- Anführungszeichen gehören IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schließende « am Ende einer Figurenrede gehört ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das öffnende » am Anfang einer Figurenrede gehört an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen », „, ‚ oder › beginnen oder enden, und NIE mit einem einzelnen «, \", ' oder ‹ enden oder beginnen — verschiebe das Zeichen ins richtige Nachbar-Segment.\n- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren, EINSCHLIESSLICH der Anführungszeichen!" ); } return p; }; const rawPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT; const globalPrompt = normalizeCastingPrompt(rawPrompt); if (globalPrompt !== rawPrompt && typeof _appSettings !== 'undefined') { _appSettings.audiobook_prompt = globalPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: globalPrompt }) }).catch(() => {}); } panel.querySelector('#ab-cv-prompt-text').value = globalPrompt; const closePanel = () => { panel.hidden = true; panel.innerHTML = ''; if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); if (typeof window.navReaderView === 'function') window.navReaderView('main'); else if (typeof window.showReaderView === 'function') window.showReaderView('main'); else { const mv = document.getElementById('reader-main-view'); if (mv) mv.hidden = false; } }; const setStoppingState = () => { const cancelBtn = panel.querySelector('#ab-cv-cancel'); if (cancelBtn) { cancelBtn.disabled = true; cancelBtn.innerHTML = ' Stopping...'; cancelBtn.title = 'Stopping the current casting request'; } const status = panel.querySelector('#ab-cv-status-msg'); if (status) { status.style.display = 'inline-block'; status.textContent = 'Stopping... completed passages will stay saved.'; } }; panel.querySelector('#ab-cv-cancel').addEventListener('click', () => { if (_audiobook.running) { _audiobook.cancel = true; setStoppingState(); if (typeof _audiobook.abort === 'function') _audiobook.abort(); return; } closePanel(); }); panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => { const p = panel.querySelector('#ab-cv-prompt-panel'); p.hidden = !p.hidden; const chevron = panel.querySelector('#ab-cv-prompt-chevron'); if (chevron) { chevron.className = p.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up'; } }); // Collapse the whole settings block (LLM engine + prompt) to free vertical space. (function () { const tBtn = panel.querySelector('#ab-cv-settings-toggle'); const sBox = panel.querySelector('#ab-cv-settings'); const chev = panel.querySelector('#ab-cv-settings-chevron'); function apply(collapsed) { if (sBox) sBox.style.display = collapsed ? 'none' : 'flex'; if (collapsed) { const p = panel.querySelector('#ab-cv-prompt-panel'); if (p) p.hidden = true; } if (chev) chev.className = 'mdi ' + (collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'); } let collapsed = false; try { collapsed = localStorage.getItem('ttsvc_ab_settings_collapsed') === '1'; } catch (_) {} apply(collapsed); if (tBtn) tBtn.addEventListener('click', () => { collapsed = !collapsed; try { localStorage.setItem('ttsvc_ab_settings_collapsed', collapsed ? '1' : '0'); } catch (_) {} apply(collapsed); }); })(); // Collapse the character sidebar to just avatar dots when space is tight, // or you just want more room for the script itself. (function () { const side = panel.querySelector('#ab-cv-side'); const body = panel.querySelector('.ab-cv-body'); const cBtn = panel.querySelector('#ab-cv-side-collapse'); function apply(collapsed) { if (side) side.classList.toggle('is-collapsed', collapsed); if (body) body.classList.toggle('side-collapsed', collapsed); if (cBtn) cBtn.querySelector('.mdi').className = 'mdi ' + (collapsed ? 'mdi-chevron-left' : 'mdi-chevron-right'); if (cBtn) cBtn.title = collapsed ? 'Expand character list' : 'Collapse to avatars'; } let collapsed = false; try { collapsed = localStorage.getItem('ttsvc_ab_side_collapsed') === '1'; } catch (_) {} apply(collapsed); if (cBtn) cBtn.addEventListener('click', () => { collapsed = !collapsed; try { localStorage.setItem('ttsvc_ab_side_collapsed', collapsed ? '1' : '0'); } catch (_) {} apply(collapsed); }); })(); // Prompt Library logic let savedPrompts = []; try { savedPrompts = JSON.parse(localStorage.getItem('ttsvc_ab_prompts') || '[]'); } catch (_) { savedPrompts = []; } const libSelect = panel.querySelector('#ab-cv-prompt-lib'); const delBtn = panel.querySelector('#ab-cv-prompt-del'); const promptText = panel.querySelector('#ab-cv-prompt-text'); const renderPromptLib = (selectedIdx = -1) => { libSelect.innerHTML = '' + savedPrompts.map((p, i) => ``).join(''); if (selectedIdx >= 0) { libSelect.value = selectedIdx; delBtn.style.display = 'block'; } else { libSelect.value = ''; delBtn.style.display = 'none'; } }; renderPromptLib(); libSelect.addEventListener('change', () => { const idx = parseInt(libSelect.value); const nameInput = panel.querySelector('#ab-cv-prompt-name'); if (!isNaN(idx) && savedPrompts[idx]) { promptText.value = savedPrompts[idx].prompt; if (nameInput) nameInput.value = savedPrompts[idx].name; delBtn.style.display = 'block'; } else { if (nameInput) nameInput.value = ''; delBtn.style.display = 'none'; } }); delBtn.addEventListener('click', () => { const idx = parseInt(libSelect.value); if (isNaN(idx)) return; if (confirm('Delete this saved prompt preset?')) { savedPrompts.splice(idx, 1); localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts)); renderPromptLib(); toast('Prompt deleted', 'success'); } }); panel.querySelector('#ab-cv-prompt-save').addEventListener('click', async () => { const val = promptText.value.trim(); if (!val) { toast('Prompt is empty', 'error'); return; } const nameInput = panel.querySelector('#ab-cv-prompt-name'); const name = nameInput.value.trim() || 'Custom Prompt ' + (savedPrompts.length + 1); let targetIdx = parseInt(libSelect.value); if (!isNaN(targetIdx) && savedPrompts[targetIdx] && savedPrompts[targetIdx].name === name) { savedPrompts[targetIdx].prompt = val; } else { savedPrompts.push({ name, prompt: val }); targetIdx = savedPrompts.length - 1; } localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts)); renderPromptLib(targetIdx); toast('Prompt preset saved', 'success'); // Also save as global default for next time if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val; try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) {} }); // Sync the LLM engine dropdown with global options const syncEngineSelect = () => { const localSel = panel.querySelector('#ab-cv-llm-url'); const globalSel = document.getElementById('llm-active-url'); if (globalSel && localSel) { const currentVal = localSel.value; localSel.innerHTML = globalSel.innerHTML; localSel.value = currentVal || globalSel.value || ''; } }; syncEngineSelect(); // Fetch available models and populate the select drop-down const loadModels = () => { const urlInput = panel.querySelector('#ab-cv-llm-url'); const sel = panel.querySelector('#ab-cv-llm-select'); if (!sel || !urlInput) return; const currentUrl = urlInput.value.trim(); const oldVal = sel.value; sel.innerHTML = ''; fetch('/api/conversation/llm-models' + (currentUrl ? '?url=' + encodeURIComponent(currentUrl) : '')) .then(r => r.json()) .then(d => { if (d.models && d.models.length) { const preferred = audiobookSafeLlmModel(oldVal || defaultModel); sel.innerHTML = d.models.map(m => ``).join(''); if (preferred && d.models.includes(preferred)) sel.value = preferred; else if (oldVal && d.models.includes(oldVal) && !audiobookIsRouterModel(oldVal)) sel.value = oldVal; else if (d.models.includes(defaultModel)) sel.value = defaultModel; } else { sel.innerHTML = ``; } }).catch(() => { sel.innerHTML = ``; }); }; loadModels(); panel.querySelector('#ab-cv-llm-refresh').addEventListener('click', loadModels); const urlInput = panel.querySelector('#ab-cv-llm-url'); if (urlInput) urlInput.addEventListener('change', loadModels); const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; const choice = audiobookCurrentCastLlm(panel); const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { if (callback) callback(savedChoice.url, savedChoice.model); }); }; if (isIdle) { const hasSegs = _audiobook.segments && _audiobook.segments.length > 0; panel.querySelector('#ab-cv-status-msg').style.display = 'inline-block'; if (hasSegs) { const recastUnkBtn = panel.querySelector('#ab-cv-start-recast-unk'); const recastBtn = panel.querySelector('#ab-cv-start-recast'); recastUnkBtn.style.display = 'inline-block'; recastBtn.style.display = 'inline-block'; recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); recastBtn.addEventListener('click', async () => { const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true }); if (!ok) return; applyPromptAndRun(audiobookCast); }); panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to identify characters.'; } else { const castBtn = panel.querySelector('#ab-cv-start-cast'); castBtn.style.display = 'inline-block'; castBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast)); } } else { panel.querySelector('#ab-cv-cancel').innerHTML = ' Stop Casting'; } const fill = panel.querySelector('#ab-cv-fill'), count = panel.querySelector('#ab-cv-count'); const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars'); const roster = new Map(); // name -> { count, color } const characterRecords = new Map(); // lower-case name -> character-library record // Like clIdentityNames (characters-library.js) but preserves original case — // highlightText dedups and colour-keys by the display-cased name. The token // splitting itself is the library's canonical clSplitIdentityTokens. const identityNames = (recOrSheet) => { const s = recOrSheet?.sheet || recOrSheet || {}; const out = new Set(); const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x)); add(recOrSheet?.name || s.name); ['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' })); return [...out]; }; let _hlVer = 0; // bumped whenever character records change let _hlCache = { ver: -1, rosterSize: -1, regex: null, byLower: new Map() }; const registerCharacterRecord = (rec) => { if (!rec?.name) return; _hlVer++; for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec); }; const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null; const colorFor = (name, rec) => { const key = String(name || '').toLowerCase(); const stored = rec || characterRecords.get(key); const color = stored ? _abRecordColor(stored, name) : _abDefaultCharacterColor(name); if (!roster.has(name)) roster.set(name, { count: 0, color }); else roster.get(name).color = color; return roster.get(name).color; }; // Small round avatar for character pickers/lists — the character's saved // portrait (same source as the profile detail panel) when one exists, // otherwise the colour-keyed initial-letter dot used everywhere else. const _abAvatarHtml = (name, color) => { const img = recordForName(name)?.image; const c = color || colorFor(name); return img ? `` : `${escHtml((name || '?')[0].toUpperCase())}`; }; const setCharacterColor = (name, color) => { const safe = _abNormalizeColor(color, name); const info = roster.get(name); if (info) info.color = safe; const key = String(name || '').toLowerCase(); const rec = characterRecords.get(key); if (rec) { rec.color = safe; if (rec.sheet) rec.sheet.color = safe; } return safe; }; const highlightText = (text) => { if (!text) return ''; let html = escHtml(text); // The name list + compiled regexes are invariant between segments until the // roster or character records change — rebuilding them per segment made // rendering O(segments × records) over a whole book. Colours stay live via // colorFor at replace time. if (_hlCache.ver !== _hlVer || _hlCache.rosterSize !== roster.size) { const extraNames = []; new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec))); const seen = new Set(); const names = []; for (const n of [...roster.keys(), ...extraNames]) { const k = n.toLowerCase(); if (n.length < 2 || k === 'narrator' || /^unknown|unbekannt/i.test(k) || seen.has(k)) continue; seen.add(k); names.push(n); } names.sort((a, b) => b.length - a.length); // ONE combined alternation (longest-first) applied in a single pass — // replacing name-by-name re-scanned HTML that already contained the // injected spans, so a shorter alias ("Larissa") could match inside a // longer name's data-name attribute ("Schwester Larissa") and corrupt // the markup, leaking raw style="..." text into the visible feed. const pattern = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); _hlCache = { ver: _hlVer, rosterSize: roster.size, regex: pattern ? new RegExp(`\\b(${pattern})\\b`, 'gi') : null, byLower: new Map(names.map(n => [n.toLowerCase(), n])), }; } if (_hlCache.regex) { html = html.replace(_hlCache.regex, (match) => { const canon = _hlCache.byLower.get(match.toLowerCase()) || match; return `${match}`; }); } return html; }; // ── Character selection bar ──────────────────────────────────────────────── // Clicking a character shows a compact bar above the feed with avatar, name, // prev/next line navigation, a search box, and a "Profil" button that opens // the full character sheet (hiding the feed temporarily). const feedWrap = panel.querySelector('.ab-cv-feed-wrap'); const jumpBtn = panel.querySelector('#ab-cv-quotes')?.addEventListener('click', () => audiobookToggleQuotes()); { const _qb = panel.querySelector('#ab-cv-quotes'); if (_qb) { _qb.classList.toggle('is-on', _abShowQuotes); _qb.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; } } panel.querySelector('#ab-cv-jump-btn'); // Compact toolbar above the feed: selected character, edit history, page nav. const _abTopbar = document.createElement('div'); _abTopbar.className = 'ab-cv-topbar'; feedWrap.insertBefore(_abTopbar, feedWrap.firstChild); const _abBar = document.createElement('div'); _abBar.className = 'ab-char-bar'; _abBar.hidden = true; _abTopbar.appendChild(_abBar); const _abEditToolbar = document.createElement('div'); _abEditToolbar.className = 'ab-edit-toolbar'; _abEditToolbar.innerHTML = ` `; _abTopbar.appendChild(_abEditToolbar); const _abPageNav = document.createElement('div'); _abPageNav.className = 'ab-page-nav'; _abPageNav.hidden = true; _abPageNav.innerHTML = ` `; _abTopbar.appendChild(_abPageNav); // Font-size controls for the feed's paper pages. Shares the Rehearser // Stage's persisted scale (ttsvc_reh_stage_scale) because .ab-cv-page's // font-size is already calc(12pt * var(--reh-stage-scale)) from the shared // paper spec — one reading size across both screens. const _abFontCtl = document.createElement('div'); _abFontCtl.className = 'ab-font-ctl'; _abFontCtl.innerHTML = ` `; _abTopbar.appendChild(_abFontCtl); { const _qb = _abFontCtl.querySelector('#ab-cv-quotes'); if (_qb) { _qb.addEventListener('click', () => audiobookToggleQuotes()); _qb.classList.toggle('is-on', _abShowQuotes); _qb.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; } } const _abApplyFont = () => { let v = 1; try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {} feed.style.setProperty('--reh-stage-scale', String(v)); }; const _abFontStep = (d) => { let v = 1; try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {} v = Math.round(Math.min(2, Math.max(0.7, v + d)) * 100) / 100; try { localStorage.setItem('ttsvc_reh_stage_scale', String(v)); } catch (_) {} _abApplyFont(); if (typeof rehApplyStageFont === 'function') rehApplyStageFont(); // keep the Rehearser Stage in sync too }; _abFontCtl.querySelector('.ab-font-dec').addEventListener('click', () => _abFontStep(-0.1)); _abFontCtl.querySelector('.ab-font-inc').addEventListener('click', () => _abFontStep(0.1)); _abApplyFont(); let _abCharRows = []; // rows in feed for selected character let _abNavIdx = 0; let _abDetailEl = null; // full profile panel (when Profil is open) let _abCurrentPageNum = 1; const _abClearHL = () => { feed.querySelectorAll('.ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus') .forEach(r => r.classList.remove('ab-char-hl', 'ab-char-focus')); }; const _abCloseProfile = () => { if (_abDetailEl) { _abDetailEl.remove(); _abDetailEl = null; } feed.style.display = ''; if (jumpBtn && typeof _userScrolled !== 'undefined') jumpBtn.hidden = !_userScrolled; _abCharRows.forEach(r => r.classList.add('ab-char-hl')); const btn = _abBar.querySelector('.ab-char-bar-profile'); if (btn) btn.innerHTML = ' Profil'; }; const _abCloseBar = () => { _abBar.hidden = true; _abBar.innerHTML = ''; _abCloseProfile(); _abClearHL(); _abCharRows = []; chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.remove('is-active')); if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); }; const _abNav = (dir, pool) => { const rows = pool || _abCharRows; if (!rows.length) return; _abNavIdx = ((_abNavIdx + dir) + rows.length) % rows.length; feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus')); rows[_abNavIdx].classList.add('ab-char-focus'); _abScrollIntoView(rows[_abNavIdx], { block: 'center' }); const pos = _abBar.querySelector('.ab-char-bar-pos'); if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length; }; const _abRefreshCharacterColors = (name) => { const names = name ? [name] : [...roster.keys()]; for (const n of names) colorFor(n); feed.querySelectorAll('.ab-cv-row').forEach(row => { const seg = row.__seg; if (!seg) return; const isNarrator = seg.type !== 'dialogue' || !seg.speaker || seg.speaker.toLowerCase() === 'narrator'; const speakerName = isNarrator ? 'Narrator' : seg.speaker; const c = colorFor(speakerName); const spk = row.querySelector('.ab-cv-spk'); const txt = row.querySelector('.ab-cv-txt'); if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c; if (txt) txt.innerHTML = _abMarkQuotes(highlightText(_abDisplayText(seg))); }); renderRoster(); const selected = _abBar.hidden ? null : _abBar.dataset.charName; if (selected) { const dot = _abBar.querySelector('.ab-char-bar-dot'); if (dot) dot.style.background = colorFor(selected); } }; // Open the full character profile (hides feed) const _abShowProfile = async (name) => { _abCloseProfile(); feed.style.display = 'none'; if (jumpBtn) jumpBtn.hidden = true; const profileBtn = _abBar.querySelector('.ab-char-bar-profile'); if (profileBtn) profileBtn.innerHTML = ' Skript'; const title = window.readerState?.title || ''; let rec = null; try { const all = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(title) : (typeof clGetAll === 'function' ? await clGetAll() : []); for (const r of all || []) registerCharacterRecord(r); rec = recordForName(name); } catch (_) {} const detail = document.createElement('div'); detail.className = 'ab-char-detail-panel'; _abDetailEl = detail; if (!rec) { detail.innerHTML = '' + '
Kein Charakterblatt – zuerst „Cast all character roles“ ausführen.
'; feedWrap.appendChild(detail); detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); return; } registerCharacterRecord(rec); const sh = rec.sheet || {}; const charColor = setCharacterColor(rec.name, rec.color || sh.color || colorFor(rec.name, rec)); const accent2 = clHslToHex((clNameHue(rec.name) + 40) % 360, 58, 30); const tier = String(sh.tier || '').toLowerCase(); const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur'; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const lineCount = _abStr(sh.line_count); const pct = rec.sheet?.moral_alignment_score != null ? Math.max(0, Math.min(100, rec.sheet.moral_alignment_score)) : null; const arcMap = { 'good-to-bad':'↘ Entwicklung zum Bösen','bad-to-good':'↗ Wandel zum Guten','complex':'↕ Komplex','stable-good':'→ Stabil gut','stable-bad':'→ Stabil böse','neutral':'→ Neutral' }; const avatarHtml = rec.image ? `
${escHtml(rec.name)}
` : `
${escHtml((rec.name||'?')[0].toUpperCase())}
`; // field(label, value, sheet-key) — key enables editing when pencil is active const field = (lbl, val, sk) => { const v = _abStr(val); if (!v && !sk) return ''; return `
${lbl}
${escHtml(v)}
`; }; // Sources section const srcItems = Array.isArray(rec.sources) ? rec.sources : []; const sourcesHtml = srcItems.length ? `
${srcItems.map(s => `Seite ${s.page||'?'}`).join('')}
` : ''; detail.innerHTML = `
${avatarHtml}
${escHtml(rec.name)}
${_abStr(sh.title) ? `
${escHtml(_abStr(sh.title))}
` : ''} ${_abStr(sh.archetype) ? `
${escHtml(_abStr(sh.archetype))}
` : ''}
${tierLabel}${lineCount ? `${escHtml(lineCount)} lines` : ''}
${voiceId ? escHtml(voiceId) : 'Noch keine Stimme'}
${pct != null ? `
Böse
Gut
${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)
` : ''}
${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Geschlecht',sh.gender,'gender')}${field('Titel',sh.title,'title')}${field('Beruf / Rolle',sh.profession,'profession')}${field('Auch bekannt als',sh.aliases,'aliases')}
${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}
${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}
${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}
${field('Fertigkeiten',sh.skills,'skills')}${field('Besondere Fähigkeiten',sh.capabilities,'capabilities')}
${field('',sh.relationships,'relationships')}
${sourcesHtml}
`; feedWrap.appendChild(detail); detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); detail.querySelector('.ab-cd-color input')?.addEventListener('input', async e => { const color = setCharacterColor(rec.name, e.target.value); rec.color = color; if (!rec.sheet) rec.sheet = {}; rec.sheet.color = color; detail.querySelector('.lcd-header')?.style.setProperty('--lc1', color); const av = detail.querySelector('.lcd-avatar'); if (av && !av.querySelector('img')) av.style.background = color; _abRefreshCharacterColors(rec.name); clearTimeout(_audiobook._colorSaveTimer); _audiobook._colorSaveTimer = setTimeout(async () => { try { if (typeof clPut === 'function') await clPut(rec); } catch (_) {} }, 400); }); detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => { if (typeof _openVoicePicker === 'function') _openVoicePicker(detail.querySelector('.lcd-voice-top'), rec, async () => { const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id); if (up) { _abCloseProfile(); _abShowProfile(up.name); } }); }); detail.querySelector('.ab-cd-auto')?.addEventListener('click', async () => { if (typeof _autoAssignVoice === 'function') { await _autoAssignVoice(rec); const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id); if (up) { _abCloseProfile(); _abShowProfile(up.name); } } }); detail.querySelector('.ab-cd-online')?.addEventListener('click', () => { if (typeof _charSearchOnline === 'function') _charSearchOnline(rec); }); detail.querySelector('.ab-cd-refine-btn')?.addEventListener('click', async () => { const name = String(rec.name || '').trim(); if (!name) return; if (typeof window.audiobookRecastSelectedCharacters === 'function') return window.audiobookRecastSelectedCharacters([name]); if (typeof window.csForReaderSelective === 'function') return window.csForReaderSelective([name]); toast('Character refinement is unavailable right now', 'error'); }); // Pencil edit toggle // One timer PER FIELD, not a single shared one — a shared timer meant // editing field A then switching to field B within 900ms cancelled A's // still-pending save with nothing to replace it, silently dropping that // edit (same bug already fixed once this session in the Library's own // detail page — this is the casting view's separate copy of it). const _saveTimers = new Map(); const editBtn = detail.querySelector('.ab-cd-edit-btn'); editBtn?.addEventListener('click', function () { const editing = detail.classList.toggle('ab-cd-editing'); this.innerHTML = editing ? '' : ''; this.title = editing ? 'Fertig' : 'Charakterblatt bearbeiten'; detail.querySelectorAll('.ab-editable').forEach(el => { el.contentEditable = editing ? 'true' : 'false'; if (editing) { el.addEventListener('input', function onInput() { const sk = el.dataset.sk; clearTimeout(_saveTimers.get(sk)); _saveTimers.set(sk, setTimeout(async () => { if (!rec.sheet) rec.sheet = {}; rec.sheet[sk] = el.textContent.trim(); if (['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(sk)) registerCharacterRecord(rec); if (typeof clPut === 'function') await clPut(rec); }, 900)); }); } }); if (editing) detail.querySelector('.ab-editable')?.focus(); }); // Source page clicks → jump to reader detail.querySelectorAll('.ab-cd-src').forEach(el => { el.addEventListener('click', () => { const pg = parseInt(el.dataset.page, 10); if (!pg) return; if (typeof navTo === 'function') navTo('s-reader'); setTimeout(() => { const pages = window.readerState?.pages; if (pages && pages.length >= pg && pages[pg-1]?.pageDiv) { _abScrollIntoView(pages[pg-1].pageDiv, { block: 'start' }); } else if (typeof toast === 'function') { toast('Buch im „Vorlesen"-Bereich öffnen, dann nochmal klicken', 'info'); } }, 300); }); }); }; // Select a character: show bar + highlight rows, keep feed visible const _abSelectChar = (name) => { // Toggle off if same character clicked again if (!_abBar.hidden && _abBar.dataset.charName === name) { _abCloseBar(); return; } _abCloseBar(); chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.toggle('is-active', el.dataset.name === name)); _abCharRows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r => r.__seg?.speaker && r.__seg.speaker.toLowerCase() === name.toLowerCase() ); _abNavIdx = 0; const color = colorFor(name); _abBar.dataset.charName = name; _abBar.hidden = false; _abSyncTopbar(); _abBar.innerHTML = `${(name||'?')[0].toUpperCase()}
${escHtml(name)} ${_abCharRows.length} Zeilen
1 / ${_abCharRows.length}
`; // highlight all + jump to first _abCharRows.forEach(r => r.classList.add('ab-char-hl')); if (_abCharRows.length) { _abCharRows[0].classList.add('ab-char-focus'); _abScrollIntoView(_abCharRows[0], { block: 'center' }); } _abBar.querySelector('.ab-char-bar-prev').addEventListener('click', () => _abNav(-1)); _abBar.querySelector('.ab-char-bar-next').addEventListener('click', () => _abNav(1)); _abBar.querySelector('.ab-char-bar-profile').addEventListener('click', () => { if (_abDetailEl) _abCloseProfile(); else _abShowProfile(name); }); _abBar.querySelector('.ab-char-bar-close').addEventListener('click', _abCloseBar); // Search within this character's rows const inp = _abBar.querySelector('.ab-char-bar-search'); let _st = null; const _doSearch = (jump) => { const q = inp.value.trim().toLowerCase(); const pool = q ? _abCharRows.filter(r => (r.__seg?.text || r.querySelector('.ab-cv-txt')?.textContent || '').toLowerCase().includes(q) ) : _abCharRows; feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus')); if (pool.length) { if (jump) _abNavIdx = (_abNavIdx + 1) % pool.length; else _abNavIdx = 0; pool[_abNavIdx].classList.add('ab-char-focus'); _abScrollIntoView(pool[_abNavIdx], { block: 'center' }); _abBar.querySelector('.ab-char-bar-pos').textContent = (_abNavIdx + 1) + ' / ' + pool.length; } else { _abBar.querySelector('.ab-char-bar-pos').textContent = '0 Treffer'; } }; inp.addEventListener('input', () => { clearTimeout(_st); _st = setTimeout(() => _doSearch(false), 200); }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } }); }; let _abRosterFilter = ''; let _abRosterSort = 'count'; const renderRoster = () => { const q = _abRosterFilter.trim().toLowerCase(); let items = [...roster.entries()].filter(([n, info]) => info.count > 0 && (!q || n.toLowerCase().includes(q))); items.sort(_abRosterSort === 'alpha' ? (a, b) => a[0].localeCompare(b[0]) : (a, b) => b[1].count - a[1].count); if (!items.length) { // A bare "reading…" label reads as static text, not as "still working" — // reuse the same skeleton rows shown on initial panel load so a live cast // with no characters found yet visibly looks like it's loading. chars.innerHTML = q ? `No matches` : `
${ [38,62,45,28,54,35].map(w => `
`).join('') }
`; return; } const prev = _abBar.hidden ? null : _abBar.dataset.charName; chars.innerHTML = items.map(([n, info]) => { const color = info.color || colorFor(n); return `
${_abAvatarHtml(n, color)} ${escHtml(n)} ${roster.get(n)?.count||0}
`; }).join(''); chars.querySelectorAll('.ab-char-item').forEach(item => { item.addEventListener('click', () => _abSelectChar(item.dataset.name)); }); chars.querySelectorAll('.ab-char-alias-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); _abOpenAliasPopup(btn.dataset.name, btn); }); }); }; (() => { const searchInp = panel.querySelector('#ab-cv-side-search'); const sortSel = panel.querySelector('#ab-cv-side-sort'); try { _abRosterSort = localStorage.getItem('ttsvc_ab_side_sort') || 'count'; } catch (_) {} if (sortSel) sortSel.value = _abRosterSort; let _t = null; searchInp?.addEventListener('input', () => { clearTimeout(_t); _t = setTimeout(() => { _abRosterFilter = searchInp.value; renderRoster(); }, 120); }); sortSel?.addEventListener('change', () => { _abRosterSort = sortSel.value; try { localStorage.setItem('ttsvc_ab_side_sort', _abRosterSort); } catch (_) {} renderRoster(); }); })(); // Quick "also known as" shortcut right in the sidebar, so adding an alias // (e.g. "Garthai" for "Sharraz Garthai", or "Ork" for a role-only speaker) // doesn't require leaving the casting screen for the full Character Library // editor. Writes through the same clUpsert used everywhere else, so the // alias is immediately shared with Rehearser/Character sheets too. let _abAliasPopup = null; function _abCloseAliasPopup() { if (_abAliasPopup) { _abAliasPopup.remove(); _abAliasPopup = null; } } function _abOpenAliasPopup(name, anchorEl) { _abCloseAliasPopup(); // Other already-recognized roster names, offered as pick-to-merge targets // (e.g. "Darag" also known as "Schmied" — the LLM split one character // into two roster entries) instead of only accepting a free-text alias. const otherNames = [...roster.entries()] .filter(([n, info]) => info.count > 0 && n.toLowerCase() !== name.toLowerCase() && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n)) .sort((a, b) => b[1].count - a[1].count); const listId = 'ab-alias-roster-list'; const el = document.createElement('div'); el.className = 'ab-alias-popup'; el.innerHTML = `
Also known as — ${escHtml(name)}
${otherNames.map(([n, info]) => ``).join('')} ${otherNames.length ? `
Or merge with an already-found character:
${otherNames.slice(0, 8).map(([n, info]) => ``).join('')}
` : ''}
`; document.body.appendChild(el); const rect = anchorEl.getBoundingClientRect(); el.style.left = Math.min(rect.left, window.innerWidth - 280) + 'px'; el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 340) + 'px'; const inp = el.querySelector('.ab-alias-popup-inp'); setTimeout(() => inp.focus(), 30); const otherByLower = new Map(otherNames.map(([n]) => [n.toLowerCase(), n])); const doSave = async (mergeName) => { const alias = (mergeName || inp.value.trim()); if (!alias) { _abCloseAliasPopup(); return; } const mergeTarget = otherByLower.get(alias.toLowerCase()); _abCloseAliasPopup(); if (mergeTarget) { // Merging rewrites every matching segment (fast, plain array loop) — // only THOSE rows need to be redrawn (a name change/recolor), not the // whole feed. Rebuilding all thousands of rows for a 2-line merge is // what used to make even a tiny merge take as long as opening a // freshly cast book, and freeze the tab along the way. The affected // rows are scattered across the feed rather than contiguous, so // they're patched in place (_abPatchScatteredRows) instead of a full // redraw, still chunked across animation frames for the rare case of // a merge touching hundreds of lines. const busy = _abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}…`, true); await new Promise(r => requestAnimationFrame(r)); try { const book = window.readerState?.title || ''; const rec = await clUpsert(book, { name, aliases: alias }); if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; } const { changed: n, segs } = _abMergeCharacters(mergeTarget, name); if (n) { const patched = await _abPatchScatteredRows(segs, (done, total) => busy.setProgress(done, total)); if (!patched) await _abRedrawSegmentsChunked(_abActiveSegments().arr, (done, total) => busy.setProgress(done, total)); _abPersistManualEdit(); } toast(n ? `Merged "${mergeTarget}" into ${name} (${n} line${n !== 1 ? 's' : ''})` : `"${alias}" added as an alias for ${name}`, 'success'); } catch (err) { toast('Could not merge: ' + (err.message || err), 'error'); } finally { busy.remove(); } } else { try { const book = window.readerState?.title || ''; const rec = await clUpsert(book, { name, aliases: alias }); if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; } renderRoster(); toast(`"${alias}" added as an alias for ${name}`, 'success'); } catch (err) { toast('Could not save alias: ' + (err.message || err), 'error'); } } }; el.querySelector('.ab-alias-save').addEventListener('click', () => doSave()); el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup()); el.querySelectorAll('.ab-alias-merge-opt').forEach(btn => { btn.addEventListener('click', () => doSave(btn.dataset.name)); }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); doSave(); } else if (e.key === 'Escape') { e.preventDefault(); _abCloseAliasPopup(); } }); setTimeout(() => { document.addEventListener('click', function onDoc(e) { if (!el.contains(e.target) && e.target !== anchorEl) { _abCloseAliasPopup(); document.removeEventListener('click', onDoc); } }); }, 0); _abAliasPopup = el; } // Reassign every segment currently attributed to fromName over to intoName // — used when the LLM split one character into two roster entries (e.g. // "Darag" / "Schmied" for the same person) and the user picks the other // entry from the alias popup instead of typing a plain-text alias. // Only relabels segments (fast, plain array loop) — the caller is // responsible for redrawing the feed afterward, since on a big book that // part is the slow one and needs to run through the chunked path with // visible progress instead of blocking the main thread outright. function _abMergeCharacters(fromName, intoName) { const active = _abActiveSegments(); if (!active.arr.length) return { changed: 0, arr: active.arr, segs: [] }; _abPushEditState(active.key, active.arr); let changed = 0; const segs = []; for (const s of active.arr) { if (s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() === fromName.toLowerCase()) { s.speaker = intoName; changed++; segs.push(s); } } return { changed, arr: active.arr, segs }; } (async () => { const title = window.readerState?.title || ''; try { const records = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(title) : (typeof clGetAll === 'function' ? await clGetAll() : []); for (const rec of records || []) { if (!rec?.name) continue; registerCharacterRecord(rec); for (const alias of identityNames(rec)) { const rosterName = [...roster.keys()].find(n => n.toLowerCase() === alias.toLowerCase()); if (rosterName) setCharacterColor(rosterName, _abRecordColor(rec, rec.name)); } } _abRefreshCharacterColors(); } catch (_) {} })(); const MAXROWS = Number.POSITIVE_INFINITY; // keep the full completed cast available for review/page navigation // Whether the user has scrolled up to review/edit earlier content. // While true: auto-scroll and trim are suppressed so their position is preserved. let _userScrolled = false; // jumpBtn already declared above (in the character-detail block) feed.addEventListener('scroll', () => { const atBottom = feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 80; _userScrolled = !atBottom; if (jumpBtn) jumpBtn.hidden = atBottom; const labels = Array.from(feed.querySelectorAll('.ab-cv-page-label[data-page]')); let current = _abCurrentPageNum; for (const label of labels) { const r = label.closest('.ab-cv-page')?.getBoundingClientRect(); const fr = feed.getBoundingClientRect(); if (r && r.top <= fr.top + 80) current = Number(label.dataset.page) || current; else break; } if (current) _abSetCurrentPage(current); }, { passive: true }); const _scrollToBottom = () => { feed.scrollTop = feed.scrollHeight; }; if (jumpBtn) { jumpBtn.addEventListener('click', () => { _userScrolled = false; jumpBtn.hidden = true; _scrollToBottom(); }); } const trim = () => { if (_userScrolled) return; // don't touch the feed while user is reading/editing // MAXROWS is permanently Infinity (see above — the full cast is kept for // review), so the removal loop below can never actually run. It still // used to query the ENTIRE feed (`querySelectorAll` over every row/note/ // divider in the whole book so far) on every single call regardless — // confirmed live as a real O(n) cost repeated on every ~80-segment // batch for the whole rest of the book, compounding into the kind of // quadratic slowdown that crashed the tab partway through a long book. // Only do that work at all if MAXROWS could ever actually be finite. if (Number.isFinite(MAXROWS)) { let rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); while (rows.length > MAXROWS) { const first = rows[0]; const page = first.closest('.ab-cv-page'); first.remove(); if (page && !page.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) page.remove(); rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); } } _scrollToBottom(); }; // Group casting feed content into per-page cards (white "paper" on the feed's // canvas background) so long books read visually like a paginated document // instead of one unbroken scroll. pagemark() starts a new card; everything // else appends into whichever card is currently open. let _abCurPage = null; const _abJumpToReaderPage = async (pageNum) => { const pg = parseInt(pageNum, 10); if (!pg) return; if (typeof window.readerJumpToPage === 'function') { await window.readerJumpToPage(pg); return; } if (typeof navTo === 'function') navTo('s-reader'); if (typeof window.navReaderView === 'function') window.navReaderView('main'); else if (typeof window.showReaderView === 'function') window.showReaderView('main'); setTimeout(async () => { const pageDiv = window.readerState?.pages?.[pg - 1]?.pageDiv; if (pageDiv) { if (typeof window.readerRenderPage === 'function') await window.readerRenderPage(pg - 1); _abScrollIntoView(pageDiv, { block: 'start', inline: 'nearest' }); } else if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info'); }, 250); }; const _abNewPage = (label, pageNum) => { // Drop the previous card if a page boundary produced no content at all // (e.g. a blank page), so we don't leave an empty white box in the feed. if (_abCurPage && !_abCurPage.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) _abCurPage.remove(); const p = document.createElement('div'); p.className = 'ab-cv-page'; if (label) { const pageAttr = pageNum ? ` data-page="${escHtml(String(pageNum))}"` : ''; p.innerHTML = ``; } feed.appendChild(p); _abCurPage = p; if (pageNum) { _abSetCurrentPage(pageNum); _abUpdatePageNav(); } return p; }; const _abPage = () => _abCurPage || _abNewPage(); const _abPageNumbers = () => { const set = new Set(); const readerPages = window.readerState?.mode === 'pdf' ? (window.readerState?.pages?.length || 0) : 0; if (readerPages > 0) { for (let i = 1; i <= readerPages; i++) set.add(i); } else { (_audiobook.pageMarks || []).forEach(m => set.add((m.page || 0) + 1)); } feed.querySelectorAll('.ab-cv-page-label[data-page]').forEach(el => { const n = parseInt(el.dataset.page, 10); if (n) set.add(n); }); return [...set].sort((a, b) => a - b); }; const _abSetCurrentPage = (pageNum) => { const pg = parseInt(pageNum, 10); if (!pg) return; _abCurrentPageNum = pg; const sel = _abPageNav.querySelector('.ab-page-select'); if (sel && [...sel.options].some(o => Number(o.value) === pg)) sel.value = String(pg); }; const _abUpdatePageNav = () => { const pages = _abPageNumbers(); const sel = _abPageNav.querySelector('.ab-page-select'); if (!sel || !pages.length) { _abPageNav.hidden = true; if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); return; } const old = Number(sel.value) || _abCurrentPageNum || pages[0]; sel.innerHTML = pages.map(n => ``).join(''); _abPageNav.hidden = pages.length <= 1; _abSetCurrentPage(pages.includes(old) ? old : pages[0]); if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); }; const _abScrollToCastPage = (pageNum) => { const pg = parseInt(pageNum, 10); if (!pg) return; let label = feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`); if (!label) { const active = _abActiveSegments(); if (active.arr.length) _abRedrawSegments(active.arr); label = feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`); } if (pg === 1) { _userScrolled = true; if (jumpBtn) jumpBtn.hidden = false; const firstPage = label?.closest('.ab-cv-page') || feed.firstElementChild; if (firstPage) _abScrollIntoView(firstPage, { block: 'start' }); else feed.scrollTo({ top: 0, behavior: 'smooth' }); _abSetCurrentPage(pg); return; } if (!label) { toast('This cast page is not available yet. Use the book button to jump to the source page.', 'info'); _abSetCurrentPage(pg); return; } _userScrolled = true; if (jumpBtn) jumpBtn.hidden = false; _abScrollIntoView(label.closest('.ab-cv-page'), { block: 'start' }); _abSetCurrentPage(pg); }; const _abStepPage = (dir) => { const pages = _abPageNumbers(); if (!pages.length) return; const current = Number(_abPageNav.querySelector('.ab-page-select')?.value) || _abCurrentPageNum || pages[0]; const idx = Math.max(0, pages.indexOf(current)); const next = pages[Math.max(0, Math.min(pages.length - 1, idx + dir))]; _abScrollToCastPage(next); }; _abPageNav.querySelector('.ab-page-select')?.addEventListener('change', e => _abScrollToCastPage(e.target.value)); _abPageNav.querySelector('.ab-page-prev')?.addEventListener('click', () => _abStepPage(-1)); _abPageNav.querySelector('.ab-page-next')?.addEventListener('click', () => _abStepPage(1)); _abPageNav.querySelector('.ab-page-source')?.addEventListener('click', () => { const pg = Number(_abPageNav.querySelector('.ab-page-select')?.value) || _abCurrentPageNum; _abJumpToReaderPage(pg); }); const _abUndoStack = []; const _abRedoStack = []; const _abHistoryLimit = 40; const _abSyncTopbar = () => { const hasSelection = !_abBar.hidden; const hasHistory = _abUndoStack.length > 0 || _abRedoStack.length > 0; _abEditToolbar.hidden = !hasHistory; // Always visible — it hosts the font-size controls now, not just the // page nav / selection bar / undo history. _abTopbar.hidden = false; }; const _abActiveSegments = () => { if (_audiobook.running && Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments }; if (Array.isArray(_audiobook.segments)) return { key: 'segments', arr: _audiobook.segments }; if (Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments }; return { key: 'segments', arr: [] }; }; const _abCloneSegments = (segs) => (segs || []).map(s => ({ ...s })); const _abSyncRosterList = (segs) => { const names = []; for (const s of segs || []) { const sp = s?.type === 'dialogue' && s.speaker ? s.speaker : ''; if (!sp || /^Narrator$/i.test(sp) || /^Unknown|Unbekannt/i.test(sp)) continue; if (!names.some(n => n.toLowerCase() === sp.toLowerCase())) names.push(sp); } _audiobook.roster = names; }; const _abPersistManualEdit = () => { const active = _abActiveSegments(); if (_audiobook.lastText) { const done = _audiobook.completedChunks || active.arr.length; const total = _audiobook.completedTotal || done; setTimeout(() => _abSaveDraft(active.arr || [], _audiobook.roster || [], _audiobook.lastText, done, total), 50); } _audiobookDebouncedSave(); }; const _abUpdateEditButtons = () => { const undo = _abEditToolbar.querySelector('.ab-edit-undo'); const redo = _abEditToolbar.querySelector('.ab-edit-redo'); if (undo) undo.disabled = !_abUndoStack.length; if (redo) redo.disabled = !_abRedoStack.length; _abSyncTopbar(); }; const _abPushEditState = (keyOverride, arrOverride) => { const active = arrOverride ? { key: keyOverride || 'segments', arr: arrOverride } : _abActiveSegments(); if (!active.arr.length) return false; _abUndoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) }); while (_abUndoStack.length > _abHistoryLimit) _abUndoStack.shift(); _abRedoStack.length = 0; _abUpdateEditButtons(); return true; }; const _abRowSpeaker = (s) => { const isNarrator = s.type !== 'dialogue' || !s.speaker || String(s.speaker).toLowerCase() === 'narrator'; return { isNarrator, speakerName: isNarrator ? 'Narrator' : s.speaker }; }; // A narration segment that reads like a heading: short, standalone (a // paragraph of its own from OCR/paragraph detection), either a chapter-word // pattern or a few words with no sentence punctuation. Rendered bold, // larger, and centered so recovered chapter titles look like titles. const _abIsHeadingSeg = (s) => { if (s?.type === 'dialogue') return false; const t = String(s?.text || '').trim(); if (!t || t.length > 60) return false; if (/^(prolog(ue)?|epilog(ue)?|kapitel|chapter|teil|buch|part|book|akt|szene|scene)\b/i.test(t)) return true; if (/^\d+[\.\)]?\s*(kapitel|chapter)?$/i.test(t)) return true; return t.split(/\s+/).length <= 7 && !/[.!?;:,«»"]/.test(t); }; const _abRowFromSegment = (s, extraClass = '') => { const { isNarrator, speakerName } = _abRowSpeaker(s); const c = colorFor(speakerName); const row = document.createElement('div'); row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}${_abIsHeadingSeg(s) ? ' is-heading' : ''}`; row.__seg = s; row.innerHTML = ` ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''} ${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; // No per-row listeners here on purpose — with 1000+ segments in a big book, // attaching 2 extra listeners per row on every redraw (merge/split rebuilds // the whole feed) was measurably slower. Editing is wired once via event // delegation on `feed` instead (see _abStartRowEdit and its callers below). return row; }; // Like _abPatchRowRange, but for segments scattered across the feed instead // of one contiguous block (e.g. every line a merged character spoke, which // can be anywhere across a thousand-segment book). _abPatchRowRange inserts // all replacements before the FIRST old row, which would bunch scattered // rows together in the wrong place — this replaces each row in-place // instead, so a merge only touches the handful of rows that actually // changed rather than redrawing the entire feed. const _abPatchScatteredRows = (segs, onProgress) => new Promise(resolve => { if (!segs || !segs.length) { resolve(true); return; } const bySeg = new Set(segs); const targets = [...feed.querySelectorAll('.ab-cv-row')].filter(r => r.__seg && bySeg.has(r.__seg)); if (!targets.length) { resolve(false); return; } let i = 0; const step = () => { const end = Math.min(i + 150, targets.length); for (; i < end; i++) targets[i].replaceWith(_abRowFromSegment(targets[i].__seg)); if (onProgress) onProgress(i, targets.length); if (i < targets.length) { requestAnimationFrame(step); } else { _abRecountRoster(_abActiveSegments().arr); _abClearHL(); _abUpdatePageNav(); resolve(true); } }; requestAnimationFrame(step); }); const _abPatchRowRange = (oldRows, newSegs, activeArr) => { const rows = (oldRows || []).filter(Boolean); if (!rows.length) return false; const parent = rows[0].parentNode; if (!parent || rows.some(r => r.parentNode !== parent)) return false; const beforeCounts = _abRosterCountsFromSegs(rows.map(r => r.__seg).filter(Boolean)); const afterCounts = _abRosterCountsFromSegs(newSegs || []); const frag = document.createDocumentFragment(); for (const seg of newSegs || []) frag.appendChild(_abRowFromSegment(seg)); parent.insertBefore(frag, rows[0]); for (const row of rows) row.remove(); _abApplyRosterDelta(beforeCounts, afterCounts); _abQueueRosterRender(); _abClearHL(); _abUpdatePageNav(); return true; }; const _abAdjacentRow = (row, dir) => { let cur = dir < 0 ? row?.previousElementSibling : row?.nextElementSibling; while (cur && !(cur.classList && cur.classList.contains('ab-cv-row'))) { cur = dir < 0 ? cur.previousElementSibling : cur.nextElementSibling; } return cur || null; }; // Swap a row's text for a textarea (edit button click or double-click), // delegated from a single feed-level listener instead of per-row ones. const _abStartRowEdit = (row) => { if (!row || !row.__seg || row.querySelector('.ab-cv-edit-ta')) return; const s = row.__seg; const txtEl = row.querySelector('.ab-cv-txt'); if (!txtEl) return; const ta = document.createElement('textarea'); ta.className = 'ab-cv-edit-ta'; ta.value = s.text || ''; txtEl.replaceWith(ta); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); const commit = (save) => { if (save) { const val = ta.value; if (val !== s.text) { _abPushEditState(); s.text = val; _abPersistManualEdit(); } } ta.replaceWith(txtEl); txtEl.innerHTML = _abMarkQuotes(highlightText(_abDisplayText(s))); }; ta.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); } else if (e.key === 'Escape') { e.preventDefault(); commit(false); } }); ta.addEventListener('blur', () => commit(true)); }; const _abRosterCountsFromSegs = (segs) => { const counts = new Map(); for (const s of segs || []) { const { speakerName } = _abRowSpeaker(s || {}); counts.set(speakerName, (counts.get(speakerName) || 0) + 1); } return counts; }; const _abApplyRosterCounts = (counts) => { for (const info of roster.values()) info.count = 0; for (const [name, count] of counts || []) { const info = roster.get(name) || { count: 0, color: colorFor(name) }; info.count = count; roster.set(name, info); } _audiobook.roster = [...roster.entries()] .filter(([n, info]) => info.count > 0 && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n)) .map(([n]) => n); }; const _abApplyRosterDelta = (beforeCounts, afterCounts) => { const names = new Set([...(beforeCounts || new Map()).keys(), ...(afterCounts || new Map()).keys()]); for (const name of names) { const before = beforeCounts?.get(name) || 0; const after = afterCounts?.get(name) || 0; if (before === after) continue; const info = roster.get(name) || { count: 0, color: colorFor(name) }; info.count = Math.max(0, (info.count || 0) + (after - before)); roster.set(name, info); } _audiobook.roster = [...roster.entries()] .filter(([n, info]) => info.count > 0 && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n)) .map(([n]) => n); }; let _abRosterRenderRaf = 0; const _abQueueRosterRender = () => { if (_abRosterRenderRaf) return; _abRosterRenderRaf = requestAnimationFrame(() => { _abRosterRenderRaf = 0; renderRoster(); }); }; const _abRecountRoster = (segs) => { _abApplyRosterCounts(_abRosterCountsFromSegs(segs || [])); _abQueueRosterRender(); }; const _abRedrawSegments = (segs) => { const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : ''; feed.innerHTML = ''; _abCurPage = null; // Segments carry their own page number — stamped at cast time for new // casts, and back-filled once at draft load by _abStampSegmentPages for // drafts saved before the .page field existed. Rendering directly from it // (instead of re-guessing offsets from text on every redraw) is what fixed // pages silently merging after navigating away from the panel and back. let lastPage = null; for (const s of segs || []) { if (s.page != null && s.page !== lastPage) { _abNewPage('Page ' + s.page, s.page); lastPage = s.page; } _abPage().appendChild(_abRowFromSegment(s)); } _abRecountRoster(segs || []); _abClearHL(); _abUpdatePageNav(); if (selectedChar) { _abBar.hidden = true; _abSelectChar(selectedChar); } }; // Same as _abRedrawSegments, but builds the feed in batches across several // animation frames instead of one long synchronous loop — a full redraw on // a big book (thousands of rows, each running highlightText's regex pass) // blocks the main thread long enough to trigger the browser's own "Page // Unresponsive" warning. Used by operations that redraw the WHOLE feed at // once (character merge); the normal single-segment edits stay on the // plain synchronous path since those are cheap regardless. // Two overlapping calls used to both survive: each one's own closured // `i`/`list` kept its rAF chain running independently of the OTHER call's // `feed.innerHTML = ''`, so a second (perfectly legitimate, non-recursive) // trigger — e.g. Studio re-entering the Characters phase while an earlier // restore's rAF batches hadn't finished yet — raced the first and both // ended up appending their own full set of rows into the same feed. // Confirmed live: every row duplicated exactly once (same segment object, // two DOM nodes), which is exactly what two full, uncoordinated passes // over the same segment list would produce. A generation token lets each // in-flight chain notice it's been superseded and stop appending instead // of racing the newer one. let _abRedrawGen = 0; const _abRedrawSegmentsChunked = (segs, onProgress, batchSize = 150) => new Promise(resolve => { const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : ''; const myGen = ++_abRedrawGen; feed.innerHTML = ''; _abCurPage = null; const list = segs || []; let i = 0, lastPage = null; const step = () => { if (myGen !== _abRedrawGen) { resolve(); return; } // superseded by a newer redraw — stop const end = Math.min(i + batchSize, list.length); for (; i < end; i++) { const s = list[i]; if (s.page != null && s.page !== lastPage) { _abNewPage('Page ' + s.page, s.page); lastPage = s.page; } _abPage().appendChild(_abRowFromSegment(s)); } if (onProgress) onProgress(i, list.length); if (i < list.length) { requestAnimationFrame(step); } else { _abRecountRoster(list); _abClearHL(); _abUpdatePageNav(); if (selectedChar) { _abBar.hidden = true; _abSelectChar(selectedChar); } resolve(); } }; requestAnimationFrame(step); }); const _abRestoreEditState = (snap) => { if (!snap) return; const restored = _abCloneSegments(snap.segments); if (snap.key === 'liveSegments') _audiobook.liveSegments = restored; else _audiobook.segments = restored; _abRedrawSegments(restored); _abPersistManualEdit(); }; const _abUndoEdit = () => { const active = _abActiveSegments(); if (!_abUndoStack.length || !active.arr.length) return; _abRedoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) }); _abRestoreEditState(_abUndoStack.pop()); _abUpdateEditButtons(); }; const _abRedoEdit = () => { const active = _abActiveSegments(); if (!_abRedoStack.length || !active.arr.length) return; _abUndoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) }); _abRestoreEditState(_abRedoStack.pop()); _abUpdateEditButtons(); }; _abEditToolbar.querySelector('.ab-edit-undo')?.addEventListener('click', _abUndoEdit); _abEditToolbar.querySelector('.ab-edit-redo')?.addEventListener('click', _abRedoEdit); _abSyncTopbar(); // Manual character assignment logic let assignModeSeg = null; let assignModeRow = null; let assignPopup = document.getElementById('ab-cv-assign-popup'); if (!assignPopup) { assignPopup = document.createElement('div'); assignPopup.id = 'ab-cv-assign-popup'; assignPopup.style.cssText = 'position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;'; const header = document.createElement('div'); header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;'; header.innerHTML = 'Assign to'; assignPopup.appendChild(header); const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = 'Search or add character...'; inp.style.cssText = 'width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);'; assignPopup.appendChild(inp); const list = document.createElement('div'); list.className = 'ab-cv-popup-list'; list.style.cssText = 'display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;'; assignPopup.appendChild(list); document.body.appendChild(assignPopup); // assignPopup is a page-lifetime DOM singleton, but audiobookCastView() // (and its assignName/closeAssignPopup/roster closures) gets re-created // on every fresh cast/recast run. Listeners attached here would otherwise // permanently close over whichever run's functions existed the first // time this "if (!assignPopup)" block ran — so typing a new name and // hitting Enter would silently call a stale, disconnected assignName // from an earlier session. _abOpenAssignPopup() refreshes // assignPopup._assignName/_close on every open, so route through those // instead of the closed-over references. header.querySelector('.mdi-close').addEventListener('click', () => assignPopup._close?.()); document.addEventListener('mousedown', e => { // Don't auto-close on a click/drag inside the passage text either — that's // exactly how you feed a name into an already-open popup (see the feed's // click and mouseup handlers below), so closing here on mousedown would // kill the popup before that logic ever runs. if (assignPopup.style.display !== 'none' && !assignPopup.contains(e.target) && !e.target.closest('.ab-cv-spk') && !e.target.closest('.ab-cv-txt')) { assignPopup._close?.(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && assignPopup.style.display !== 'none') { assignPopup._close?.(); } }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { const addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn'); const visibleBtns = Array.from(assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]')).filter(b => b.style.display !== 'none'); if (addBtn && addBtn.style.display !== 'none') { addBtn.click(); } else if (visibleBtns.length === 1) { visibleBtns[0].click(); } else { const val = inp.value.trim(); if (val) assignPopup._assignName?.(val); } } else if (e.key === 'Escape') { assignPopup._close?.(); } }); inp.addEventListener('input', () => { const val = inp.value.trim().toLowerCase(); const listItems = assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]'); let exactMatch = false; listItems.forEach(item => { const name = item.dataset.name.toLowerCase(); if (name === val) exactMatch = true; if (name.includes(val)) item.style.display = 'flex'; else item.style.display = 'none'; }); let addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn'); if (val && !exactMatch && val !== 'narrator') { if (!addBtn) { addBtn = document.createElement('div'); addBtn.className = 'ab-cv-popup-add-btn'; addBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;'; addBtn.onmouseover = () => addBtn.style.background = 'var(--panel)'; addBtn.onmouseout = () => addBtn.style.background = 'transparent'; } addBtn.innerHTML = `+ Add "${escHtml(inp.value.trim())}"`; addBtn.onclick = () => assignPopup._assignName?.(inp.value.trim()); addBtn.style.display = 'flex'; assignPopup.querySelector('.ab-cv-popup-list').appendChild(addBtn); // keep at bottom } else if (addBtn) { addBtn.style.display = 'none'; } }); } function closeAssignPopup() { assignPopup.style.display = 'none'; if (assignModeRow) assignModeRow.classList.remove('is-assigning'); assignModeSeg = null; assignModeRow = null; } function assignName(name) { if (!assignModeSeg) return; const active = _abActiveSegments(); _abPushEditState(active.key, active.arr); const isNarrator = name.toLowerCase() === 'narrator'; // Decrement old speaker's count before reassigning const _oldSpk = assignModeSeg.speaker; if (_oldSpk && roster.has(_oldSpk)) { const _old = roster.get(_oldSpk); if (_old.count > 0) _old.count--; } assignModeSeg.speaker = isNarrator ? 'Narrator' : name; assignModeSeg.type = isNarrator ? 'narration' : 'dialogue'; if (isNarrator) assignModeSeg.emotion = ''; assignModeRow.classList.remove('is-assigning'); const speakerName = isNarrator ? 'Narrator' : name; const c = colorFor(speakerName); if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c }); roster.get(speakerName).count++; assignModeRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(speakerName)}${assignModeSeg.emotion ? ' (' + escHtml(assignModeSeg.emotion) + ')' : ''}`; assignModeRow.querySelector('.ab-cv-spk').style.color = c; assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character'; _abRecountRoster(_abActiveSegments().arr); toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success'); closeAssignPopup(); // Persist manual corrections to both localStorage (fast) and IndexedDB (durable). // Reuse the real done/total the cast last reached — an interrupted cast must // stay reported (and resumable) as interrupted, not flip to "100% done" just // because a speaker got manually reassigned. _abPersistManualEdit(); } // Shared by clicking the speaker label AND clicking/dragging a name inside // the narration text itself — same popup, optionally pre-filled with the // word(s) you clicked so you don't have to retype an exact match. function _abOpenAssignPopup(row, anchorEl, prefill) { if (!row || !row.__seg || row.classList.contains('is-processing')) return; if (assignModeRow) assignModeRow.classList.remove('is-assigning'); assignModeSeg = row.__seg; assignModeRow = row; row.classList.add('is-assigning'); // Re-point the popup's shared listeners at THIS view's assignName/close — // audiobookCastView() is re-invoked per cast/recast run, so these must be // refreshed on every open rather than captured once (see the singleton // setup above for why). assignPopup._assignName = assignName; assignPopup._close = closeAssignPopup; window.getSelection().removeAllRanges(); assignPopup.style.display = 'flex'; const rect = anchorEl.getBoundingClientRect(); const top = Math.min(rect.bottom + 4, window.innerHeight - 300); assignPopup.style.top = top + 'px'; assignPopup.style.left = rect.left + 'px'; const list = assignPopup.querySelector('.ab-cv-popup-list'); list.innerHTML = ''; const narrBtn = document.createElement('div'); narrBtn.dataset.name = 'Narrator'; narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;'; narrBtn.innerHTML = `📖 Narrator`; narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)'; narrBtn.onmouseout = () => narrBtn.style.background = 'transparent'; narrBtn.onclick = () => assignName('Narrator'); list.appendChild(narrBtn); const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count); for (const [n, info] of items) { const btn = document.createElement('div'); btn.dataset.name = n; btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);'; const img = recordForName(n)?.image; btn.innerHTML = (img ? `` : ``) + ` ${escHtml(n)}`; btn.onmouseover = () => btn.style.background = 'var(--panel)'; btn.onmouseout = () => btn.style.background = 'transparent'; btn.onclick = () => assignName(n); list.appendChild(btn); } const inp = assignPopup.querySelector('input'); inp.value = prefill || ''; setTimeout(() => { inp.focus(); if (prefill) inp.dispatchEvent(new Event('input')); }, 50); } const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker); const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker); const _abMergedSegment = (a, b) => { let base = a; if (_abIsUnknownSeg(a) && !_abIsUnknownSeg(b)) base = b; else if (_abIsNarrSeg(a) && !_abIsNarrSeg(b)) base = b; const type = _abIsNarrSeg(base) ? 'narration' : 'dialogue'; const speaker = type === 'narration' ? 'Narrator' : (base.speaker || 'Unknown'); return { speaker, type, emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '', text: audiobookJoinSegmentText(a.text || '', b.text || ''), // Anchor the merged row at its earlier segment's page — merging never // built this at all before, so a merge would silently drop the row's // page number and it would render as if it belonged to whatever page // card came before it. page: a.page ?? b.page, }; }; const _abMergeByRow = (row, dir) => { const active = _abActiveSegments(); const idx = active.arr.indexOf(row?.__seg); if (idx < 0) return; const leftIdx = dir < 0 ? idx - 1 : idx; const rightIdx = dir < 0 ? idx : idx + 1; if (leftIdx < 0 || rightIdx >= active.arr.length) { toast('No adjacent segment to merge', 'info'); return; } const leftRow = dir < 0 ? _abAdjacentRow(row, -1) : row; const rightRow = dir < 0 ? row : _abAdjacentRow(row, 1); _abPushEditState(active.key, active.arr); const merged = _abMergedSegment(active.arr[leftIdx], active.arr[rightIdx]); active.arr.splice(leftIdx, 2, merged); if (active.key === 'segments') _audiobook.segments = active.arr; else _audiobook.liveSegments = active.arr; if (!leftRow || !rightRow || leftRow.parentNode !== rightRow.parentNode) { _abRedrawSegmentsChunked(active.arr); } else if (!_abPatchRowRange([leftRow, rightRow], [merged], active.arr)) { _abRedrawSegmentsChunked(active.arr); } _abPersistManualEdit(); toast('Segments merged', 'success'); }; // Editing is pencil-icon only (see the click handler's .ab-cv-edit-text // branch) — double-click was removed so selecting/dragging text to assign // a character (see the .ab-cv-txt mousedown/mouseup handling below) can't // accidentally drop you into edit mode instead. feed.addEventListener('click', e => { const pageLabel = e.target.closest('.ab-cv-page-label[data-page]'); if (pageLabel) { e.preventDefault(); e.stopPropagation(); _abJumpToReaderPage(pageLabel.dataset.page); return; } const editBtn = e.target.closest('.ab-cv-edit-text'); if (editBtn) { e.preventDefault(); e.stopPropagation(); _abStartRowEdit(editBtn.closest('.ab-cv-row')); return; } const mergeBtn = e.target.closest('.ab-cv-row-tool'); if (mergeBtn) { e.preventDefault(); e.stopPropagation(); _abMergeByRow(mergeBtn.closest('.ab-cv-row'), mergeBtn.classList.contains('ab-cv-merge-prev') ? -1 : 1); return; } const spk = e.target.closest('.ab-cv-spk'); if (spk) { _abOpenAssignPopup(spk.closest('.ab-cv-row'), spk, ''); return; } // This guard (rather than just isCollapsed) stops the click that follows // a drag's mouseup from also firing the word-click branch below. if (_abJustHandledSelection) { _abJustHandledSelection = false; return; } const txtEl = e.target.closest('.ab-cv-txt'); if (txtEl && window.getSelection().isCollapsed) { // Clicking a name/word never opens or retargets a popup by itself — // only the speaker label (handled above) selects a paragraph. This // requires an assign popup to already be open (via that label click, // or a double-click fast-assign): a click on a name anywhere in the // text then just borrows that name into the search box, targeting // whichever paragraph was actually selected. Without this, clicking a // name that happens to live in some OTHER paragraph's text (the // common way to "grab a name I see") silently reassigned THAT // paragraph instead of the one the user meant to fix. if (!assignModeRow) return; const row = txtEl.closest('.ab-cv-row'); const nameHit = e.target.closest('.ab-name-hit'); const clickedWord = nameHit ? (nameHit.dataset.name || nameHit.textContent.trim()) : _abWordRangeAtPoint(e.clientX, e.clientY)?.word; if (assignModeRow !== row) { if (clickedWord) { const inp = assignPopup.querySelector('input'); inp.value = clickedWord; inp.dispatchEvent(new Event('input')); inp.focus(); } return; } if (nameHit) { _abOpenAssignPopup(row, nameHit, clickedWord); } else { const hit = _abWordRangeAtPoint(e.clientX, e.clientY); if (hit) { const rect = hit.range.getBoundingClientRect(); _abOpenAssignPopup(row, { getBoundingClientRect: () => rect }, hit.word); } } } }); let _abJustHandledSelection = false; // Hover highlight for the word under the cursor — one reused overlay div // positioned from the caret-range rect, instead of per-word spans (see // _abWordRangeAtPoint for why spans froze the page at book scale). let _abHoverHL = document.getElementById('ab-word-hover'); if (!_abHoverHL) { _abHoverHL = document.createElement('div'); _abHoverHL.id = 'ab-word-hover'; _abHoverHL.className = 'ab-word-hover'; _abHoverHL.hidden = true; document.body.appendChild(_abHoverHL); } let _abHoverRaf = 0; let _abHoverPt = null; const _abHideHoverHL = () => { _abHoverHL.hidden = true; }; feed.addEventListener('mousemove', e => { _abHoverPt = { x: e.clientX, y: e.clientY, target: e.target }; if (_abHoverRaf) return; _abHoverRaf = requestAnimationFrame(() => { _abHoverRaf = 0; const pt = _abHoverPt; if (!pt || !pt.target?.closest) return; const txtEl = pt.target.closest('.ab-cv-txt'); if (!txtEl || pt.target.closest('.ab-name-hit') || feed.querySelector('.ab-cv-edit-ta')) { _abHideHoverHL(); return; } const hit = _abWordRangeAtPoint(pt.x, pt.y); if (!hit) { _abHideHoverHL(); return; } const r = hit.range.getBoundingClientRect(); if (!r.width) { _abHideHoverHL(); return; } _abHoverHL.style.left = (r.left - 2) + 'px'; _abHoverHL.style.top = (r.top - 1) + 'px'; _abHoverHL.style.width = (r.width + 4) + 'px'; _abHoverHL.style.height = (r.height + 2) + 'px'; _abHoverHL.hidden = false; }); }); feed.addEventListener('mouseleave', _abHideHoverHL); feed.addEventListener('scroll', _abHideHoverHL, { passive: true }); feed.addEventListener('dblclick', e => { const hit = e.target.closest('.ab-name-hit'); if (!hit) return; // only a known name/alias can fast-assign; unknown text still needs the popup const row = hit.closest('.ab-cv-row'); const name = hit.dataset.name; if (row && name) { _abOpenAssignPopup(row, hit, ''); assignName(name); } }); let splitBtn = document.getElementById('ab-cv-split-btn'); if (!splitBtn) { splitBtn = document.createElement('button'); splitBtn.id = 'ab-cv-split-btn'; splitBtn.className = 'btn-secondary btn-sm'; splitBtn.innerHTML = ' Split text to Unknown Speaker'; splitBtn.style.cssText = 'position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);'; document.body.appendChild(splitBtn); } let currentSplitState = null; // Character offset of a (node, offset) point within txtSpan's full text // content — used instead of fullText.indexOf(selectedText) below, which // silently split at the WRONG spot whenever the selected phrase (or a // trimmed/whitespace variant of it) occurred more than once earlier in the // same paragraph. Range-counting always finds the exact spot you dragged, // no matter how common the selected text is elsewhere in the passage. const _abOffsetInEl = (el, node, offset) => { const r = document.createRange(); r.selectNodeContents(el); r.setEnd(node, offset); return r.toString().length; }; document.addEventListener('selectionchange', () => { if (!feed) return; // Panel closed const sel = window.getSelection(); if (sel.isCollapsed || !feed.contains(sel.anchorNode)) { if (splitBtn) splitBtn.style.display = 'none'; currentSplitState = null; return; } const txtSpan = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode.closest('.ab-cv-txt') : sel.anchorNode.closest('.ab-cv-txt'); if (!txtSpan) { splitBtn.style.display = 'none'; return; } const row = txtSpan.closest('.ab-cv-row'); if (!row || !row.__seg) return; const rawText = sel.toString(); const text = rawText.trim(); if (text.length > 0) { // If we are in assign mode and they selected a short text, it's for assigning a name. Don't show split. if (assignModeSeg && text.length < 40 && !text.includes('\n')) { splitBtn.style.display = 'none'; return; } const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); splitBtn.style.display = 'block'; splitBtn.style.top = (rect.bottom + 8) + 'px'; splitBtn.style.left = Math.max(10, rect.left + (rect.width / 2) - 100) + 'px'; // Forward selections have anchor==start; a backward drag (dragging // right-to-left) has anchor==end, so use range.start/end (always // document-order) rather than sel.anchor/focus for offset math. const startOffset = _abOffsetInEl(txtSpan, range.startContainer, range.startOffset); const endOffset = _abOffsetInEl(txtSpan, range.endContainer, range.endOffset); currentSplitState = { row, text, rawText, seg: row.__seg, startOffset, endOffset }; } else { splitBtn.style.display = 'none'; currentSplitState = null; } }); splitBtn.addEventListener('click', () => { if (!currentSplitState) return; const { row, seg, startOffset, endOffset } = currentSplitState; const fullText = seg.text || ''; const before = fullText.slice(0, startOffset); const selectedText = fullText.slice(startOffset, endOffset); const after = fullText.slice(endOffset); if (!selectedText) return; // Try committed segments first, then live array (during active casting) let arr = _audiobook.segments || []; let globalIdx = arr.indexOf(seg); if (globalIdx === -1 && Array.isArray(_audiobook.liveSegments)) { arr = _audiobook.liveSegments; globalIdx = arr.indexOf(seg); } if (globalIdx !== -1) _abPushEditState(arr === _audiobook.liveSegments ? 'liveSegments' : 'segments', arr); const newSegs = []; if (before.length) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before, page: seg.page }); newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: selectedText, page: seg.page }); if (after.length) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after, page: seg.page }); if (globalIdx !== -1) { arr.splice(globalIdx, 1, ...newSegs); if (!_abPatchRowRange([row], newSegs, arr)) _abRedrawSegments(arr); _abPersistManualEdit(); } else { // DOM-only split — will be reconciled once casting finishes console.warn('[audiobook] split during casting: DOM-only, segment not yet in array'); const frag = document.createDocumentFragment(); for (const s of newSegs) frag.appendChild(_abRowFromSegment(s)); row.parentNode.insertBefore(frag, row); row.remove(); } splitBtn.style.display = 'none'; window.getSelection().removeAllRanges(); toast('Segment split! You can now assign a character to the Unknown block.', 'success'); }); feed.addEventListener('mouseup', () => { const sel = window.getSelection(); if (sel.isCollapsed || !feed.contains(sel.anchorNode)) return; const text = sel.toString().trim(); if (!text || text.length >= 40 || text.includes('\n')) return; // A short selection is ambiguous — could be a name to assign, or a short // quoted line ("»Henker«.") the user is dragging out to split into its // own Unknown-speaker segment. Quote marks are a strong tell for the // latter (names don't come wrapped in guillemets/quotes), so leave those // alone for the "Split text to Unknown Speaker" button (wired via // selectionchange below) instead of hijacking them into the assign popup. if (/^[»„“"'‘]/.test(text)) return; // Dragging to select a name only ever feeds an ALREADY-open popup (opened // by clicking a paragraph's speaker label) — it must not open/retarget a // popup for whichever paragraph happens to contain the dragged text, // which used to reassign the wrong paragraph (the one you dragged a name // out of, not the one you actually meant to fix). if (!assignModeSeg) return; _abJustHandledSelection = true; // stop the click that follows this mouseup from also firing assignName(text); sel.removeAllRanges(); }); chars.addEventListener('click', e => { const chip = e.target.closest('.ab-chip'); if (chip) { let name = ''; for (const n of chip.childNodes) if (n.nodeType === 3) name += n.nodeValue; name = name.trim(); if (assignModeSeg) { if (name) assignName(name); } else if (name) { // Scroll to the next occurrence of this character in the feed const rows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r => r.__seg && r.__seg.speaker === name); if (!rows.length) return; const currentY = feed.scrollTop; let target = rows.find(r => (r.offsetTop - feed.offsetTop) > currentY + 10); if (!target) target = rows[0]; // loop around to the top _abScrollIntoView(target, { block: 'center' }); // Highlight briefly target.style.transition = 'background 0.3s'; target.style.background = 'var(--accent-hover, rgba(100, 150, 255, 0.2))'; setTimeout(() => { if (target.parentNode) target.style.background = ''; }, 1000); } } }); return { // Refresh the sidebar roster from the FULL cast — recast/quality runs feed // the view only the lines they check, which made the sidebar collapse to // "Unknown " as if all named characters had been lost. recountRoster(allSegs) { _abRecountRoster(allSegs || []); }, rebuild(segs) { this.clearProcessing(); feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); // Chunked — this runs on every draft restore of a previously-cast book, // so a large cast (thousands of rows, each running highlightText's regex // pass) must not block the main thread synchronously (see complete()). _abRedrawSegmentsChunked(segs || []); }, update(done) { const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = false; const pct = Math.round((done / total) * 100); if (fill) fill.style.width = pct + '%'; if (count) { count.hidden = false; count.textContent = `passage ${done} / ${total}`; } const fillText = panel.querySelector('#ab-cv-fill-text'); if (fillText) fillText.textContent = `${pct}% (Passage ${done} of ${total})`; }, processing(text) { if (this._procRow) this._procRow.remove(); this._thinkRaw = ''; this._thinkDone = false; this._procRow = document.createElement('div'); this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row'; const isLong = text.length > 160; const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : ''); // Split view (collapsed by default, toggled via the chevron): left = // the passage being read, right = the LLM's live thinking/raw-output // stream (fed by view.thinking via the SSE endpoint). this._procRow.innerHTML = `
LLM Reading…
${preview}
`; this._procRow.querySelector('.ab-cv-expand-btn').addEventListener('click', function () { const split = this.closest('.ab-cv-llm-row').querySelector('.ab-cv-llm-split'); const open = this.getAttribute('aria-expanded') === 'true'; this.setAttribute('aria-expanded', String(!open)); split.hidden = open; const icon = this.querySelector('span'); if (icon) icon.className = open ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-down'; }); _abPage().appendChild(this._procRow); trim(); }, // Live LLM output for the passage currently processing. Streams into the // (collapsed-by-default) thinking column; the user reveals it via the // chevron button set up in processing() above. thinking(delta) { if (!this._procRow || !delta || this._thinkDone) return; this._thinkRaw = (this._thinkRaw || '') + delta; const think = this._procRow.querySelector('.ab-cv-think'); const label = this._procRow.querySelector('.ab-cv-think-label'); if (!think) return; // Some models wrap real reasoning in ...; others ignore // that instruction and stream straight into the JSON answer. Show // whichever is actually arriving — live progress beats nothing — but // label it honestly instead of calling raw JSON "thinking". const openAt = this._thinkRaw.indexOf(''); let shown, isReal = false, closeAt = -1; if (openAt >= 0) { const afterOpen = this._thinkRaw.slice(openAt + ''.length); closeAt = afterOpen.indexOf(''); shown = closeAt >= 0 ? afterOpen.slice(0, closeAt) : afterOpen; isReal = true; } else { shown = this._thinkRaw; } if (label) label.innerHTML = isReal ? ' LLM Thinking…' : ' Live Output (raw — no reasoning exposed)'; think.textContent = shown.trim().slice(-20000); think.scrollTop = think.scrollHeight; if (closeAt >= 0) this._thinkDone = true; }, clearProcessing() { if (this._procRow) { this._procRow.remove(); this._procRow = null; } }, addSegments(segs) { this.clearProcessing(); feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); const makeRow = (s) => { const { speakerName } = _abRowSpeaker(s); colorFor(speakerName); roster.get(speakerName).count++; return _abRowFromSegment(s); }; const CHUNK = 80; // Render first chunk immediately so the screen fills fast const first = segs.slice(0, CHUNK); const frag0 = document.createDocumentFragment(); first.forEach(s => frag0.appendChild(makeRow(s))); _abPage().appendChild(frag0); renderRoster(); // Stream remaining chunks without blocking the main thread if (segs.length > CHUNK) { let i = CHUNK; const next = () => { if (i >= segs.length) { trim(); renderRoster(); return; } const batch = document.createDocumentFragment(); const end = Math.min(i + CHUNK, segs.length); for (; i < end; i++) batch.appendChild(makeRow(segs[i])); _abPage().appendChild(batch); renderRoster(); setTimeout(next, 0); }; setTimeout(next, 0); } else { trim(); } }, note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; _abPage().appendChild(r); trim(); }, // Visual gap marking that the passages before/after are not contiguous. // prevIdx / nextIdx are indices into _audiobook.segments for the gap boundaries. // Click expands the divider to show all segments in the gap inline. divider(prevIdx, nextIdx) { this.clearProcessing(); const r = document.createElement('div'); r.className = 'ab-cv-divider ab-cv-divider-expand'; const gapFrom = Math.max(0, (prevIdx >= 0 ? prevIdx + 1 : 0)); const segsAll = _audiobook.segments || []; const gapTo = Math.min(segsAll.length - 1, (nextIdx >= 0 ? nextIdx - 1 : segsAll.length - 1)); const gapCount = Math.max(0, gapTo - gapFrom + 1); r.innerHTML = `⋯ ${gapCount > 0 ? gapCount + ' line' + (gapCount !== 1 ? 's' : '') + ' hidden — ' : ''}click to expand`; r.title = 'Click to reveal the text between these passages'; r.addEventListener('click', () => { const gap = segsAll.slice(gapFrom, gapTo + 1); if (!gap.length) { r.remove(); return; } const PEEK = 20; const show = gap.slice(0, PEEK); const rest = gap.slice(PEEK); const frag = document.createDocumentFragment(); for (const s of show) { const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const spk = isNarr ? 'Narrator' : s.speaker; const c = colorFor(spk); const row = document.createElement('div'); row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : ''); row.__seg = s; row.innerHTML = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; frag.appendChild(row); } if (rest.length) { // Insert a new collapsed divider for the remaining lines const next = document.createElement('div'); next.className = 'ab-cv-divider ab-cv-divider-expand'; next.innerHTML = `⋯ ${rest.length} line${rest.length !== 1 ? 's' : ''} hidden — click to expand`; next.title = 'Click to reveal more'; const restSegs = rest; // closure next.addEventListener('click', function onClick() { const f2 = document.createDocumentFragment(); for (const s of restSegs) { const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const spk = isNarr ? 'Narrator' : s.speaker; const c = colorFor(spk); const row = document.createElement('div'); row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : ''); row.__seg = s; row.innerHTML = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; f2.appendChild(row); } next.replaceWith(f2); }); frag.appendChild(next); } r.replaceWith(frag); }); _abPage().appendChild(r); trim(); }, // Page-break marker in the casting feed (from source PDF page boundaries). // Starts a new "paper" card so each source page reads as its own block. pagemark(pageNum) { this.clearProcessing(); _abNewPage('Page ' + pageNum, pageNum); trim(); }, // Park the panel in a "done" state with a Review button instead of auto-popping // the preview — so it waits for you if you wandered off to do something else. complete(summary, onOpen, onRecast, onRecastUnknown) { if (count) count.hidden = true; const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = true; const completedSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length ? _audiobook.segments : (typeof allSegments !== 'undefined' && allSegments.length ? allSegments : null); // Chunked (not the plain synchronous redraw) — reopening a big already-cast // book means thousands of rows each running highlightText's regex pass, // which on the synchronous path blocks the main thread long enough to // trigger the browser's "Page Unresponsive" warning and reads to the user // as if casting silently restarted on its own. if (completedSegments) _abRedrawSegmentsChunked(completedSegments); const cancelBtn = panel.querySelector('#ab-cv-cancel'); if (cancelBtn) cancelBtn.hidden = true; const foot = panel.querySelector('#ab-cv-foot'); foot.hidden = false; // The "N characters · M segments" summary lives under the character // sidebar (not the action-button footer) so it reads next to the list // it's summarising. const sideEl = panel.querySelector('.ab-cv-side'); if (sideEl) { let doneEl = sideEl.querySelector('.ab-cv-side-done'); if (!doneEl) { doneEl = document.createElement('div'); doneEl.className = 'ab-cv-side-done'; sideEl.appendChild(doneEl); } doneEl.innerHTML = ` ${escHtml(summary)}`; } // An interrupted cast (crash/reload) leaves completedChunks < completedTotal — // offer to pick up from there instead of only full/partial recasts. const resumable = _audiobook.completedChunks > 0 && _audiobook.completedTotal > 0 && _audiobook.completedChunks < _audiobook.completedTotal; const continueBtnHtml = resumable ? `` : ''; // Three grouped flyout buttons instead of eight flat ones — Identify // (speaker attribution passes), Cast Characters (sheet generation), // Cast (view/export) — plus Continue casting when resumable and Open // Script Rehearser stay as direct one-click actions since they're the // most common next step, not variants of each other. foot.innerHTML = `${continueBtnHtml}`; const verificationPrompt = `Du bist ein Qualitätsprüfer für die Sprecherzuordnung eines bereits analysierten deutschen Hörbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT — du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters gehört, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer könnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erzähltext hineingezogen?". WORAUF DU PRÜFST — IN DIESER REIHENFOLGE (höchste Priorität zuerst): 1. ZUERST alle 'Unknown'-Zeilen: Löse den Sprecher über Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegesprächen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung möglich ist. Das ist die dringendste Kategorie — eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete. 2. DANACH Zeilenketten mit demselben Sprecher in Folge (2 oder mehr aufeinanderfolgende Zeilen — egal ob 'Narrator' oder ein Charakter): Das ist die zweithäufigste Fehlerquelle. Prüfe jede Zeile in einer solchen Kette einzeln: Ist das wirklich durchgehend derselbe Sprecher, oder wurde eine Sprecherwechsel-Zeile (z.B. eine Antwort einer anderen Figur, oder gesprochene Rede ohne erhaltene Anführungszeichen) fälschlich in die Kette hineingezogen? Prüfe besonders bei 'Narrator'-Ketten: Ist wirklich jede Zeile neutrale Erzählerbeschreibung, oder ist tatsächlich eine Aussage dabei, die ein Charakter so gesagt haben könnte — nur ohne erhaltene Anführungszeichen (typisch bei PDF/OCR-Extraktion)? Prüfe Tonfall, Wortwahl und Perspektive: Klingt eine der Zeilen wie eine direkte Aussage/Reaktion einer Person in der Szene (Ich-Form, Anrede, Ausruf, Frage), nicht wie neutrale Erzählerbeschreibung? Dann ist SIE Dialog, nicht Narration — auch wenn keine » « vorhanden sind, und auch wenn die Zeilen davor/danach in derselben Kette echt narrativ sind. 3. ZULETZT ein allgemeiner Plausibilitäts-Check bei allen übrigen, bereits vermuteten Sprechern: Passt Tonfall/Wortwahl der Zeile zu dem, was über diese Figur im bisherigen Text bekannt ist (Sprechweise, Haltung, Beziehung zu anderen Figuren)? Wenn eine Zeile im Kontext eindeutig zu einer ANDEREN, im Text erkennbaren Person passt, korrigiere sie dorthin. 4. Echte Narration NICHT anfassen: Reine Beschreibung, Handlung, Übergänge, Kapitelanfänge bleiben 'Narrator'. Nur weil eine Figur erwähnt wird, wird der Satz nicht zu ihrem Dialog. DEDUKTIONS-WERKZEUGE für die Sprecherzuordnung: - Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende. - Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person — auch ohne Anführungszeichen. Das ist der häufigste Fehler der ersten Zuordnung: gesprochene Rede wird fälschlich als Narration markiert, weil die Anführungszeichen bei der Extraktion verloren gingen. - Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile AKTIV HANDELT (aufblickt, sich umdreht, den Kopf schüttelt), ist der wahrscheinlichste Sprecher dieser Zeile. - ZUHÖRER-AUSSCHLUSS (negative Evidenz): Wahrnehmungs- und Reaktionsverben kennzeichnen den ZUHÖRER, nicht den Sprecher ("Marcian spürte ihre Tränen", "Marcian hörte darüber hinweg" → Marcian hört zu, jemand anderes spricht). Ebenso ist eine Person, die im Zitat per Vokativ ANGEREDET wird ("..., Marcian."), nicht der Sprecher. Prüfe diese Ausschlüsse ZUERST, bevor du einen Namen aus dem Nachbarsatz übernimmst — das ist die häufigste Fehlerquelle bei nicht zugeordneten Zeilen. - Pronomen-Inquit bei geteilten Zitaten: Eine Zeile wie ", antwortete er knapp." gehört zum vorherigen Zitat; löse das Pronomen auf die zuletzt genannte Person passenden Geschlechts VOR dem Zitat auf (er → letzter Mann, sie → letzte Frau). - Ping-Pong-Prinzip: Zwei Personen im Gespräch wechseln sich strikt ab, auch ohne Tags — verfolge die Kette zur letzten eindeutigen Nennung zurück. - Pronomen-Auflösung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts. - Rollenbezeichnungen sind gültige Sprecher (z.B. 'Ork', 'Der Fremde', 'Wächter') statt 'Unknown'. FÜR JEDES SEGMENT AUSGABE: - speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters (nutze den in der bekannten Charakterliste geführten Namen, nicht einen Spitznamen/Alias, falls der Kontext eindeutig zuordnet). - type: 'narration' oder 'dialogue' - text: EXAKT der WORTWÖRTLICHE Originaltext — KEINE Änderungen, KEINE Auslassungen, KEINE Ergänzungen. - emotion: Bei Dialogen 1-2 deutsche Wörter für den Tonfall. Bei Narration leer (''). ABSOLUTE REGELN: - Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren. - Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu. - Mische NIEMALS Narration und Dialog in einem Segment. - Sei konservativ: ändere eine Zeile nur, wenn du nach dieser Prüfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend wäre — nicht jede Zeile muss sich ändern.`; window._abVerificationPrompt = verificationPrompt; const runVerificationPass = () => { // Deliberately NOT a copy of the first-pass attribution prompt. Pass 1 // classifies from scratch; this pass receives a segment that ALREADY // has a label (a character's name, or Narrator) and has to judge // whether that label actually holds up — a plausibility/confidence // check rather than fresh attribution. It also runs on every Narrator // segment (not just Unknown dialogue), specifically to catch spoken // lines that got swallowed into narration on the first pass. const choice = audiobookCurrentCastLlm(panel); const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); closePanel(); audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt, includeNarrator: true }); }; // 3rd quality pass — deliberately NOT chunk-by-chunk like the two // passes above (both re-read the source text in ~4000-char windows, // since a whole book is far bigger than any context window). This one // works purely over ALREADY-ATTRIBUTED lines already sitting in memory: // for each character, gather every line credited to them from anywhere // in the book and send that whole bundle to the LLM in one call, asking // it to judge each line against the REST of that character's own // established voice — catches a line that got misattributed to the // right-sounding-in-isolation-but-wrong-in-context character, which a // single passage window could never expose since it only ever sees // that one line's immediate neighbours. const runConsistencyPass = async () => { const active = _abActiveSegments(); const segs = active.arr; if (!segs.length) { toast('Nothing cast yet to check', 'error'); return; } const byName = new Map(); segs.forEach((s, idx) => { if (s.type !== 'dialogue' || !s.speaker) return; if (/^Narrator$/i.test(s.speaker) || /^Unknown|Unbekannt/i.test(s.speaker)) return; if (!byName.has(s.speaker)) byName.set(s.speaker, []); byName.get(s.speaker).push(idx); }); const MIN_LINES = 4; // can't judge a "pattern" from fewer lines than this const MAX_LINES_PER_CALL = 60; // context-size ceiling for one character const candidates = [...byName.entries()].filter(([, idxs]) => idxs.length >= MIN_LINES); if (!candidates.length) { toast('No characters with enough lines yet to check for consistency', 'error'); return; } const choice2 = audiobookCurrentCastLlm(panel); const savedChoice2 = audiobookSaveLlmChoice(choice2.url, choice2.model); closePanel(); _abPushEditState(active.key, segs); const busy = _abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length !== 1 ? 's' : ''}…`, true); let checked = 0, flaggedTotal = 0, failedNames = []; const touchedSegs = []; const knownNames = [...roster.keys()]; try { for (const [name, idxs] of candidates) { busy.setProgress(checked, candidates.length); // Evenly sample across the WHOLE arc (not just the first N // appearances) when a major character has more lines than fit in // one call, so their voice late in the book is represented too. let sampleIdxs = idxs; if (idxs.length > MAX_LINES_PER_CALL) { const step = idxs.length / MAX_LINES_PER_CALL; sampleIdxs = Array.from({ length: MAX_LINES_PER_CALL }, (_, i) => idxs[Math.floor(i * step)]); } const lines = sampleIdxs.map(i => ({ index: i, text: segs[i].text })); try { const r = await fetch('/api/audiobook-consistency-check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ character: name, lines, known_characters: knownNames.filter(n => n.toLowerCase() !== name.toLowerCase()), llm_url: savedChoice2.url, model: savedChoice2.model, }), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const data = await r.json(); const outliers = Array.isArray(data?.outliers) ? data.outliers : []; for (const o of outliers) { // The model is asked to echo back the real bracketed index, // but sometimes returns its own sequential count instead // (confirmed happening live) — verify against the quoted // excerpt it was also asked to provide, and fall back to a // text search across this character's OTHER lines if the // index it gave doesn't actually land on one of them. Safer // to skip a real correction than to misapply one to the // wrong (innocent) segment. let idx = o.index; const quote = String(o.quote || '').trim().toLowerCase(); const matchesQuote = (i) => !quote || (segs[i]?.text || '').toLowerCase().includes(quote.slice(0, 40)); if (typeof idx !== 'number' || !segs[idx] || segs[idx].speaker !== name || !matchesQuote(idx)) { idx = quote ? sampleIdxs.find(i => segs[i].speaker === name && matchesQuote(i)) : undefined; } if (typeof idx !== 'number' || !segs[idx] || segs[idx].speaker !== name) continue; const suggested = String(o.suggested_speaker || '').trim(); let newSpeaker = null; if (!suggested || /^unknown|unbekannt$/i.test(suggested)) newSpeaker = 'Unknown'; else newSpeaker = knownNames.find(n => n.toLowerCase() === suggested.toLowerCase()) || null; // Only ever reassign to an ALREADY-known name (or 'Unknown') // — this pass never invents a new character. if (!newSpeaker || newSpeaker === name) continue; segs[idx].speaker = newSpeaker; if (newSpeaker === 'Unknown') segs[idx].type = 'dialogue'; touchedSegs.push(segs[idx]); flaggedTotal++; } } catch (err) { failedNames.push(name); console.error('[consistency check]', name, err); } checked++; busy.setProgress(checked, candidates.length); } } finally { busy.remove(); } if (flaggedTotal) { const patched = await _abPatchScatteredRows(touchedSegs, () => {}); if (!patched) await _abRedrawSegmentsChunked(active.arr); _abPersistManualEdit(); } const failSuffix = failedNames.length ? ` (${failedNames.length} character${failedNames.length !== 1 ? 's' : ''} failed to check: ${failedNames.slice(0, 5).join(', ')})` : ''; toast( (flaggedTotal ? `Consistency check: ${flaggedTotal} line${flaggedTotal !== 1 ? 's' : ''} reassigned across ${checked} character${checked !== 1 ? 's' : ''}` : `Consistency check: no mismatches found across ${checked} character${checked !== 1 ? 's' : ''}`) + failSuffix, failedNames.length && !flaggedTotal ? 'error' : 'success' ); }; foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => { closePanel(); await audiobookOpenCurrentInRehearser(); }); const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; const choice = audiobookCurrentCastLlm(panel); const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { closePanel(); if (callback) callback(savedChoice.url, savedChoice.model); }); }; const runIdentifyAll = async () => { const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true }); if (!ok) return; applyPromptAndRun(audiobookCast); }; const runIdentifyUnknown = () => applyPromptAndRun(audiobookRecastUnknown); // Spoken lines typed as narration are invisible to runIdentifyUnknown (it only // scans type==='dialogue'), and "Verify all" would re-send all ~1000 narration // segments. This re-checks only the ones scoring as direct speech. const runRepairNarration = () => { const segs = _audiobook.segments || []; const n = (typeof _abSuspectNarration === 'function') ? _abSuspectNarration(segs).size : 0; if (!n) { toast('No narration lines look like mislabeled dialogue', 'success'); return; } toast(`Re-checking ${n} narration line${n !== 1 ? 's' : ''} that look like spoken dialogue…`, 'info'); audiobookRecastUnknown(null, null, { repairNarration: true, prompt: verificationPrompt }); }; const runCastAll = () => { if (typeof window.csForReader === 'function') window.csForReader(); else if (typeof csForReader === 'function') csForReader(); else toast('Character sheets not loaded yet', 'error'); }; const runCastFresh = async () => { const ok = await confirmDialog('Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.', { title: 'Discard all character sheets?', okLabel: 'Discard & rebuild', danger: true }); if (!ok) return; if (typeof window.csForReader === 'function') window.csForReader({ fresh: true }); else if (typeof csForReader === 'function') csForReader({ fresh: true }); else toast('Character sheets not loaded yet', 'error'); }; const runCastSelected = () => { if (!existing.length) { toast('No character sheets found yet for this book', 'error'); return; } _abOpenRecastSelectPopup(existing); }; // "Continue uncasted characters" — same completeness check the live // progress UI already uses ("N profiles with details") to find anyone // with zero real detail fields (never cast, or only ever picked up as // a bare name), and runs the targeted/selective pass (evidence-window // per character, not a full book re-scan) on just those — skips // everyone who's already got a real profile instead of re-touching // the whole cast like "Cast all character roles" does. const runCastContinueUncasted = async () => { if (typeof clGetAllByTagOrBook !== 'function' || typeof csForReaderSelective !== 'function' || typeof CS_DETAIL_FIELDS === 'undefined') { toast('Character sheets not loaded yet', 'error'); return; } let recs = []; try { recs = await clGetAllByTagOrBook(bookTitle); } catch (_) {} // Indexed by canonical name ONLY used to miss every alias — a roster // name that's really just an already-known alternate name for a // MORE complete record (e.g. "Fremder" once a character-sheet pass // has proven it's the same person as the already-detailed "Zerwas") // looked like "never cast at all" and got endlessly re-queued as its // own separate target, recreating the same split every time this ran. const recByName = new Map(); recs.forEach(r => { const name = String(r.name || '').trim().toLowerCase(); if (name) recByName.set(name, r); if (typeof clSplitIdentityTokens === 'function') { for (const a of clSplitIdentityTokens(r?.sheet?.aliases, { aliases: true })) { const key = a.toLowerCase(); if (!recByName.has(key)) recByName.set(key, r); } } }); const roster = (_audiobook.roster || []).filter(n => n && !/^unknown$|^unbekannt$/i.test(n.trim())); // A character with only a handful of lines usually just doesn't have // enough text for the LLM to say anything real about them — sending // them through the same pass as everyone else burns a call that // almost always comes back blank anyway (confirmed live: a whole- // roster run filled in the one prominent character and left every // <20-line character empty). Skip anyone under this line count by // default rather than pretending they're "uncasted" forever. const MIN_LINES_TO_TRY = 10; const lineCounts = new Map(); (_audiobook.segments || []).forEach(s => { if (s?.type !== 'dialogue' || !s.speaker) return; const k = String(s.speaker).trim().toLowerCase(); lineCounts.set(k, (lineCounts.get(k) || 0) + 1); }); const tooSparse = []; const incomplete = roster.filter(n => { const rec = recByName.get(String(n).trim().toLowerCase()); const hasDetail = rec && CS_DETAIL_FIELDS.some(f => String((rec.sheet || {})[f] || '').trim()); if (hasDetail) return false; const lc = lineCounts.get(String(n).trim().toLowerCase()) || 0; if (lc < MIN_LINES_TO_TRY) { tooSparse.push(n); return false; } return true; }); if (!incomplete.length) { toast(tooSparse.length ? `Nothing left to try — ${tooSparse.length} character${tooSparse.length !== 1 ? 's have' : ' has'} fewer than ${MIN_LINES_TO_TRY} lines and ${tooSparse.length !== 1 ? 'were' : 'was'} skipped` : 'Every character already has a profile with detail — nothing to continue', 'success'); return; } toast(`Continuing ${incomplete.length} uncasted character${incomplete.length !== 1 ? 's' : ''}` + (tooSparse.length ? ` (skipped ${tooSparse.length} with fewer than ${MIN_LINES_TO_TRY} lines)` : '') + '…', 'info'); csForReaderSelective(incomplete); }; const runViewCast = () => { // navTo('s-library') is silently swallowed by Studio's nav guard // while Studio is the active section (same pattern that broke "Open // Script Rehearser" — see studio.js's _stuInstallNavGuardOnce) — // confirmed live: the button fired with no visible effect. Studio's // own Voices phase already shows this exact roster (it borrows the // same #lib-chars-list the old Library page uses), so redirect // there instead of navigating away. if (document.getElementById('s-caststudio')?.classList.contains('is-active') && typeof window.showStudioPhase === 'function') { window.showStudioPhase(3); return; } try { sessionStorage.setItem('ttsvc_cast_return', 'reader'); } catch (_) {} if (typeof navTo === 'function') navTo('s-library'); if (typeof navLibraryView === 'function') navLibraryView('characters'); }; // The Character Library accumulates a record for every name any past // casting run has ever produced for this book, with no cleanup tied to // the CURRENT roster — confirmed live: 65+ saved records for a book // whose current cast is 46 names, including spelling-variant // duplicates ("Globo Brohm" / "Gombo Brohm" / "Gernot Brohm") and a // character split three ways ("Kolon" / "Kolon der Zwerg" / "Kolon // Tunneltreiber") that a past, less-accurate pass never got merged // away. The current roster (this run's own attribution, not the // Library) is the ground truth for who's actually in the cast now. const runLibraryCleanup = async () => { if (typeof clGetAllByTagOrBook !== 'function' || typeof clDelete !== 'function') { toast('Character library is not available right now', 'error'); return; } const roster = (_audiobook.roster || []).filter(n => n && !/^unknown$|^unbekannt$/i.test(n.trim())); if (!roster.length) { toast('No current roster to clean up against', 'error'); return; } const rosterSet = new Set(roster.map(n => n.trim().toLowerCase())); let recs = []; try { recs = await clGetAllByTagOrBook(bookTitle); } catch (_) {} const toDelete = recs.filter(r => !rosterSet.has(String(r.name || '').trim().toLowerCase())); if (!toDelete.length) { toast(`Library already matches the current ${roster.length}-name roster — nothing to remove`, 'success'); return; } const preview = toDelete.slice(0, 12).map(r => r.name).join(', ') + (toDelete.length > 12 ? `, +${toDelete.length - 12} more` : ''); const ok = await confirmDialog( `Remove ${toDelete.length} character record${toDelete.length !== 1 ? 's' : ''} that aren't in the current ${roster.length}-name roster? This cannot be undone.\n\n${preview}`, { title: 'Clean up character library?', okLabel: `Remove ${toDelete.length}`, danger: true } ); if (!ok) return; let removed = 0; for (const r of toDelete) { try { await clDelete(r.id); removed++; } catch (_) {} } toast(`Removed ${removed} character record${removed !== 1 ? 's' : ''} not in the current roster`, 'success'); }; const bookTitle = window.readerState?.title || ''; let existing = []; // If this book's already been cast before, "Cast selected character // roles" can reuse the loaded character list instead of starting blind. (async () => { if (!bookTitle || typeof clGetAllByTagOrBook !== 'function') return; try { existing = await clGetAllByTagOrBook(bookTitle); } catch (_) {} })(); const identifyMenu = foot.querySelector('#ab-cv-menu-identify'); const castMenu = foot.querySelector('#ab-cv-menu-cast'); const viewCastMenu = foot.querySelector('#ab-cv-menu-viewcast'); identifyMenu?.addEventListener('click', () => _abToggleFootMenu(identifyMenu, [ { icon: 'mdi-refresh', label: '1. Identify all characters', title: 'Scan the text and build the cast list from scratch', onClick: runIdentifyAll, danger: true }, { icon: 'mdi-auto-fix', label: '2. Auto-repair cast (2.1–2.5)', title: 'Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed', onClick: () => audiobookAutoRepairCast() }, { icon: 'mdi-comment-alert-outline', label: '2.1 Repair mislabeled dialogue', title: 'Finds spoken lines that were typed as narration (usually because the » « quote marks were lost in extraction) and re-checks only those — far cheaper than verifying every narration line', onClick: runRepairNarration }, { icon: 'mdi-account-question-outline', label: '2.2 Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown }, { icon: 'mdi-repeat', label: '2.3 Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => { const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10'); if (input == null) return; const threshold = parseInt(input, 10); if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; } audiobookRecastUntilThreshold(threshold); } }, { icon: 'mdi-shield-check-outline', label: '2.4 Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass }, { icon: 'mdi-account-search-outline', label: '2.5 Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass }, ])); window._abRunConsistencyPass = runConsistencyPass; castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [ { icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll }, { icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted }, { icon: 'mdi-account-check-outline', label: 'Cast selected character roles', title: existing.length ? 'Generate / refresh the character sheets for selected cast characters' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected }, { divider: true }, { icon: 'mdi-refresh', label: 'New recast (discard & rebuild all)', title: 'Discard every generated character sheet and rebuild every profile from scratch', onClick: runCastFresh, danger: true }, ])); viewCastMenu?.addEventListener('click', () => _abToggleFootMenu(viewCastMenu, [ { icon: 'mdi-eye-outline', label: 'View cast', title: 'View the cast overview in the library', onClick: runViewCast }, { icon: 'mdi-folder-zip-outline', label: 'Export cast archive (.zip)', title: 'Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character', onClick: audiobookExportCastMd }, { divider: true }, { icon: 'mdi-broom', label: 'Clean up library to current roster', title: 'Remove saved character records that aren\'t in the current roster — old spelling-variant duplicates and superseded names from past casting runs', onClick: runLibraryCleanup, danger: true }, ])); foot.querySelector('#ab-cv-continue')?.addEventListener('click', () => applyPromptAndRun((u, m) => audiobookCast(u, m, { startIndex: _audiobook.completedChunks, segments: _audiobook.segments, roster: _audiobook.roster, narrationOnly: _audiobook.narratedPassages, degraded: _audiobook.degraded }))); panel.classList.add('ab-castpanel-done'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, done(options = {}) { const stopped = options.stopped || _audiobook.cancel; if (!stopped) { closePanel(); return; } this.clearProcessing(); if (count) count.hidden = true; const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = true; feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); const message = options.message || 'Casting stopped before any passage completed.'; if (!feed.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page')) { feed.innerHTML = ''; _abCurPage = null; const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = message; feed.appendChild(r); } else if (options.message) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = message; feed.appendChild(r); } if (!chars.querySelector('.ab-char-item')) { chars.innerHTML = 'No completed characters yet.'; } const foot = panel.querySelector('#ab-cv-foot'); const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; foot.hidden = false; foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel); foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => { closePanel(); await audiobookOpenCurrentInRehearser(); }); foot.querySelector('#ab-cv-stopped-recast')?.addEventListener('click', () => { const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value; const currentChoice = audiobookCurrentCastLlm(panel); const choice = audiobookSaveLlmChoice(currentChoice.url, currentChoice.model); if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt || '' }) }).finally(() => { closePanel(); audiobookCast(choice.url, choice.model); }); }); panel.classList.add('ab-castpanel-done'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, // Show a fresh "ready" state (used when server draft fetch returns empty) loadingRestore() { feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); feed.innerHTML = ''; _abCurPage = null; const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = 'Loading saved cast…'; feed.appendChild(r); chars.innerHTML = 'restoring…'; const statusMsg = panel.querySelector('#ab-cv-status-msg'); if (statusMsg) { statusMsg.style.display = 'inline-block'; statusMsg.textContent = 'Checking saved cast before starting a new one.'; } const castBtn = panel.querySelector('#ab-cv-start-cast'); if (castBtn) castBtn.style.display = 'none'; }, setFreshState(message = 'Ready to identify characters.') { feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); if (!feed.querySelector('.ab-cv-row, .ab-cv-divider, .ab-cv-page')) { feed.innerHTML = ''; _abCurPage = null; const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = message; feed.appendChild(r); } if (!chars.querySelector('.ab-char-item')) chars.innerHTML = 'No saved cast found.'; const statusMsg = panel.querySelector('#ab-cv-status-msg'); if (statusMsg) { statusMsg.style.display = 'inline-block'; statusMsg.textContent = message; } const castBtn = panel.querySelector('#ab-cv-start-cast'); if (castBtn) { castBtn.style.display = 'inline-block'; castBtn.addEventListener('click', () => { const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value; const currentChoice = audiobookCurrentCastLlm(panel); const choice = audiobookSaveLlmChoice(currentChoice.url, currentChoice.model); if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt; audiobookCast(choice.url, choice.model); }); } }, }; } async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) { if (_audiobook.running) 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))); if (repl.length && hasResolved && !hasUnresolvedDialogue) { 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; } // 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 _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).`); } } 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 exact same // { includeNarrator: true } combination used manually — via console — for a // full autonomous end-to-end run this app was already driven through once) // 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: fall back to the exhaustive sweep, once. toast(`2.4 Still ${prev} unknown — running full verification pass…`, 'info'); await audiobookRecastUnknown(null, null, { includeNarrator: 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); } async function audiobookCast(overrideUrl, overrideModel, resume) { if (_audiobook.running) 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 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 = ''; } } } 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(); } } } 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); } } 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`); } // 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; } 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 _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 (_) {} } } _audiobook.segments = allSegments; _audiobook.lastText = text; _audiobook.roster = roster; _audiobook.narratedPassages = narrationOnly; _audiobook.degraded = degraded; _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 => `