// ── 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 —
// the old 90s timeout aborted almost every passage before the model finished.
const AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS = 360000;
const AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS = 180000;
const AUDIOBOOK_RECAST_TIMEOUT_MS = 240000;
const AUDIOBOOK_RECAST_CONTEXT_CHARS = 4200;
const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6;
const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000;
const _audiobook = { running: false, cancel: false };
window._audiobook = _audiobook;
let _abDraftAutosaveTimer = null;
let _abLastServerDraftErrorAt = 0;
async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
const parentSignal = options.signal;
const ac = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
ac.abort();
}, timeoutMs);
const onParentAbort = () => ac.abort(parentSignal?.reason);
if (parentSignal) {
if (parentSignal.aborted) onParentAbort();
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
}
try {
return await fetch(url, { ...options, signal: ac.signal });
} catch (err) {
if (timedOut) {
const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`);
timeoutErr.name = 'TimeoutError';
throw timeoutErr;
}
throw err;
} finally {
clearTimeout(timer);
if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort);
}
}
function audiobookTimeoutSeconds(timeoutMs) {
return Math.max(5, Math.round(timeoutMs / 1000));
}
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 || ''}"`;
}
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;
}
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);
}
function _abCastMarkdown(data) {
const payload = data || {};
return [
'# Cast Script',
'',
`**Book:** ${payload.title || ''} `,
`**Saved:** ${payload.savedAt || ''} `,
`**Segments:** ${Array.isArray(payload.segments) ? payload.segments.length : 0} `,
'',
'```json',
JSON.stringify(payload, null, 2),
'```',
'',
].join('\n');
}
// One-time migration for drafts saved before segments carried a .page number:
// re-derive each segment's page from the draft's pageMarks via the old
// forward-only offset search and stamp it on. Mirrors the legacy render-time
// fallback exactly (segments before the first mark stay unstamped → unlabeled
// leading card), but runs once at draft load instead of on every redraw, so
// the feed renderer stays single-path.
function _abStampSegmentPages(segs, pageMarks, text) {
if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return;
const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
const src = text || '';
if (!marks.length || !src) return;
let markIdx = 0, searchPos = 0, cur = null;
if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; }
for (const s of segs) {
const probe = String(s.text || '').trim().slice(0, 24);
const at = probe ? src.indexOf(probe, searchPos) : -1;
const pos = at >= 0 ? at : searchPos;
if (at >= 0) searchPos = at + probe.length;
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
cur = marks[markIdx].page + 1;
markIdx++;
}
if (cur != null) s.page = cur;
}
}
function _abSaveDraft(segs, roster, text, done, total) {
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');
}
});
}
}
async function audiobookExportCastMd() {
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.md`;
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 Markdown', '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 «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/;
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
// so the audiobook reads cleanly and speaker tags aren't split.
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
const AB_NOTNAME = new Set([
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
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.
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 };
}
function audiobookSplitByQuotes(text) {
const spans = [];
for (let i = 0; i < text.length; i++) {
const open = text[i];
const close = AB_QUOTE_PAIRS[open];
if (!close) continue;
const minStart = spans.length ? spans[spans.length - 1].end : 0;
const orphan = _abOrphanClosingQuoteSpan(text, i, minStart);
if (orphan) {
spans.push(orphan);
i = orphan.end - 1;
continue;
}
if ((open === '«' || open === '»') && i > 0 && !/[\s([{—–-]/.test(text[i - 1])) continue;
const raw = text.slice(i + 1);
const closeIdx = raw.indexOf(close);
const endInRaw = _abUnclosedQuoteEnd(raw, closeIdx);
const quote = raw.slice(0, endInRaw).trim();
if (!quote) continue;
const consumedClose = closeIdx >= 0 && endInRaw === closeIdx;
spans.push({ start: i, end: i + 1 + endInRaw + (consumedClose ? 1 : 0), quote });
i = spans[spans.length - 1].end - 1;
}
if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }];
const out = []; let last = 0;
for (let k = 0; k < spans.length; k++) {
const sp = spans[k];
const pre = text.slice(last, sp.start);
if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' });
if (sp.quote) {
const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length);
const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown';
out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' });
}
last = sp.end;
}
const tail = text.slice(last).trimEnd();
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
return audiobookTurnTaking(out);
}
// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO
// distinct named speakers are established nearby (conservative: won't guess in a
// monologue, so it rarely invents a wrong name).
function audiobookTurnTaking(segs) {
let a = null, b = null; // two most recent distinct named speakers (b = latest)
for (const s of segs) {
if (s.type !== 'dialogue') continue;
if (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) {
if (s.speaker !== b) { a = b; b = s.speaker; }
} else if (a && b && a !== b) {
s.speaker = a; // the other of the two → alternate
const t = a; a = b; b = t; // rotate so the next Unknown alternates back
}
}
return segs;
}
function audiobookIsRouterModel(model) {
return /^auto[-_ ]?router/i.test(String(model || '').trim());
}
function audiobookSafeLlmModel(model) {
const m = String(model || '').trim();
const saved = String((typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_model) || '').trim();
if (audiobookIsRouterModel(m) && saved && !audiobookIsRouterModel(saved)) return saved;
return m || saved;
}
function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof _appSettings !== 'undefined' && _appSettings?.llm_url) || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
function audiobookLlmModel() { return audiobookSafeLlmModel($('reh-llm-model')?.value || ''); }
function audiobookLang() { return $('reh-design-lang')?.value || ''; }
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;
}
}
const raw = idxs.map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
// Locate each page anchor in the final text → page-break offsets.
let from = 0;
for (const a of anchors) {
const probe = audiobookDehyphenate(a.anchor).slice(0, 24);
if (!probe) continue;
const pos = text.indexOf(probe, from);
if (pos >= 0) { _audiobook.pageMarks.push({ offset: pos, page: a.page }); from = pos; }
}
return text;
}
// ── Progress overlay ─────────────────────────────────────────────────────────
function audiobookProgress(total) {
let ov = document.getElementById('audiobook-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'audiobook-overlay';
ov.className = 'audiobook-overlay';
ov.innerHTML = `
Casting audiobook
Analysing…
`;
document.body.appendChild(ov);
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
}
ov.hidden = false;
const fill = ov.querySelector('#audiobook-fill');
const msg = ov.querySelector('#audiobook-msg');
return {
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
done() { ov.hidden = true; },
};
}
// Delegates to the character library's canonical colour implementation
// (clNormalizeColor / clHslToHex / clNameHue in characters-library.js) so
// avatar colours can't drift between views; only the 'Narrator' default for a
// missing name lives here.
function _abNormalizeColor(color, fallbackName) {
return clNormalizeColor(color, fallbackName || 'Narrator');
}
function _abDefaultCharacterColor(name) {
if (/^Unknown|Unbekannt/i.test(name || '')) return '#a83232';
return _abNormalizeColor(null, name);
}
function _abRecordColor(rec, name) {
return _abNormalizeColor(rec?.color || rec?.sheet?.color, name || rec?.name);
}
// Escape a segment's text and underline any known character names. Module-level
// so the review/preview overlay (audiobookShowPreview) can use it too — the cast
// view (audiobookCastView) defines its own roster-coloured version that shadows
// this inside its closure. Names default to the current run's roster.
function highlightText(text, names) {
if (!text) return '';
let html = escHtml(text);
const list = (names || (_audiobook && _audiobook.roster) || [])
.filter(n => n && n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n))
.sort((a, b) => b.length - a.length);
list.forEach((name) => {
if (name.length < 2) return;
const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const color = _abDefaultCharacterColor(name);
html = html.replace(new RegExp(`\\b(${safe})\\b`, 'gi'),
m => `${m}`);
});
return html;
}
// 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.
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
const panel = document.getElementById('reader-audiobook-panel');
if (!panel) return;
if (typeof window.navReaderView === 'function') window.navReaderView('cast');
else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
else {
const mainView = document.getElementById('reader-main-view');
if (mainView) mainView.hidden = true;
panel.hidden = false;
}
panel.className = 'ab-castpanel-inline card';
panel.style.display = '';
panel.style.flexDirection = '';
panel.style.minHeight = '';
panel.innerHTML = `
Casting audiobookpassage 0 / ${total}
Characters found
${[38,62,45,28,54,35].map(w => `
`).join('')}
Ready to cast.
`;
const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen.
WICHTIGE DEFINITION VON DIALOG:
Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden.
Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).
GRAMMATIK-REGELN FÜR NARRATION:
- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").
- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.
- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.
- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.
ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv):
1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf.
2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausführt und direkt davor/danach wörtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. »Es regnet.«").
3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette lückenlos zurück zur letzten eindeutigen Nennung.
4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters?
5. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
6. Unbekannte Sprecher: Nur wenn eine Zuordnung durch Kontext absolut nicht möglich ist, verwende 'Unknown'. Rate nicht blind, aber bevorzuge immer einen namentlich genannten Charakter gegenüber 'Unknown'.
FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS:
- speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge.
- type: 'narration' oder 'dialogue'
- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen).
- emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen ('').
STRIKTE FORMAT- UND TEXTREGELN:
- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt.").
- »Text?« und »Text!« sind vollständige Dialoge — das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht.
- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("flüsterte er", "sagte sie", "rief Uriens") oder normale Erzählerhandlung weitergeht.
- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. "Der Tod trägt rot. «"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.
- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'.
- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!`;
const normalizeCastingPrompt = (prompt) => {
let p = prompt || AB_DEFAULT_PROMPT;
p = p.replace(
"- Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'.",
"- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt (\"flüsterte er\", \"sagte sie\", \"rief Uriens\") oder normale Erzählerhandlung weitergeht.\n- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.\n- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'."
);
p = p.replace(
"- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen.",
"- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen)."
);
p = p.replace(
"- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!",
"- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!"
);
if (!/GRAMMATIK-REGELN FÜR NARRATION/.test(p)) {
p = p.replace(
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).',
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.'
);
}
return p;
};
const rawPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT;
const globalPrompt = normalizeCastingPrompt(rawPrompt);
if (globalPrompt !== rawPrompt && typeof _appSettings !== 'undefined') {
_appSettings.audiobook_prompt = globalPrompt;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: globalPrompt }) }).catch(() => {});
}
panel.querySelector('#ab-cv-prompt-text').value = globalPrompt;
const closePanel = () => {
panel.hidden = true;
panel.innerHTML = '';
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
if (typeof window.navReaderView === 'function') window.navReaderView('main');
else if (typeof window.showReaderView === 'function') window.showReaderView('main');
else {
const mv = document.getElementById('reader-main-view');
if (mv) mv.hidden = false;
}
};
const setStoppingState = () => {
const cancelBtn = panel.querySelector('#ab-cv-cancel');
if (cancelBtn) {
cancelBtn.disabled = true;
cancelBtn.innerHTML = ' Stopping...';
cancelBtn.title = 'Stopping the current casting request';
}
const status = panel.querySelector('#ab-cv-status-msg');
if (status) {
status.style.display = 'inline-block';
status.textContent = 'Stopping... completed passages will stay saved.';
}
};
panel.querySelector('#ab-cv-cancel').addEventListener('click', () => {
if (_audiobook.running) {
_audiobook.cancel = true;
setStoppingState();
if (typeof _audiobook.abort === 'function') _audiobook.abort();
return;
}
closePanel();
});
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
const p = panel.querySelector('#ab-cv-prompt-panel');
p.hidden = !p.hidden;
const chevron = panel.querySelector('#ab-cv-prompt-chevron');
if (chevron) {
chevron.className = p.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
}
});
// Collapse the whole settings block (LLM engine + prompt) to free vertical space.
(function () {
const tBtn = panel.querySelector('#ab-cv-settings-toggle');
const sBox = panel.querySelector('#ab-cv-settings');
const chev = panel.querySelector('#ab-cv-settings-chevron');
function apply(collapsed) {
if (sBox) sBox.style.display = collapsed ? 'none' : 'flex';
if (collapsed) { const p = panel.querySelector('#ab-cv-prompt-panel'); if (p) p.hidden = true; }
if (chev) chev.className = 'mdi ' + (collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up');
}
let collapsed = false;
try { collapsed = localStorage.getItem('ttsvc_ab_settings_collapsed') === '1'; } catch (_) {}
apply(collapsed);
if (tBtn) tBtn.addEventListener('click', () => {
collapsed = !collapsed;
try { localStorage.setItem('ttsvc_ab_settings_collapsed', collapsed ? '1' : '0'); } catch (_) {}
apply(collapsed);
});
})();
// Collapse the character sidebar to just avatar dots when space is tight,
// or you just want more room for the script itself.
(function () {
const side = panel.querySelector('#ab-cv-side');
const body = panel.querySelector('.ab-cv-body');
const cBtn = panel.querySelector('#ab-cv-side-collapse');
function apply(collapsed) {
if (side) side.classList.toggle('is-collapsed', collapsed);
if (body) body.classList.toggle('side-collapsed', collapsed);
if (cBtn) cBtn.querySelector('.mdi').className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left');
if (cBtn) cBtn.title = collapsed ? 'Expand character list' : 'Collapse to avatars';
}
let collapsed = false;
try { collapsed = localStorage.getItem('ttsvc_ab_side_collapsed') === '1'; } catch (_) {}
apply(collapsed);
if (cBtn) cBtn.addEventListener('click', () => {
collapsed = !collapsed;
try { localStorage.setItem('ttsvc_ab_side_collapsed', collapsed ? '1' : '0'); } catch (_) {}
apply(collapsed);
});
})();
// Prompt Library logic
let savedPrompts = [];
try { savedPrompts = JSON.parse(localStorage.getItem('ttsvc_ab_prompts') || '[]'); } catch (_) { savedPrompts = []; }
const libSelect = panel.querySelector('#ab-cv-prompt-lib');
const delBtn = panel.querySelector('#ab-cv-prompt-del');
const promptText = panel.querySelector('#ab-cv-prompt-text');
const renderPromptLib = (selectedIdx = -1) => {
libSelect.innerHTML = '' +
savedPrompts.map((p, i) => ``).join('');
if (selectedIdx >= 0) {
libSelect.value = selectedIdx;
delBtn.style.display = 'block';
} else {
libSelect.value = '';
delBtn.style.display = 'none';
}
};
renderPromptLib();
libSelect.addEventListener('change', () => {
const idx = parseInt(libSelect.value);
const nameInput = panel.querySelector('#ab-cv-prompt-name');
if (!isNaN(idx) && savedPrompts[idx]) {
promptText.value = savedPrompts[idx].prompt;
if (nameInput) nameInput.value = savedPrompts[idx].name;
delBtn.style.display = 'block';
} else {
if (nameInput) nameInput.value = '';
delBtn.style.display = 'none';
}
});
delBtn.addEventListener('click', () => {
const idx = parseInt(libSelect.value);
if (isNaN(idx)) return;
if (confirm('Delete this saved prompt preset?')) {
savedPrompts.splice(idx, 1);
localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts));
renderPromptLib();
toast('Prompt deleted', 'success');
}
});
panel.querySelector('#ab-cv-prompt-save').addEventListener('click', async () => {
const val = promptText.value.trim();
if (!val) { toast('Prompt is empty', 'error'); return; }
const nameInput = panel.querySelector('#ab-cv-prompt-name');
const name = nameInput.value.trim() || 'Custom Prompt ' + (savedPrompts.length + 1);
let targetIdx = parseInt(libSelect.value);
if (!isNaN(targetIdx) && savedPrompts[targetIdx] && savedPrompts[targetIdx].name === name) {
savedPrompts[targetIdx].prompt = val;
} else {
savedPrompts.push({ name, prompt: val });
targetIdx = savedPrompts.length - 1;
}
localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts));
renderPromptLib(targetIdx);
toast('Prompt preset saved', 'success');
// Also save as global default for next time
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val;
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) {}
});
// Sync the LLM engine dropdown with global options
const syncEngineSelect = () => {
const localSel = panel.querySelector('#ab-cv-llm-url');
const globalSel = document.getElementById('llm-active-url');
if (globalSel && localSel) {
const currentVal = localSel.value;
localSel.innerHTML = globalSel.innerHTML;
localSel.value = currentVal || globalSel.value || '';
}
};
syncEngineSelect();
// Fetch available models and populate the select drop-down
const loadModels = () => {
const urlInput = panel.querySelector('#ab-cv-llm-url');
const sel = panel.querySelector('#ab-cv-llm-select');
if (!sel || !urlInput) return;
const currentUrl = urlInput.value.trim();
const oldVal = sel.value;
sel.innerHTML = '';
fetch('/api/conversation/llm-models' + (currentUrl ? '?url=' + encodeURIComponent(currentUrl) : ''))
.then(r => r.json())
.then(d => {
if (d.models && d.models.length) {
const preferred = audiobookSafeLlmModel(oldVal || defaultModel);
sel.innerHTML = d.models.map(m => ``).join('');
if (preferred && d.models.includes(preferred)) sel.value = preferred;
else if (oldVal && d.models.includes(oldVal) && !audiobookIsRouterModel(oldVal)) sel.value = oldVal;
else if (d.models.includes(defaultModel)) sel.value = defaultModel;
} else {
sel.innerHTML = ``;
}
}).catch(() => {
sel.innerHTML = ``;
});
};
loadModels();
panel.querySelector('#ab-cv-llm-refresh').addEventListener('click', loadModels);
const urlInput = panel.querySelector('#ab-cv-llm-url');
if (urlInput) urlInput.addEventListener('change', loadModels);
const applyPromptAndRun = (callback) => {
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
const choice = audiobookCurrentCastLlm(panel);
const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model);
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) })
.finally(() => {
if (callback) callback(savedChoice.url, savedChoice.model);
});
};
if (isIdle) {
const hasSegs = _audiobook.segments && _audiobook.segments.length > 0;
panel.querySelector('#ab-cv-status-msg').style.display = 'inline-block';
if (hasSegs) {
const recastUnkBtn = panel.querySelector('#ab-cv-start-recast-unk');
const recastBtn = panel.querySelector('#ab-cv-start-recast');
recastUnkBtn.style.display = 'inline-block';
recastBtn.style.display = 'inline-block';
recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to recast.';
} else {
const castBtn = panel.querySelector('#ab-cv-start-cast');
castBtn.style.display = 'inline-block';
castBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
}
} else {
panel.querySelector('#ab-cv-cancel').innerHTML = ' Stop Casting';
}
const fill = panel.querySelector('#ab-cv-fill'), count = panel.querySelector('#ab-cv-count');
const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars');
const roster = new Map(); // name -> { count, color }
const characterRecords = new Map(); // lower-case name -> character-library record
// Like clIdentityNames (characters-library.js) but preserves original case —
// highlightText dedups and colour-keys by the display-cased name. The token
// splitting itself is the library's canonical clSplitIdentityTokens.
const identityNames = (recOrSheet) => {
const s = recOrSheet?.sheet || recOrSheet || {};
const out = new Set();
const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x));
add(recOrSheet?.name || s.name);
['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' }));
return [...out];
};
let _hlVer = 0; // bumped whenever character records change
let _hlCache = { ver: -1, rosterSize: -1, list: [] };
const registerCharacterRecord = (rec) => {
if (!rec?.name) return;
_hlVer++;
for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec);
};
const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null;
const colorFor = (name, rec) => {
const key = String(name || '').toLowerCase();
const stored = rec || characterRecords.get(key);
const color = stored ? _abRecordColor(stored, name) : _abDefaultCharacterColor(name);
if (!roster.has(name)) roster.set(name, { count: 0, color });
else roster.get(name).color = color;
return roster.get(name).color;
};
const setCharacterColor = (name, color) => {
const safe = _abNormalizeColor(color, name);
const info = roster.get(name);
if (info) info.color = safe;
const key = String(name || '').toLowerCase();
const rec = characterRecords.get(key);
if (rec) { rec.color = safe; if (rec.sheet) rec.sheet.color = safe; }
return safe;
};
const highlightText = (text) => {
if (!text) return '';
let html = escHtml(text);
// The name list + compiled regexes are invariant between segments until the
// roster or character records change — rebuilding them per segment made
// rendering O(segments × records) over a whole book. Colours stay live via
// colorFor at replace time.
if (_hlCache.ver !== _hlVer || _hlCache.rosterSize !== roster.size) {
const extraNames = [];
new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec)));
const seen = new Set();
const names = [];
for (const n of [...roster.keys(), ...extraNames]) {
const k = n.toLowerCase();
if (n.length < 2 || k === 'narrator' || /^unknown|unbekannt/i.test(k) || seen.has(k)) continue;
seen.add(k);
names.push(n);
}
names.sort((a, b) => b.length - a.length);
_hlCache = {
ver: _hlVer,
rosterSize: roster.size,
list: names.map(name => ({
name,
regex: new RegExp(`\\b(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi'),
})),
};
}
for (const { name, regex } of _hlCache.list) {
html = html.replace(regex, (match) => `${match}`);
}
return html;
};
// ── Character selection bar ────────────────────────────────────────────────
// Clicking a character shows a compact bar above the feed with avatar, name,
// prev/next line navigation, a search box, and a "Profil" button that opens
// the full character sheet (hiding the feed temporarily).
const feedWrap = panel.querySelector('.ab-cv-feed-wrap');
const jumpBtn = panel.querySelector('#ab-cv-jump-btn');
// Compact toolbar above the feed: selected character, edit history, page nav.
const _abTopbar = document.createElement('div');
_abTopbar.className = 'ab-cv-topbar';
feedWrap.insertBefore(_abTopbar, feedWrap.firstChild);
const _abBar = document.createElement('div');
_abBar.className = 'ab-char-bar';
_abBar.hidden = true;
_abTopbar.appendChild(_abBar);
const _abEditToolbar = document.createElement('div');
_abEditToolbar.className = 'ab-edit-toolbar';
_abEditToolbar.innerHTML = `
`;
_abTopbar.appendChild(_abEditToolbar);
const _abPageNav = document.createElement('div');
_abPageNav.className = 'ab-page-nav';
_abPageNav.hidden = true;
_abPageNav.innerHTML = `
`;
_abTopbar.appendChild(_abPageNav);
let _abCharRows = []; // rows in feed for selected character
let _abNavIdx = 0;
let _abDetailEl = null; // full profile panel (when Profil is open)
let _abCurrentPageNum = 1;
const _abClearHL = () => {
feed.querySelectorAll('.ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus')
.forEach(r => r.classList.remove('ab-char-hl', 'ab-char-focus'));
};
const _abCloseProfile = () => {
if (_abDetailEl) { _abDetailEl.remove(); _abDetailEl = null; }
feed.style.display = '';
if (jumpBtn && typeof _userScrolled !== 'undefined') jumpBtn.hidden = !_userScrolled;
_abCharRows.forEach(r => r.classList.add('ab-char-hl'));
const btn = _abBar.querySelector('.ab-char-bar-profile');
if (btn) btn.innerHTML = ' Profil';
};
const _abCloseBar = () => {
_abBar.hidden = true;
_abBar.innerHTML = '';
_abCloseProfile();
_abClearHL();
_abCharRows = [];
chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.remove('is-active'));
if (typeof _abSyncTopbar === 'function') _abSyncTopbar();
};
const _abNav = (dir, pool) => {
const rows = pool || _abCharRows;
if (!rows.length) return;
_abNavIdx = ((_abNavIdx + dir) + rows.length) % rows.length;
feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus'));
rows[_abNavIdx].classList.add('ab-char-focus');
rows[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
const pos = _abBar.querySelector('.ab-char-bar-pos');
if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length;
};
const _abRefreshCharacterColors = (name) => {
const names = name ? [name] : [...roster.keys()];
for (const n of names) colorFor(n);
feed.querySelectorAll('.ab-cv-row').forEach(row => {
const seg = row.__seg;
if (!seg) return;
const isNarrator = seg.type !== 'dialogue' || !seg.speaker || seg.speaker.toLowerCase() === 'narrator';
const speakerName = isNarrator ? 'Narrator' : seg.speaker;
const c = colorFor(speakerName);
const spk = row.querySelector('.ab-cv-spk');
const txt = row.querySelector('.ab-cv-txt');
if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c;
if (txt) txt.innerHTML = highlightText(seg.text || '');
});
renderRoster();
const selected = _abBar.hidden ? null : _abBar.dataset.charName;
if (selected) {
const dot = _abBar.querySelector('.ab-char-bar-dot');
if (dot) dot.style.background = colorFor(selected);
}
};
// Open the full character profile (hides feed)
const _abShowProfile = async (name) => {
_abCloseProfile();
feed.style.display = 'none';
if (jumpBtn) jumpBtn.hidden = true;
const profileBtn = _abBar.querySelector('.ab-char-bar-profile');
if (profileBtn) profileBtn.innerHTML = ' Skript';
const title = window.readerState?.title || '';
let rec = null;
try {
const all = typeof clGetAllByTagOrBook === 'function'
? await clGetAllByTagOrBook(title)
: (typeof clGetAll === 'function' ? await clGetAll() : []);
for (const r of all || []) registerCharacterRecord(r);
rec = recordForName(name);
} catch (_) {}
const detail = document.createElement('div');
detail.className = 'ab-char-detail-panel';
_abDetailEl = detail;
if (!rec) {
detail.innerHTML = ''
+ '
Kein Charakterblatt – zuerst „Cast Characters" ausführen.