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

2518 lines
129 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;
// 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));
}));
}
// ── Editable attribution preview ─────────────────────────────────────────────
async function audiobookOpenCurrentInRehearser() {
const segs = _audiobook.segments || [];
if (!segs.length) { toast('No cast to open', 'error'); return false; }
// Build the record straight from the CURRENT in-memory segments and load
// it directly — a prior version saved this to IndexedDB and then
// re-fetched it by id before loading, which could hand back a stale
// record (a read-after-write race, or a stale `_audiobook.rehId` left
// over from an earlier pass) instead of the just-corrected data. Building
// once and loading that same object removes the possibility entirely;
// the DB write below is now just for persistence, not the source of what
// gets loaded.
if (typeof window.rehLoadRecord === 'function' && typeof _audiobookBuildRehRecord === 'function') {
try {
const rec = await _audiobookBuildRehRecord(segs);
if (_audiobook.rehId) rec.id = _audiobook.rehId;
document.getElementById('audiobook-preview')?.remove();
if (typeof navTo === 'function') navTo('s-rehearser');
window.rehLoadRecord(rec);
// rehLoadRecord always lands on the Cast phase, but by the time you're
// opening the Rehearser from the Audiobook pipeline, voices were already
// assigned in Assign Voices — Cast there is pure redundant re-work.
// Jump straight to Stage (phase 3), the line-by-line editing view.
// showPhase() alone only toggles which phase <div> is visible — the
// actual script/character panes are built by buildScriptPage(), same
// as the Stage sub-tab's own click handler does.
if (rehState.lines.length && typeof buildScriptPage === 'function') {
buildScriptPage();
if (typeof showPhase === 'function') showPhase(3);
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
} else if (typeof showPhase === 'function') showPhase(3);
toast('Opened cast in Script Rehearser', 'success');
audiobookSaveAsRehearsal({ silent: true }).catch(() => {});
return true;
} catch (err) {
console.warn('[audiobook] failed to build rehearser record directly, falling back:', err);
}
}
await audiobookSaveAsRehearsal({ silent: true });
const { script, emotions } = audiobookBuildScript(segs);
await audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
if (rehState.lines.length && typeof buildScriptPage === 'function') {
buildScriptPage();
if (typeof showPhase === 'function') showPhase(3);
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
} else if (typeof showPhase === 'function') showPhase(3);
return true;
}
function audiobookShowPreview() {
audiobookOpenCurrentInRehearser();
return;
const segs = _audiobook.segments || [];
const roster = _audiobook.roster || [];
document.getElementById('audiobook-preview')?.remove();
const ov = document.createElement('div');
ov.id = 'audiobook-preview';
ov.className = 'audiobook-overlay';
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
const charCount = speakerSet.filter(n => !/^Unknown|Unbekannt/i.test(n)).length;
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast &amp; lines
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}${_audiobook.recoveredGaps ? ` <span class="audiobook-narr-note">${_audiobook.recoveredGaps} dropped sentence/paragraph gap${_audiobook.recoveredGaps !== 1 ? 's' : ''} recovered as Narrator — review placement.</span>` : ''}</div>
<datalist id="audiobook-roster">${opts}</datalist>
<div class="audiobook-seglist" id="audiobook-seglist"></div>
<div class="audiobook-actions">
<button class="btn-secondary btn-sm" id="audiobook-preview-cancel">Cancel</button>
<button class="btn-primary btn-sm" id="audiobook-preview-open"><span class="mdi mdi-account-music-outline"></span> Open in Rehearser</button>
</div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
<div class="audiobook-seg-text">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</div>
</div>`).join('');
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
}
function audiobookApplyPreviewAndOpen() {
const segs = _audiobook.segments;
document.querySelectorAll('#audiobook-seglist .audiobook-seg-sp').forEach(inp => {
const i = +inp.dataset.i; const v = inp.value.trim() || 'Narrator';
segs[i].speaker = v;
segs[i].type = (v.toLowerCase() === 'narrator') ? 'narration' : 'dialogue';
});
document.querySelectorAll('#audiobook-seglist .audiobook-seg-emo').forEach(inp => {
const i = +inp.dataset.i; segs[i].emotion = inp.value.trim();
});
const { script, emotions } = audiobookBuildScript(segs);
audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
}
// Build a rehearser script (CAPS speaker + line; narration as plain paragraphs)
// and a parallel list of per-dialogue-line emotions (same order as dialog lines).
// Inserts \f page-break markers at the segment boundary nearest each source PDF
// page start (so saved/opened scripts keep the book's pagination). Page positions
// are realigned per-segment against the source text to avoid cumulative drift.
function audiobookBuildScript(segments) {
let script = '';
const emotions = [];
const marks = _audiobook.pageMarks || [];
const src = _audiobook.lastText || '';
let markIdx = 0, searchPos = 0;
// The first page mark sits at the start of the document — no leading break.
if (marks.length && marks[0].offset <= 2) markIdx = 1;
for (const s of segments) {
const t = (s.text || '').trim(); if (!t) continue;
// Where does this segment sit in the source text? (verbatim narration matches
// exactly; dialogue text — quotes stripped — still occurs in the source.)
if (src && markIdx < marks.length) {
const probe = t.slice(0, 24);
const at = probe ? src.indexOf(probe, searchPos) : -1;
const pos = at >= 0 ? at : searchPos;
if (at >= 0) searchPos = at + probe.length;
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
if (script.trim()) {
script += `\n\f${marks[markIdx].page + 1}\n`; // page break — 1-indexed page number
}
markIdx++;
}
}
const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
if (isDialogue) {
script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';
emotions.push(s.emotion || '');
} else {
script += '\n' + t + '\n'; // narration → narrator reads these
}
}
return { script: script.trim(), emotions };
}
async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
if ($('reh-script-text')) $('reh-script-text').value = script;
if ($('reh-script-title')) $('reh-script-title').value = title;
if (typeof navTo === 'function') navTo('s-rehearser');
// Reuse the rehearser's own parse flow (builds lines, cast, jumps to Cast phase)
const btn = $('reh-parse-btn');
if (btn) btn.click();
else if (typeof parseScript === 'function') { rehState.lines = parseScript(script); }
// Apply per-line emotions to dialog lines in order (Phase 3: emotion-aware narration)
let speakers = 0, lines = 0;
if (window.rehState && Array.isArray(rehState.lines)) {
let k = 0;
rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = dialogueEmotions[k++]; if (e) l.emotion = e; lines++; } });
speakers = Object.keys(rehState.cast || {}).filter(s => !String(s).includes('NARRATOR')).length;
}
// Persist as a reopenable rehearsal so the cast/lines aren't lost — find it under
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
let saved = false;
if (typeof saveToLibrary === 'function') {
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {}
}
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
}
// ── Audiobook export (rehearser): synthesise every line → MP3 per chapter ────
function audiobookIsChapter(line) {
if (line.type === 'act' || line.type === 'scene') return true;
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
if (!t || t.length > 60) return false;
// OCR'd headings (chapter titles baked into the PDF as images, recovered via
// Tesseract) commonly lead with the chapter number rather than the keyword —
// "1.Kapitel", "I. Kapitel", "Kapitel 1" — so the keyword can't be required
// at position 0. Allow an optional leading number/roman numeral (with an
// optional trailing period, and no requirement for a space before the
// keyword, since OCR frequently drops the space in "1.Kapitel").
// A lone leading letter from the roman-numeral set is also just the first
// letter of an ordinary word — "C" (100) is real Roman notation, but it's
// also literally how "Chapter" starts. Confirmed live: without the
// lookahead requiring whitespace/period/end right after the numeral run,
// this silently ate the "C" off "Chapter 1: The Beginning", leaving
// "hapter 1..." — which then obviously failed the keyword match entirely.
const NUM = /^(?:[ivxlcdm]+(?=[.\s]|$)|\d{1,3})\.?\s*/i;
const rest = t.replace(NUM, '');
const KEYWORD = /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i;
if (!KEYWORD.test(rest)) return false;
// "part"/"book"/"teil" ("teil" = German "part") are common ordinary words,
// not just chapter markers — "Teil des Grundes war unklar." ("Part of the
// reason was unclear.") is a perfectly normal, short narration sentence
// that starts with the same word. Requiring the keyword at position 0 was
// never enough on its own; this used to only affect export file-splitting
// (harmless — one fewer bucket boundary), but wiring the same check into
// the Stage view's rendering made it consequential: an ordinary sentence
// getting misdetected now silently replaces real paragraph text with a
// thin chapter-marker line (confirmed live: "lots of empty pages" in the
// A4 pagination view, from real narration vanishing this way). Whatever
// follows the keyword must therefore look like part of a heading — empty,
// a bare trailing number/numeral, or a colon/dash-separated subtitle —
// never a normal grammatical continuation.
const afterKeyword = rest.replace(KEYWORD, '').trim();
if (afterKeyword === '') return true;
if (/^[ivxlcdm\d]{1,6}\.?$/i.test(afterKeyword)) return true;
return /^[ivxlcdm\d]{0,6}\.?\s*[-:–—]\s*.{1,40}$/i.test(afterKeyword);
}
// German ordinal STEM for 1-99 (no gender ending yet — that depends on the
// noun it modifies, see _abGermanOrdinalWord) — covers any chapter count a
// real novel would have. Returns null outside that range so the caller can
// fall back to the original digits rather than guessing.
const _AB_DE_ORDINAL_STEM_1_19 = ['', 'erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt',
'acht', 'neunt', 'zehnt', 'elft', 'zwölft', 'dreizehnt', 'vierzehnt', 'fünfzehnt',
'sechzehnt', 'siebzehnt', 'achtzehnt', 'neunzehnt'];
const _AB_DE_CARDINAL_TENS = { 2: 'zwanzig', 3: 'dreißig', 4: 'vierzig', 5: 'fünfzig', 6: 'sechzig', 7: 'siebzig', 8: 'achtzig', 9: 'neunzig' };
const _AB_DE_CARDINAL_ONES = ['', 'ein', 'zwei', 'drei', 'vier', 'fünf', 'sechs', 'sieben', 'acht', 'neun'];
// gender: 'n' (neuter, "-tes" — das Kapitel/Buch) or 'm' (masculine, "-ter" — der Teil).
function _abGermanOrdinalWord(n, gender) {
let stem;
if (n >= 1 && n <= 19) stem = _AB_DE_ORDINAL_STEM_1_19[n];
else if (n >= 20 && n <= 99) {
const tens = _AB_DE_CARDINAL_TENS[Math.floor(n / 10)];
const ones = n % 10;
if (!tens) return null;
stem = (ones ? _AB_DE_CARDINAL_ONES[ones] + 'und' : '') + tens + 'st';
} else return null;
return stem + (gender === 'm' ? 'er' : 'es');
}
// "Kapitel"/"Buch" are neuter (das), "Teil" is masculine (der) — "Dritter
// Teil" not "Drittes Teil".
const _AB_DE_HEADING_GENDER = { kapitel: 'n', buch: 'n', teil: 'm' };
// A German chapter/part heading like "1. Kapitel" or "3.Teil" reads badly
// through TTS as-is: the bare "<digits>." is exactly the shape a TTS
// frontend's number/list normalizer treats as a list marker rather than a
// number to speak, so it goes silent and only "Kapitel" gets voiced after an
// odd pause (confirmed live/reported). Spelling the ordinal out — "Erstes
// Kapitel" — sidesteps that normalizer entirely. Only rewrites the TTS-bound
// copy of the text (see the call site in audiobookExport); the underlying
// segment text is left alone everywhere else (casting, display, export
// filenames, audiobookIsChapter's own detection).
function _abSpokenChapterHeading(text, language) {
if (!/^de/i.test(language || '')) return text;
const m = /^(\d{1,2})\.\s*(Kapitel|Teil|Buch)\b(.*)$/i.exec((text || '').trim());
if (!m) return text;
const gender = _AB_DE_HEADING_GENDER[m[2].toLowerCase()] || 'n';
const word = _abGermanOrdinalWord(parseInt(m[1], 10), gender);
if (!word) return text;
return `${word[0].toUpperCase()}${word.slice(1)} ${m[2]}${m[3]}`;
}
// Shared between the export dialog's checklist (audiobookExportDialog) and
// the export progress popup's per-chapter label (audiobookExport) so
// "Chapter N" always means the same bucket in both places. The leading
// untitled bucket — speakable text before the first recognised chapter
// heading (title page/cover blurb OCR, a preface with no heading of its
// own) — is labeled "Front matter" instead of stealing "Chapter 1" and
// pushing every real chapter's number off by one (confirmed live).
function _abChapterLabels(buckets) {
const frontMatter = buckets.length > 1 && !buckets[0].title;
let num = 0;
return buckets.map((b, i) => (frontMatter && i === 0)
? 'Front matter'
: ('Chapter ' + (++num) + (b.title ? ': ' + b.title : '')));
}
// ── Pause settings (paragraph/chapter gaps + optional chapter sound) ────────
// Persisted per-browser (not per-book) via localStorage — these are pacing
// preferences, not book content, so they should carry over between exports.
function _abPauseSettings() {
const num = (key, def) => {
let v;
try { v = parseFloat(localStorage.getItem(key)); } catch (_) { v = NaN; }
return Number.isFinite(v) && v >= 0 ? v : def;
};
return {
paragraphMs: num('ttsvc_ab_pause_paragraph_s', 2) * 1000,
chapterMs: num('ttsvc_ab_pause_chapter_s', 4) * 1000,
};
}
function _abSetPauseSetting(key, seconds) {
try { localStorage.setItem(key, String(seconds)); } catch (_) {}
}
function _abChapterSfxDataUrl() {
try { return localStorage.getItem('ttsvc_ab_chapter_sfx') || null; } catch (_) { return null; }
}
function _abSetChapterSfx(dataUrl) {
try {
if (dataUrl) localStorage.setItem('ttsvc_ab_chapter_sfx', dataUrl);
else localStorage.removeItem('ttsvc_ab_chapter_sfx');
} catch (e) { toast('Could not save chapter sound — file may be too large', 'error'); }
}
// Decodes the user's custom chapter-transition sound (any format the browser
// can decode) and re-renders it at the EXACT sample rate/channels the
// narration clips use — mergeWavBlobs concatenates raw PCM bytes assuming a
// single shared format, so a mismatched sample rate here would play back
// pitched/sped-up or corrupt the merge, not just sound wrong.
async function _abChapterSfxWavBlob(fmt) {
const dataUrl = _abChapterSfxDataUrl();
if (!dataUrl) return null;
try {
const resp = await fetch(dataUrl);
const arrayBuf = await resp.arrayBuffer();
const AC = window.AudioContext || window.webkitAudioContext;
const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const tmpCtx = new AC();
const decoded = await tmpCtx.decodeAudioData(arrayBuf.slice(0));
if (typeof tmpCtx.close === 'function') tmpCtx.close();
const offline = new OAC(fmt.channels, Math.max(1, Math.ceil(decoded.duration * fmt.sampleRate)), fmt.sampleRate);
const src = offline.createBufferSource();
src.buffer = decoded;
src.connect(offline.destination);
src.start();
const rendered = await offline.startRendering();
const frames = rendered.length;
const pcm = new Int16Array(frames * fmt.channels);
for (let ch = 0; ch < fmt.channels; ch++) {
const data = rendered.getChannelData(Math.min(ch, rendered.numberOfChannels - 1));
for (let i = 0; i < frames; i++) {
const s = Math.max(-1, Math.min(1, data[i]));
pcm[i * fmt.channels + ch] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
}
return _buildWavBlob(new Uint8Array(pcm.buffer), fmt);
} catch (e) {
console.error('[audiobook export] chapter sound decode failed', e);
toast('Could not use the custom chapter sound — check the audio file', 'error');
return null;
}
}
// Wires the Perform & Export pause-settings fields to localStorage. Called
// every time Studio's phase 4 is shown (studio.js) — guarded so listeners
// are only attached once, since the fields themselves are never reparented
// away (unlike the borrowed Stage/tone-warning elements).
let _abPauseUIInited = false;
function _abInitPauseUI() {
const pEl = $('stu-pause-paragraph'), cEl = $('stu-pause-chapter');
const sfxFile = $('stu-chapter-sfx-file'), sfxClear = $('stu-chapter-sfx-clear'), sfxCurrent = $('stu-chapter-sfx-current');
if (!pEl || !cEl) return;
const s = _abPauseSettings();
pEl.value = s.paragraphMs / 1000;
cEl.value = s.chapterMs / 1000;
if (sfxCurrent) sfxCurrent.textContent = _abChapterSfxDataUrl() ? 'Custom sound set' : 'No custom sound';
if (_abPauseUIInited) return;
_abPauseUIInited = true;
pEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_paragraph_s', Math.max(0, parseFloat(pEl.value) || 0)));
cEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_chapter_s', Math.max(0, parseFloat(cEl.value) || 0)));
sfxClear?.addEventListener('click', () => {
_abSetChapterSfx(null);
if (sfxFile) sfxFile.value = '';
if (sfxCurrent) sfxCurrent.textContent = 'No custom sound';
});
sfxFile?.addEventListener('change', () => {
const file = sfxFile.files && sfxFile.files[0];
if (!file) return;
// Capped well under localStorage's ~5-10MB origin quota (base64 inflates
// size by ~33%) — a chapter chime doesn't need to be a whole song.
if (file.size > 2 * 1024 * 1024) {
toast('Chapter sound is too large (max 2 MB) — trim it to a short chime/sting', 'error');
sfxFile.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
_abSetChapterSfx(reader.result);
if (sfxCurrent) sfxCurrent.textContent = file.name;
};
reader.onerror = () => toast('Could not read that audio file', 'error');
reader.readAsDataURL(file);
});
}
window._abInitPauseUI = _abInitPauseUI;
function audiobookLineVoice(l) {
if (l.type === 'dialog') {
const c = rehState.cast[l.speaker] || {};
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion, c.voice) : '') };
}
return { voice: rehState.narratorVoice, instruct: '' };
}
// Long narrator paragraphs must be split before they reach the TTS service.
// The service has to restart its decoder/model context for a very long
// request, and its own emergency cut can land mid-sentence. Keep the split
// sentence-aware and add only a short, intentional breath between generated
// pieces; normal paragraph gaps are added later when chapters are merged.
async function _abSynthesizeLongText(voice, text, instruct, backend) {
const pieces = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, 900)
: [text];
if (pieces.length <= 1) return fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend);
const blobs = [];
for (const piece of pieces) {
blobs.push(await fetchTtsPreviewBlob(voice, piece, 'wav', instruct, backend));
}
return mergeWavBlobs(blobs, 120);
}
async function audiobookExport(opts = {}) {
if (_abBusyGuard('Export')) 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; }
if (typeof _ensureNarrator === 'function') _ensureNarrator();
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());
};
// Bucket speakable lines into chapters (by chapter headings / act / scene)
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); }
});
let activeBuckets = buckets;
if (opts.selectedChapterIndices) {
activeBuckets = buckets.filter((_, i) => opts.selectedChapterIndices.includes(i));
}
const allIdx = activeBuckets.flatMap(b => b.idx);
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
// "Synthesising line 812 / 2351…" alone gives no sense of where in the
// book that is for a multi-hour export — map each line back to its
// chapter so the progress popup can show "Chapter 5 / 13 · line …" too
// (requested live).
const activeLabels = _abChapterLabels(activeBuckets);
const lineChapterPos = new Map();
activeBuckets.forEach((b, pos) => b.idx.forEach(idx => lineChapterPos.set(idx, pos)));
const progLabel = (doneCount, i) => {
const label = activeLabels[lineChapterPos.get(i)];
return (label ? label + ' · ' : '') + `Synthesising line ${doneCount} / ${allIdx.length}…`;
};
_audiobook.running = true; _audiobook.cancel = false;
const prog = audiobookProgress(allIdx.length);
// WAV, not mp3, per line: each clip needs to be losslessly merged before
// any lossy encoding happens (see the merge step below for why) — encoding
// to mp3 here, one clip at a time, is exactly what produced a file most
// players couldn't play past the first clip.
const wavClips = new Map();
const failedLines = [];
let done = 0;
const queue = allIdx.slice();
const worker = async () => {
while (queue.length && !_audiobook.cancel) {
const i = queue.shift();
// "Synth all" already pre-synthesizes every line into rehState.synthCache
// for instant playback — this export used to ignore that entirely and
// re-hit the TTS API for every single line from scratch regardless,
// turning what should be an instant export (for anyone who already
// pre-synthesized) into a full re-synthesis of the whole book, easily
// 20+ minutes for a real novel with no visible sign anything had gone
// wrong — confirmed as the actual cause behind "clicked Audiobook and
// got no file": it was still working, just silently redoing work that
// was already done. A cached, non-stale clip is reused as-is; a stale
// one (tone edited, or the speaker's voice reassigned — see
// _rehInvalidateCastVoice) is skipped so the branch below re-synthesizes.
if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) {
wavClips.set(i, rehState.synthCache.get(i));
prog.update(++done, progLabel(done, i));
continue;
}
try {
// audiobookLineVoice/_rehInlineTone/stripMarkdown used to run OUTSIDE
// this try — any of them throwing on one unusual line (confirmed live:
// an export died silently around line ~600/2351) escaped worker()
// entirely uncaught. That unwound straight past the caller's `finally`
// (which calls prog.done(), removing the progress popup) with no catch
// downstream to show a toast — the whole multi-hour export vanishing
// with zero explanation, worse than the "stop instead of merging a
// file with silent gaps" this file already stops other failures for.
// Now it's just one more line added to failedLines like any other.
const l = rehState.lines[i];
const { voice, instruct } = audiobookLineVoice(l);
let text = (typeof _rehInlineTone === 'function')
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
if (l.type !== 'dialog' && audiobookIsChapter(l)) text = _abSpokenChapterHeading(text, audiobookLang());
// Beyond rehState.synthCache (this browser tab's memory, gone on
// reload), lines synthesized via "Synth all" in an EARLIER session
// persist to disk too — check there before hitting TTS at all.
let blob = null, cacheKey = null, book = null;
if (typeof _lineAudioCacheKey === 'function') {
book = _lineAudioBookName();
cacheKey = await _lineAudioCacheKey(text, voice, instruct);
blob = await _lineAudioCacheGet(book, cacheKey);
}
if (!blob) {
// fetchTtsPreviewBlob's own retry (_ttsPreviewFetchWithRetry) only
// covers a connection-level failure — fetch() doesn't throw on a
// non-2xx response, so a backend hiccup (confirmed live: transient
// 500s during the first ~50 lines, most likely GPU/engine warm-up
// contention from the two parallel workers both starting cold)
// throws straight out of fetchTtsPreviewBlob itself, past that
// retry layer entirely. A single blip like that used to doom the
// ENTIRE multi-hour export — confirmed live, different ordinary,
// unremarkable lines failed on repeated attempts, so this isn't
// about any one line's content. Give a transient failure a few
// seconds to pass before giving up on this line for real.
let lastErr;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
blob = await _abSynthesizeLongText(voice, text, instruct, _ttsBackendForVoice(voice, rehState.backend));
lastErr = null;
break;
} catch (e) {
lastErr = e;
if (attempt < 3) await new Promise(r => setTimeout(r, 3000 * attempt));
}
}
if (lastErr) throw lastErr;
if (cacheKey) _lineAudioCachePut(book, cacheKey, blob);
}
wavClips.set(i, blob);
rehState.synthCache.set(i, blob); // benefits a retry/re-export too
}
catch (e) { failedLines.push(i); console.error('[audiobook export] synth failed for line', i, e); }
prog.update(++done, progLabel(done, i));
}
};
// Used to run 2 workers concurrently to speed up bulk export. Every TTS
// backend this app talks to (voice_clone, voice_design, fishspeech, the
// other local engines) is a single self-hosted GPU model instance, not a
// horizontally-scaled service — confirmed live, twice, with two different
// backends: 2 concurrent requests reliably push at least one past the
// reverse proxy's 60s timeout under real load, producing exactly the
// "some lines just fail, no clear reason" pattern that made a multi-hour
// book export fail outright. Serial is slower per line but doesn't waste
// time on doomed, retried, ultimately-failing requests — net faster and
// actually finishes.
// Defense in depth beyond the per-line try above: an export this long
// (thousands of lines, potentially hours) failing SILENTLY — progress
// popup just gone, no toast, nothing in server logs because the crash
// never even reached a network call — is far worse than any other failure
// mode this file already guards against. Whatever still gets through here
// is at least reported instead of vanishing.
try { await worker(); }
catch (e) {
console.error('[audiobook export] worker crashed:', e);
toast('Export stopped unexpectedly: ' + (e.message || e), 'error');
return;
}
finally { prog.done(); _audiobook.running = false; }
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
// A line whose synthesis failed used to just be missing from `mp3`, so
// `buckets[c].idx.map(...).filter(Boolean)` silently dropped it out of the
// merged file — the export "succeeded" while quietly missing dialogue/
// narration, discoverable only by listening to the whole thing. Stop
// instead of merging a file with silent gaps in it.
if (failedLines.length) {
toast(failedLines.length + ' line(s) failed to synthesise — retry them (Script Rehearser → re-synthesise stale) before exporting, or they will silently drop from the audiobook', 'error');
return;
}
const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook'));
const realChapters = buckets.filter(b => b.title).length > 0;
let files = 0;
const savedFiles = []; // {name, url} — for the results panel below
const bookForExport = _lineAudioBookName();
// The "Save Location (Server)" field's default value literally reads
// "exports/" — treat that (and blank) as "use the app's normal export
// folder" rather than actually nesting into a folder named "exports",
// which would otherwise silently relocate every default export the moment
// this field started being honored server-side (it used to be collected
// and sent but ignored — confirmed live, ` /api/audiobook-export` never
// read its `folder` query param).
const _folderRaw = (opts.folder || '').trim().replace(/\/+$/, '');
const folder = (_folderRaw && _folderRaw.toLowerCase() !== 'exports') ? _folderRaw : '';
const folderQS = folder ? '?folder=' + encodeURIComponent(folder) : '';
// Remembered per book so "Browse saved exports" and the zip-all link find
// files saved under a custom folder later, without the user re-entering it.
try {
const key = 'ttsvc_export_folder:' + bookForExport;
if (folder) localStorage.setItem(key, folder); else localStorage.removeItem(key);
} catch (_) {}
const encMsg = $('audiobook-msg');
const pauses = _abPauseSettings();
if (opts.format === 'single') {
let masterBlobs = [];
for (let c = 0; c < activeBuckets.length; c++) {
const blobs = activeBuckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
if (!blobs.length) continue;
if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${activeBuckets.length}…`;
let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs);
if (c > 0 && activeBuckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt;
const lead = [];
const sfx = await _abChapterSfxWavBlob(ref);
if (sfx) lead.push(sfx);
if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref));
if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]);
}
masterBlobs.push(mergedWav);
}
if (masterBlobs.length) {
if (encMsg) encMsg.textContent = `Encoding full book to MP3…`;
let finalWav = await mergeWavBlobs(masterBlobs, 0);
let blob = finalWav;
try {
const encResp = await fetch('/api/audio/encode-mp3', { method: 'POST', body: finalWav });
if (encResp.ok) blob = await encResp.blob();
} catch (e) {}
const ext = blob === finalWav ? 'wav' : 'mp3';
const name = `${title}.${ext}`;
if (typeof readerDownload === 'function') readerDownload(blob, name);
try {
const exportUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;
const r = await fetch(exportUrl, { method: 'POST', body: blob });
if (r.ok) savedFiles.push({ name, url: exportUrl });
} catch (e) {}
files++;
}
} else {
for (let c = 0; c < activeBuckets.length; c++) {
const blobs = activeBuckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
if (!blobs.length) continue;
// mergeWavBlobs (generation.js) properly concatenates raw PCM sample
// data under one RIFF header — unlike naively Blob-concatenating
// separately-encoded mp3 clips (each with its own frame/ID3 headers),
// this produces one genuinely continuous, seekable audio stream.
// A silent gap is inserted between every pair of lines (`pauses.paragraphMs`)
// — mergeWavBlobs used to butt clips together with literally zero space,
// which read as characters teleporting mid-scene with no beat between them.
if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${activeBuckets.length}…`;
let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs);
// Every chapter after the first gets a longer lead-in pause (and, if the
// user configured one, a custom transition sound) prepended — the first
// chapter needs no lead-in since there's nothing before it to separate
// from. This survives even when chapters export as separate files (the
// normal case — see `realChapters` below): most audiobook players queue
// consecutive tracks back-to-back with no gap of their own.
if (c > 0 && activeBuckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt;
const lead = [];
const sfx = await _abChapterSfxWavBlob(ref);
if (sfx) lead.push(sfx);
if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref));
if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]);
}
// One real mp3 encode pass over the whole merged chapter, at an
// explicit bitrate (routes/tts.py's /api/audio/encode-mp3) — the old
// per-line encoding left the bitrate at ffmpeg/lame's unset default,
// which came out to 32kbps, well below what even a 24kHz mono voice
// source needs to avoid audible compression artifacts.
if (encMsg) encMsg.textContent = `Encoding chapter ${c + 1} / ${activeBuckets.length}…`;
let blob = mergedWav;
try {
const encResp = await fetch('/api/audio/encode-mp3', { method: 'POST', body: mergedWav });
if (encResp.ok) blob = await encResp.blob();
else console.error('[audiobook export] mp3 encode failed, shipping wav instead:', encResp.status);
} catch (e) { console.error('[audiobook export] mp3 encode request failed, shipping wav instead:', e); }
const ext = blob === mergedWav ? 'wav' : 'mp3';
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
const name = (realChapters || buckets.length > 1)
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.${ext}`
: `${title}.${ext}`;
if (typeof readerDownload === 'function') readerDownload(blob, name);
// Also persist server-side — the browser download alone lands wherever
// the user's browser settings put it, with nothing in the app itself
// saying where, and re-finding it later means re-running the whole
// export from scratch. Best-effort: the browser download above already
// succeeded either way.
try {
const saveUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;
const saveResp = await fetch(saveUrl, { method: 'POST', body: blob });
if (saveResp.ok) savedFiles.push({ name, url: saveUrl });
} catch (_) {}
files++;
await new Promise(r => setTimeout(r, 400));
}
} // close else block
toast('Exported audiobook · ' + files + (realChapters ? ' chapter file(s)' : ' file(s)'), 'success');
if (savedFiles.length && typeof _abShowExportResults === 'function') _abShowExportResults(bookForExport, savedFiles, { folder });
}
// A small results panel after "Audiobook" finishes — separate from the
// success toast, which is plain text and disappears after a few seconds
// with no way to actually get back to the files. Lists each saved chapter
// with a real download link and shows the server-side folder they live in,
// so the export doesn't just vanish into whatever the browser's download
// settings did with it.
function _abShowExportResults(book, files, opts = {}) {
document.getElementById('ab-export-results')?.remove();
const ov = document.createElement('div');
ov.id = 'ab-export-results';
ov.className = 'audiobook-overlay';
const folderQS = opts.folder ? '?folder=' + encodeURIComponent(opts.folder) : '';
const zipUrl = `/api/audiobook-export/${encodeURIComponent(book)}/zip${folderQS}`;
ov.innerHTML = `<div class="audiobook-box" style="max-width:520px;">
<div class="audiobook-title"><span class="mdi mdi-book-music-outline"></span> ${opts.browsing ? 'Saved audiobook exports' : 'Audiobook exported'}<span style="flex:1"></span><button type="button" class="btn-secondary btn-sm" id="ab-export-close">Close</button></div>
${opts.browsing ? '' : `<div style="display:flex; align-items:center; gap:8px; margin:10px 0 4px; color:#2e7d32; font-weight:600; font-size:13px;"><span class="mdi mdi-check-circle-outline" style="font-size:18px;"></span> Export complete — ${files.length} file${files.length === 1 ? '' : 's'} saved</div>`}
<p class="card-subtitle" style="margin:4px 0 14px;">Saved on the server${opts.browsing ? '' : ', in case the browser’s own download went somewhere you don’t check'} — come back and download or play any of these again any time, without re-exporting.</p>
<div style="display:flex; flex-direction:column; gap:8px; max-height:50vh; overflow-y:auto;">
${files.map((f, i) => `
<div class="ab-export-file-row" data-idx="${i}" style="display:flex; align-items:center; gap:10px; padding:8px 10px; border:1px solid var(--border); border-radius:8px;">
<button type="button" class="btn-secondary btn-sm ab-export-play-btn" title="Play"><span class="mdi mdi-play"></span></button>
<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px;" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
<a class="btn-primary btn-sm" href="${f.url}" download="${escHtml(f.name)}"><span class="mdi mdi-download"></span> Download</a>
</div>`).join('')}
</div>
${files.length > 1 ? `<div style="margin-top:14px; text-align:right;"><a class="btn-secondary btn-sm" href="${zipUrl}"><span class="mdi mdi-folder-zip-outline"></span> Download all (.zip)</a></div>` : ''}
</div>`;
document.body.appendChild(ov);
// One shared <audio> element for the whole panel — only one row plays at
// a time, requested live alongside a clearer "it actually finished" state
// (the plain success toast alone disappears in a few seconds with no way
// back to the files, and there was previously no way to preview a result
// without downloading it first).
const audioEl = new Audio();
let playingIdx = null;
const setIcon = (idx, playing) => {
const icon = ov.querySelector(`.ab-export-file-row[data-idx="${idx}"] .ab-export-play-btn .mdi`);
if (icon) icon.className = 'mdi ' + (playing ? 'mdi-pause' : 'mdi-play');
};
const stop = () => { audioEl.pause(); if (playingIdx !== null) setIcon(playingIdx, false); playingIdx = null; };
ov.querySelectorAll('.ab-export-play-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.closest('.ab-export-file-row').dataset.idx, 10);
if (playingIdx === idx && !audioEl.paused) { stop(); return; }
const prevIdx = playingIdx;
audioEl.src = files[idx].url;
audioEl.play().catch(e => toast('Could not play: ' + (e.message || e), 'error'));
if (prevIdx !== null) setIcon(prevIdx, false);
playingIdx = idx;
setIcon(idx, true);
});
});
audioEl.addEventListener('ended', stop);
const close = () => { stop(); ov.remove(); };
ov.querySelector('#ab-export-close').addEventListener('click', close);
ov.addEventListener('click', e => { if (e.target === ov) close(); });
}
// "Browse" the exports already saved for the current book, without
// re-running the export — the entry point for finding a chapter you
// downloaded in an earlier session and can't find in your Downloads folder.
async function audiobookBrowseExports() {
const book = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : ($('reh-script-title')?.value.trim() || 'Untitled');
// A custom "Save Location" moves where this book's exports live server-side
// (see audiobookExport's folderQS) — without also passing it here, this
// would always look in the fixed default dir and report "no saved exports"
// for a book that was actually exported to a custom folder.
let folder = '';
try { folder = localStorage.getItem('ttsvc_export_folder:' + book) || ''; } catch (_) {}
const folderQS = folder ? '?folder=' + encodeURIComponent(folder) : '';
try {
const r = await fetch(`/api/audiobook-export/${encodeURIComponent(book)}${folderQS}`);
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
if (!d.files || !d.files.length) { toast('No saved exports yet for this book — run "Audiobook" first', 'info'); return; }
const files = d.files.map(f => ({ name: f.name, url: `/api/audiobook-export/${encodeURIComponent(book)}/${encodeURIComponent(f.name)}${folderQS}` }));
_abShowExportResults(book, files, { browsing: true, folder });
} catch (e) {
toast('Could not load saved exports: ' + (e.message || e), 'error');
}
}
$('reh-tb-browse-exports')?.addEventListener('click', audiobookBrowseExports);
// ── 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';
}
}
window.TTVC = window.TTVC || {};
window.TTVC.audiobook = window.TTVC.audiobook || {};
Object.assign(window.TTVC.audiobook, {
startCast: audiobookCast,
openCastView: audiobookOpenCastView,
recastUnknown: audiobookRecastUnknown,
recastSelectedCharacters: audiobookRecastSelectedCharacters,
export: audiobookExport,
saveAsRehearsal: audiobookSaveAsRehearsal,
});