tts-voice-creator-clone-and.../static/js/audiobook.js

1741 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 = '<span class="mdi mdi-close"></span> 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 = '<span class="mdi mdi-loading mdi-spin"></span> 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 <span> — 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 `<button class="ab-cv-check${on ? ' is-checked' : ''}" type="button" title="${on
? 'Manually checked — click to un-check'
: 'Mark as manually checked (removes it from the review queue)'}"><span class="mdi mdi-${on ? 'check-circle' : 'check-circle-outline'}"></span></button>`;
}
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 `<span class="ab-cv-conf ab-conf-checked" title="You confirmed this line (↑)">✓ Correct</span>`;
if (mark === 'wrong') return `<span class="ab-cv-conf ab-conf-wrong" title="You flagged this line as wrong (↓) — revisit it with the “Marked wrong” filter">✗ Wrong</span>`;
const word = s.confLabel || label;
return `<span class="ab-cv-conf ${cls}" title="${escHtml(word + ' — right about ' + s.conf + '% of the time' + why)}">${word} ${s.conf}%</span>`;
}
// 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 <language>" 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 = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
<div class="audiobook-msg" id="audiobook-msg">Analysing…</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
</div>`;
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, '<span class="ab-q">$1</span>');
}
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 => `<span style="border-bottom: 2px solid ${color}; font-weight: 600;">${m}</span>`);
});
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 = `<div class="audiobook-box" style="text-align:center;">
<span class="mdi mdi-loading mdi-spin" style="font-size:28px; color:var(--accent);"></span>
<div class="audiobook-msg ab-busy-msg" style="margin:12px 0 0;">${escHtml(message)}</div>
${withProgress ? '<div class="reader-synth-track" style="margin-top:10px"><div class="reader-synth-fill ab-busy-fill" style="width:0%"></div></div>' : ''}
</div>`;
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 <body>, 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
? '<div class="ab-foot-menu-sep"></div>'
: `<button type="button" class="ab-foot-menu-item${it.danger ? ' is-danger' : ''}" data-idx="${i}" ${it.disabled ? 'disabled' : ''} title="${escHtml(it.title || '')}"><span class="mdi ${it.icon}"></span><span class="ab-foot-menu-item-label">${escHtml(it.label)}</span></button>`
).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
? `<span class="ab-char-dot ab-char-dot-img" style="border-color:${color}"><img src="${escHtml(c.image)}" alt=""></span>`
: `<span class="ab-char-dot" style="background:${color}">${escHtml((c?.name || '?')[0].toUpperCase())}</span>`;
return `<label class="ab-char-item" data-name="${escHtml(c.name)}">
<input type="checkbox" class="ab-recast-cb" value="${escHtml(c.name)}"${selected.has(c.name) ? ' checked' : ''}>
${avatar}
<span class="ab-char-name">${escHtml(c.name)}</span>
<b class="ab-char-count">${lineCount}</b>
</label>`;
};
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('') : '<div class="ab-cv-empty">No matches</div>';
list.querySelectorAll('.ab-recast-cb').forEach(cb => {
cb.addEventListener('change', () => { if (cb.checked) selected.add(cb.value); else selected.delete(cb.value); });
});
};
ov.innerHTML = '<div class="audiobook-box ab-recast-dialog">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Select characters to cast or recast roles</div>'
+ '<div class="audiobook-msg">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.</div>'
+ '<div class="ab-cv-side-tools">'
+ '<input type="text" class="ab-cv-side-search" id="ab-recast-search" placeholder="Search characters..." autocomplete="off">'
+ '<select class="ab-cv-side-sort" id="ab-recast-sort" title="Sort">'
+ '<option value="lines">Lines</option>'
+ '<option value="alpha">A–Z</option>'
+ '</select>'
+ '</div>'
+ '<div class="ab-recast-select-list"></div>'
+ '<div class="audiobook-actions">'
+ '<button class="btn-secondary btn-sm" id="ab-recast-select-all">Select all</button>'
+ '<span style="flex:1"></span>'
+ '<button class="btn-secondary btn-sm" id="ab-recast-cancel">Cancel</button>'
+ '<button class="btn-primary btn-sm" id="ab-recast-confirm">Generate selected sheets</button>'
+ '</div>'
+ '</div>';
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';
}
}