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