3576 lines
188 KiB
JavaScript
3576 lines
188 KiB
JavaScript
// ── 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));
|
||
}));
|
||
}
|
||
|
||
|
||
// Every long-running audiobook action opens with a "already running?" guard.
|
||
// They all used to `return` in silence, so clicking Recast / Verify / Export
|
||
// while something else was in flight did nothing at all and said nothing —
|
||
// indistinguishable from a broken button. Worse, casting auto-starts the
|
||
// auto-repair sequence 800ms after it finishes, so the window in which a click
|
||
// is silently swallowed opens on its own, with no visible sign anything is
|
||
// running. Confirmed live: an automated pipeline recorded Recast and Verify as
|
||
// "done" in under a second each because both returned instantly this way.
|
||
function _abBusyGuard(what) {
|
||
if (!_audiobook.running) return false;
|
||
try {
|
||
toast(`${what} can't start yet — another pass is still running. Use Stop in the casting panel first.`, 'error');
|
||
} catch (_) {}
|
||
return true;
|
||
}
|
||
|
||
async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) {
|
||
if (_abBusyGuard('Recast / verify')) return;
|
||
const segs = _audiobook.segments;
|
||
if (!segs || !segs.length) return;
|
||
const originalSegments = segs.map(s => ({...s}));
|
||
const countUnknownDialogue = arr => (arr || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||
const beforeUnknownCount = countUnknownDialogue(segs);
|
||
|
||
// Mechanical cases first (colon rule, post-quote inquit) — resolved in code
|
||
// for free, so they never even reach the LLM queue below.
|
||
const preResolved = audiobookResolveUnknowns(segs, [], _audiobook.roster || []);
|
||
if (preResolved.length) toast(`${preResolved.length} Unknown line${preResolved.length !== 1 ? 's' : ''} resolved by grammar rules`, 'success');
|
||
|
||
// The verify pass (options.includeNarrator) also re-checks every Narrator
|
||
// line, not just Unknown dialogue — that's the only way to catch dialogue
|
||
// that got hidden inside narration on the first pass. Confirmed
|
||
// non-Unknown dialogue stays out of scope either way; re-litigating lines
|
||
// that are already clearly attributed isn't what was asked for.
|
||
const unknownIdxs = [];
|
||
const _abRepairSuspects = options.repairNarration ? _abSuspectNarration(segs) : null;
|
||
for (let i = 0; i < segs.length; i++) {
|
||
const s = segs[i];
|
||
const isUnknownDialogue = s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker));
|
||
// includeNarrator re-checks EVERY narration line (thorough but expensive);
|
||
// repairNarration re-checks only the ones that actually look like speech.
|
||
const isNarratorCandidate = (options.includeNarrator && s.type === 'narration')
|
||
|| (options.repairNarration && s.type === 'narration' && _abRepairSuspects && _abRepairSuspects.has(i));
|
||
if (isUnknownDialogue || isNarratorCandidate) unknownIdxs.push(i);
|
||
}
|
||
if (!unknownIdxs.length) {
|
||
toast('No Unknown speakers found', 'success');
|
||
audiobookShowPreview();
|
||
return;
|
||
}
|
||
|
||
_audiobook.running = true; _audiobook.cancel = false;
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
|
||
const ac = new AbortController(); _audiobook.abort = () => ac.abort();
|
||
|
||
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
||
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
|
||
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
||
// repairNarration exists specifically to re-judge narration that may really be
|
||
// speech — the verification prompt is the one written for that question, so it
|
||
// is the right default here. Previously only the menu button passed it, so the
|
||
// same pass behaved differently when called from auto-repair or the console.
|
||
let promptOverride = typeof options.prompt === 'string' ? options.prompt : null;
|
||
if (!promptOverride && options.repairNarration && typeof window._abVerificationPrompt === 'string') {
|
||
promptOverride = window._abVerificationPrompt;
|
||
}
|
||
|
||
const groups = audiobookRecastGroups(unknownIdxs, segs);
|
||
const view = audiobookCastView(unknownIdxs.length, llm_url, model);
|
||
view.recountRoster(segs); // sidebar shows the whole cast's roster, not just the lines being checked
|
||
|
||
// Resolve a speaker name the LLM returns to its canonical character —
|
||
// deterministic lookup against saved aliases (e.g. "Vampire" -> "Zerwas"),
|
||
// rather than relying on the model to remember/output the canonical name
|
||
// itself. Built once up front so every segment write-back stays consistent.
|
||
const _rcAliasMap = new Map();
|
||
try {
|
||
const bookTitle = window.readerState?.title || '';
|
||
const records = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(bookTitle) : [];
|
||
for (const rec of records || []) {
|
||
if (!rec?.name) continue;
|
||
_rcAliasMap.set(rec.name.toLowerCase(), rec.name);
|
||
if (typeof clSplitIdentityTokens === 'function') {
|
||
for (const a of clSplitIdentityTokens(rec.aliases, { aliases: true })) _rcAliasMap.set(a.toLowerCase(), rec.name);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
const canonicalizeSpeaker = (name) => (name && _rcAliasMap.get(name.toLowerCase())) || name;
|
||
|
||
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
|
||
try {
|
||
await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
|
||
body: JSON.stringify({
|
||
text: 'Wake up.', known_characters: [], recent: '', language,
|
||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
|
||
})
|
||
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
_audiobook.cancel = true;
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
view.done({ stopped: true, message: 'Character definition stopped before any lines were updated.' });
|
||
toast('Character definition stopped. Existing cast preserved.', 'info');
|
||
return;
|
||
}
|
||
}
|
||
|
||
let done = 0;
|
||
let prevIdx = -2;
|
||
let groupsSinceSave = 0;
|
||
const pendingReplacements = new Map();
|
||
const normalizeRecastSegment = (seg, fallback) => {
|
||
const type = seg?.type === 'narration' ? 'narration' : 'dialogue';
|
||
const speaker = type === 'narration' ? 'Narrator' : canonicalizeSpeaker(seg?.speaker || fallback?.speaker || 'Unknown');
|
||
return {
|
||
speaker,
|
||
type,
|
||
emotion: type === 'dialogue' ? (seg?.emotion || fallback?.emotion || '') : '',
|
||
text: seg?.text || fallback?.text || ''
|
||
};
|
||
};
|
||
try {
|
||
for (let group of groups) {
|
||
if (_audiobook.cancel) break;
|
||
|
||
const unresolvedGroup = [];
|
||
for (const idx of group) {
|
||
if (audiobookIsSpeechTagOnly(segs[idx]?.text)) {
|
||
segs[idx].type = 'narration';
|
||
segs[idx].speaker = 'Narrator';
|
||
segs[idx].emotion = '';
|
||
} else {
|
||
unresolvedGroup.push(idx);
|
||
}
|
||
}
|
||
if (!unresolvedGroup.length) {
|
||
if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]);
|
||
prevIdx = group[group.length - 1];
|
||
view.update(done, `Correcting narration tags ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`);
|
||
for (const idx of group) {
|
||
view.addSegments([segs[idx]]);
|
||
done++;
|
||
}
|
||
continue;
|
||
}
|
||
group = unresolvedGroup;
|
||
|
||
// These Unknown lines come from scattered places in the document. Mark a
|
||
// gap with "⋯" whenever this passage isn't directly after the previous one.
|
||
if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]);
|
||
prevIdx = group[group.length - 1];
|
||
|
||
const recastCtx = audiobookRecastContext(segs, group);
|
||
const passageText = recastCtx.text;
|
||
const lineLabel = group.length > 1
|
||
? `Attributing lines ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`
|
||
: `Attributing line ${done + 1} / ${unknownIdxs.length}…`;
|
||
|
||
view.update(done, lineLabel);
|
||
view.processing(passageText.trim());
|
||
|
||
let data = null;
|
||
const recastBody = audiobookAttributeBody({
|
||
text: passageText.trim(),
|
||
known_characters: _audiobook.roster.slice(-40),
|
||
recent: recastCtx.recent || '',
|
||
language,
|
||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)
|
||
}, promptOverride);
|
||
try {
|
||
// Streaming first (live thinking view); blocking endpoint as fallback.
|
||
try {
|
||
data = await audiobookAttributeStream(recastBody, view, ac.signal, AUDIOBOOK_RECAST_TIMEOUT_MS);
|
||
} catch (streamErr) {
|
||
if (streamErr.name === 'AbortError') throw streamErr;
|
||
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
signal: ac.signal,
|
||
body: JSON.stringify(recastBody)
|
||
}, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000);
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
||
data = await r.json();
|
||
}
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') { _audiobook.cancel = true; break; }
|
||
// statusText is empty on HTTP/2, which used to leave this note blank after the colon
|
||
view.note(`API Error while checking ${group.length > 1 ? 'a group of Unknown lines' : 'Unknown line'}: ${err.message || err}`);
|
||
for (const idx of group) {
|
||
view.addSegments([segs[idx]]);
|
||
done++;
|
||
}
|
||
continue;
|
||
}
|
||
if (data && data.segments) {
|
||
const used = new Set();
|
||
for (const idx of group) {
|
||
const targetSeg = segs[idx];
|
||
const seqMatch = audiobookFindReturnedSegmentSequence(targetSeg, data.segments, used);
|
||
if (seqMatch && seqMatch.segs.length > 1) {
|
||
const repl = seqMatch.segs.map(s => normalizeRecastSegment(s, targetSeg)).filter(s => (s.text || '').trim());
|
||
const hasResolved = repl.some(s => s.type === 'narration' || (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)));
|
||
const hasUnresolvedDialogue = repl.some(s => s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)));
|
||
const hasImplausibleDialogue = repl.some(s => s.type === 'dialogue' && _abImplausibleDialoguePiece(s.text));
|
||
if (repl.length && hasResolved && !hasUnresolvedDialogue && !hasImplausibleDialogue) {
|
||
seqMatch.idxs.forEach(i => used.add(i));
|
||
pendingReplacements.set(idx, repl);
|
||
repl.forEach(s => {
|
||
if (s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker) && !_audiobook.roster.includes(s.speaker)) _audiobook.roster.push(s.speaker);
|
||
});
|
||
continue;
|
||
}
|
||
}
|
||
const match = audiobookFindReturnedSegment(targetSeg, data.segments, used);
|
||
if (match && match.seg.speaker) {
|
||
const speaker = match.seg.type === 'narration' ? 'Narrator' : canonicalizeSpeaker(match.seg.speaker);
|
||
if (!speaker || /^Unknown|Unbekannt/i.test(speaker)) continue;
|
||
used.add(match.idx);
|
||
targetSeg.type = match.seg.type === 'narration' ? 'narration' : 'dialogue';
|
||
targetSeg.speaker = speaker;
|
||
targetSeg.emotion = targetSeg.type === 'dialogue' ? (match.seg.emotion || targetSeg.emotion || '') : '';
|
||
if (targetSeg.type === 'dialogue' && !_audiobook.roster.includes(speaker)) _audiobook.roster.push(speaker);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const idx of group) {
|
||
view.addSegments(pendingReplacements.get(idx) || [segs[idx]]);
|
||
done++;
|
||
}
|
||
groupsSinceSave++;
|
||
if (groupsSinceSave >= 10 && _audiobook.lastText) {
|
||
groupsSinceSave = 0;
|
||
_abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
|
||
view.recountRoster(segs); // keep the sidebar reflecting the full cast as lines resolve
|
||
}
|
||
}
|
||
view.update(_audiobook.cancel ? done : unknownIdxs.length);
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') view.note('Error defining unknowns: ' + e.message);
|
||
} finally {
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
_abHideCastingControl();
|
||
}
|
||
// Everything below only ever ran when nothing above threw — but nothing
|
||
// here was itself guarded, so any failure in this post-processing (dedup,
|
||
// rollback check, saving the draft) left the UI permanently stuck showing
|
||
// "Stop Casting" with no error and no way out, even though the LLM work
|
||
// itself had already genuinely finished (GPU load back to idle). Wrapping
|
||
// it means a failure here still reaches a terminal view.done()/view.note()
|
||
// call instead of silently hanging forever.
|
||
try {
|
||
if (pendingReplacements.size) {
|
||
[...pendingReplacements.entries()]
|
||
.sort((a, b) => b[0] - a[0])
|
||
.forEach(([idx, repl]) => segs.splice(idx, 1, ...repl));
|
||
}
|
||
// Splicing multi-segment replacements back in at scattered indices can
|
||
// reintroduce a passage that a neighbouring recast group's overlapping
|
||
// context window already restated correctly a few segments earlier —
|
||
// confirmed live (a paragraph appearing twice with a stray leading quote
|
||
// mark on the second copy, separated by an unrelated dialogue block).
|
||
const { segments: deduped, removed: dupRemoved } = _audiobookDedupNearbyDuplicates(segs);
|
||
if (dupRemoved) {
|
||
segs.splice(0, segs.length, ...deduped);
|
||
view.note(`Removed ${dupRemoved} duplicated line${dupRemoved !== 1 ? 's' : ''} introduced by this verification pass.`);
|
||
}
|
||
|
||
const afterUnknownCount = countUnknownDialogue(segs);
|
||
// The `!cancel` guard used to sit in front of this check, so STOPPING a run
|
||
// skipped the rollback entirely and a half-finished pass that had made the
|
||
// cast worse was kept silently — confirmed live: a stopped recast took a book
|
||
// from 80 Unknown lines to 159, with no warning and no way back. A run that
|
||
// increases Unknown speakers is never worth keeping, finished or interrupted.
|
||
if (afterUnknownCount > beforeUnknownCount) {
|
||
segs.splice(0, segs.length, ...originalSegments);
|
||
const how = _audiobook.cancel ? 'Stopped run' : 'Quality run';
|
||
view.note(`${how} rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
|
||
toast(`${how} rolled back because it increased Unknown speakers.`, 'error');
|
||
}
|
||
|
||
// Recast-unknown only fixes speakers on already-cast segments — it never adds
|
||
// new passages, so the original cast's real done/total must carry through
|
||
// unchanged rather than being overwritten with a fake "100% done" value.
|
||
const _rcDone = _audiobook.completedChunks || segs.length;
|
||
const _rcTotal = _audiobook.completedTotal || _rcDone;
|
||
|
||
if (_audiobook.cancel) {
|
||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||
view.done({ stopped: true, message: 'Character definition stopped. Existing cast preserved.' });
|
||
toast('Character definition stopped. Existing cast preserved.', 'info');
|
||
return;
|
||
}
|
||
|
||
// Repair passes assign speakers of their own, so they can re-introduce the
|
||
// label variants the cast-time consolidation just removed — confirmed live:
|
||
// a clean cast gained "Alrik" alongside "Alrik von Blautann" during
|
||
// auto-repair, which would have split one character across two voices again.
|
||
{ const _ph = _audiobookDropPhantomSpeakers(segs);
|
||
if (_ph.dropped) console.info('[recast] dropped', _ph.dropped, 'phantom speaker line(s):', _ph.names.join(', ')); }
|
||
{ const _role = _audiobookResolveRoleAliases(segs, _audiobook.lastText || '');
|
||
if (_role.moved) {
|
||
console.info('[recast] resolved role labels to names:', _role.pairs.join(', '), `(${_role.moved} lines)`);
|
||
view.note(`Resolved ${_role.merged} role label${_role.merged !== 1 ? 's' : ''} to character names (${_role.moved} lines).`);
|
||
} }
|
||
{ const _ident = _audiobookConsolidateSpeakerAliases(segs);
|
||
if (_ident.merged) {
|
||
console.info('[recast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');
|
||
view.note(`Merged ${_ident.merged} duplicate character label${_ident.merged !== 1 ? 's' : ''} (${_ident.moved} lines).`);
|
||
} }
|
||
// Recast/verify have just reassigned speakers, which creates new runs of
|
||
// consecutive lines by one character that the cast-time merge never saw.
|
||
{ const _dm = _audiobookMergeAdjacentDialogue(segs);
|
||
if (_dm.merged) {
|
||
segs.splice(0, segs.length, ..._dm.segments);
|
||
console.info('[recast] merged', _dm.merged, 'consecutive same-speaker dialogue segment(s)');
|
||
view.note(`Merged ${_dm.merged} consecutive same-speaker line${_dm.merged !== 1 ? 's' : ''}.`);
|
||
} }
|
||
// Confidence last: every pass above can change a speaker, and a stale
|
||
// score is worse than none (it would send the reviewer to the wrong lines).
|
||
_abStampConfidence(segs);
|
||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
} catch (e) {
|
||
console.error('[audiobookRecastUnknown] post-processing failed', e);
|
||
view.done({ stopped: true, message: 'Finished checking speakers, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });
|
||
toast('Casting finished but cleanup failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Repeatedly runs the recast-unknown + narrator-verify pass (the bounded
|
||
// { repairNarration: true } combination — narration lines that actually score
|
||
// as mislabeled speech, rather than every narration segment in the book)
|
||
// until the number of Unknown-speaker dialogue lines drops below `threshold`
|
||
// or two consecutive passes make no further progress (LLM/GPU contention or a
|
||
// genuinely irreducible residue of ambiguous lines — either way, more passes
|
||
// won't help). User-requested follow-up: this used to only be doable by
|
||
// calling audiobookRecastUnknown() directly from the browser console.
|
||
// ── Auto-repair after a full cast ───────────────────────────────────────────
|
||
// A fresh "Identify all characters" run routinely leaves a tail of Unknown
|
||
// speakers plus spoken lines mis-typed as narration. Fixing those by hand meant
|
||
// knowing which of the six menu passes to run, in which order — repair BEFORE
|
||
// identify (repair converts narration into new, still-unattributed dialogue),
|
||
// and the expensive full verify only as a last resort. This runs that sequence
|
||
// automatically and stops as soon as it stops helping, so a book that casts
|
||
// cleanly costs nothing extra.
|
||
const AB_AUTO_REPAIR_KEY = 'ab-auto-repair-enabled';
|
||
function audiobookAutoRepairEnabled() {
|
||
try { return localStorage.getItem(AB_AUTO_REPAIR_KEY) !== '0'; } catch (_) { return true; }
|
||
}
|
||
async function audiobookAutoRepairCast(threshold = 10, maxRounds = 4) {
|
||
const segs = _audiobook.segments || [];
|
||
if (!segs.length) { toast('Nothing cast yet to repair', 'error'); return; }
|
||
const countUnknown = () => (_audiobook.segments || []).filter(s =>
|
||
s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||
let prev = countUnknown();
|
||
if (prev <= threshold) { toast(`Cast looks clean — ${prev} unknown speaker${prev !== 1 ? 's' : ''}`, 'success'); return; }
|
||
toast(`Auto-repair: ${prev} unknown speakers — running repair passes…`, 'info');
|
||
for (let round = 1; round <= maxRounds; round++) {
|
||
// repairNarration also re-checks Unknown dialogue in the same call, so one
|
||
// pass per round covers menu steps 2 and 3 together.
|
||
await audiobookRecastUnknown(null, null, { repairNarration: true });
|
||
if (_audiobook.cancel) { toast('Auto-repair stopped.', 'info'); return; }
|
||
const now = countUnknown();
|
||
if (now <= threshold) { toast(`Auto-repair done: ${now} unknown left after ${round} round${round !== 1 ? 's' : ''}.`, 'success'); return; }
|
||
if (now >= prev) break; // no further progress — stop burning GPU time
|
||
prev = now;
|
||
}
|
||
// 2.4 — still above target: one more verification pass.
|
||
// This used to pass includeNarrator, which queues EVERY narration segment
|
||
// (~1000 on a novel = ~190 sequential LLM calls). Auto-repair fires on its
|
||
// own after each cast, so that fallback ran unattended and never finished:
|
||
// confirmed live, it sat for ~4 hours making almost no LLM calls, changed
|
||
// not one segment, and only ended when the host process hit its 6-hour cap.
|
||
// A sweep that cannot complete is strictly worse than one that can, so this
|
||
// uses the same bounded suspect-scored pass as the rounds above.
|
||
toast(`2.4 Still ${prev} unknown — running full verification pass…`, 'info');
|
||
await audiobookRecastUnknown(null, null, { repairNarration: true });
|
||
const afterVerify = countUnknown();
|
||
// 2.5 — consistency is a different kind of check (it re-judges lines already
|
||
// attributed), so it runs last, once attributions have settled, and only if
|
||
// the passes above didn't leave the cast in a worse state than they found it.
|
||
if (typeof window._abRunConsistencyPass === 'function' && !_audiobook.cancel) {
|
||
toast('2.5 Checking voice consistency…', 'info');
|
||
try { await window._abRunConsistencyPass(); } catch (e) { console.warn('[auto-repair 2.5]', e); }
|
||
}
|
||
const final = countUnknown();
|
||
toast(final <= threshold
|
||
? `Auto-repair done: ${final} unknown left.`
|
||
: `Auto-repair finished: ${final} unknown remain (target was under ${threshold}).`,
|
||
final <= threshold ? 'success' : 'error');
|
||
}
|
||
window.audiobookAutoRepairCast = audiobookAutoRepairCast;
|
||
|
||
async function audiobookRecastUntilThreshold(threshold = 10, maxPasses = 8) {
|
||
if (_audiobook.running) { toast('Casting is already running', 'error'); return; }
|
||
const segs = _audiobook.segments;
|
||
if (!segs || !segs.length) { toast('No cast to check', 'error'); return; }
|
||
const countUnknown = () => (segs || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||
let prev = countUnknown();
|
||
if (prev <= threshold) { toast(`Already at ${prev} unknown speakers (target: <${threshold})`, 'success'); return; }
|
||
toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})…`, 'info');
|
||
for (let pass = 1; pass <= maxPasses; pass++) {
|
||
// Each round re-checks Unknown dialogue AND narration lines that score as
|
||
// mislabeled speech. This used to pass includeNarrator, which re-sent every
|
||
// narration segment (~1000 on a novel) to the LLM on EVERY iteration —
|
||
// enormously expensive and it re-litigated lines that were already correct.
|
||
await audiobookRecastUnknown(null, null, { repairNarration: true });
|
||
if (_audiobook.cancel) { toast('Stopped — cancelled mid-pass.', 'info'); return; }
|
||
const now = countUnknown();
|
||
if (now <= threshold) { toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass !== 1 ? 'es' : ''}).`, 'success'); return; }
|
||
if (now >= prev) { toast(`Stopped after ${pass} pass${pass !== 1 ? 'es' : ''}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`, 'error'); return; }
|
||
prev = now;
|
||
}
|
||
toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`, 'error');
|
||
}
|
||
window.audiobookRecastUntilThreshold = audiobookRecastUntilThreshold;
|
||
|
||
async function audiobookOpenCastView() {
|
||
if (_audiobook.running) return;
|
||
const text = audiobookScopeText();
|
||
if (!text) { toast('Import a document first', 'error'); return; }
|
||
|
||
// Bind this casting session to the open library book so its draft is restored
|
||
// by identity on reopen. Clearing the in-memory cast when the book changes
|
||
// prevents a stale in-memory cast from masking the correct per-book draft.
|
||
const _curBook = (window.readerState && readerState.savedId) || null;
|
||
if (_curBook && _audiobook.bookId && _audiobook.bookId !== _curBook) {
|
||
_audiobook.segments = null; _audiobook.lastText = null; _audiobook.roster = null;
|
||
}
|
||
_audiobook.bookId = _curBook;
|
||
|
||
const llm_url = audiobookLlmUrl();
|
||
const model = audiobookLlmModel();
|
||
|
||
const chunks = (typeof splitTextIntoChunks === 'function')
|
||
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
|
||
: [text];
|
||
|
||
// 1. In-memory restore — user navigated away and came back in the same session
|
||
if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) {
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
view.rebuild(_audiobook.segments);
|
||
const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
return;
|
||
}
|
||
|
||
// Helper: apply a draft object into the view
|
||
const _applyDraft = (draft, view, source) => {
|
||
const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0;
|
||
const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0;
|
||
_abStampSegmentPages(draft.segments, draft.pageMarks, text); // pre-.page drafts: stamp once so the renderer stays single-path
|
||
_audiobook.segments = draft.segments;
|
||
_audiobook.roster = draft.roster || [];
|
||
_audiobook.lastText = text;
|
||
_audiobook.pageMarks = draft.pageMarks || [];
|
||
_audiobook.rehId = draft.rehId || null;
|
||
// Always keep the raw progress the original cast reached, even once it's
|
||
// finished or a later edit re-saves the draft — later saves (manual speaker
|
||
// fixes, recast-unknown) must not clobber this with a "looks 100%" sentinel,
|
||
// or an interrupted cast becomes silently unresumable and misreported as done.
|
||
_audiobook.completedChunks = draftDone > 0 ? draftDone : 0;
|
||
_audiobook.completedTotal = draftTotal > 0 ? draftTotal : 0;
|
||
view.rebuild(draft.segments);
|
||
const ageMs = Date.now() - (draft.savedAt || 0);
|
||
const ageMins = Math.round(ageMs / 60000);
|
||
const ageStr = ageMins < 1 ? 'gerade eben' : ageMins < 60 ? `vor ${ageMins} Min.` : `vor ${Math.round(ageMins/60)} Std.`;
|
||
const pct = draftTotal > 0 ? Math.max(0, Math.min(100, Math.round((draftDone / draftTotal) * 100))) : 100;
|
||
const wasDone = draftTotal > 0 && draftDone >= draftTotal;
|
||
const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal;
|
||
const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt';
|
||
view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Identify all characters" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`);
|
||
const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
};
|
||
|
||
// 2. localStorage draft restore — fastest, but NOT automatically authoritative.
|
||
// The local copy used to win outright and was then pushed to the server as a
|
||
// "backfill", which meant a stale browser draft silently overwrote newer work
|
||
// saved from anywhere else — confirmed live: a cast improved from 79 Unknown
|
||
// lines to 25 and stored server-side was destroyed simply by opening the book
|
||
// in a tab holding the older draft. Whichever copy was saved most recently
|
||
// wins, and the server is only backfilled when the local copy is genuinely
|
||
// newer.
|
||
const _localDraft = _abLoadDraft(text);
|
||
if (_localDraft && _localDraft.segments && _localDraft.segments.length > 0) {
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
const bookId = _abBookId();
|
||
let chosen = _localDraft, source = 'local', serverDraft = null;
|
||
if (bookId) {
|
||
try { serverDraft = await _abLoadDraftServer(bookId); } catch (_) {}
|
||
if (serverDraft && (serverDraft.savedAt || 0) > (_localDraft.savedAt || 0)) {
|
||
chosen = serverDraft; source = 'server';
|
||
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch (_) {}
|
||
}
|
||
}
|
||
_applyDraft(chosen, view, source);
|
||
// Only push upwards when the local copy really is the newer one.
|
||
if (bookId && source === 'local' && (!serverDraft || (_localDraft.savedAt || 0) > (serverDraft.savedAt || 0))) {
|
||
fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ ..._localDraft, bookId, title: window.readerState?.title || _localDraft.title || '' })
|
||
}).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 3. Server draft restore — for other browsers/devices or cleared localStorage
|
||
const bookId = _abBookId();
|
||
if (bookId) {
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
view.loadingRestore();
|
||
try {
|
||
const serverDraft = await _abLoadDraftServer(bookId);
|
||
if (serverDraft && serverDraft.segments && serverDraft.segments.length > 0) {
|
||
// Cache locally so next open is instant
|
||
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch(_) {}
|
||
_applyDraft(serverDraft, view, 'server');
|
||
} else {
|
||
view.setFreshState('No saved cast was found for this saved book. Starting a new cast will create a new draft.');
|
||
}
|
||
} catch (_) {
|
||
view.setFreshState('Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 4. Fresh start (no book ID, no draft)
|
||
audiobookCastView(chunks.length, llm_url, model, true);
|
||
}
|
||
|
||
// Sentence splitter mirroring splitTextIntoChunks's own boundaries, so
|
||
// "did this sentence make it into the cast" is judged at the same
|
||
// granularity the rest of the pipeline already uses.
|
||
function _abSourceSentences(text) {
|
||
const out = [];
|
||
for (const line of String(text || '').split('\n')) {
|
||
if (!line.trim()) continue;
|
||
const sentences = line.match(/[^.!?]+[.!?]+\s*/g) || [];
|
||
const rest = line.replace(/[^.!?]+[.!?]+\s*/g, '').trim();
|
||
for (const s of sentences) { const t = s.trim(); if (t) out.push(t); }
|
||
if (rest) out.push(rest);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function _abNormForCoverage(t) {
|
||
return String(t || '').replace(/[«»„“”"'’‚‹›]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||
}
|
||
|
||
// Safety net for a known LLM failure mode: given a chunk of source text and
|
||
// the segments the LLM attributed for it, some passages come back with a
|
||
// sentence or whole paragraph silently missing from the JSON response —
|
||
// not mis-attributed, just never mentioned at all. Nothing downstream
|
||
// (recast, quality-run, merge passes) re-examines the *source* text against
|
||
// what actually made it into `segs`, so a dropped paragraph used to vanish
|
||
// from the cast permanently and the audiobook would jump straight to the
|
||
// next one (confirmed live). Recover any such gap as a Narrator segment,
|
||
// spliced back in next to the nearest sentence that DID make it through, so
|
||
// content is never silently lost — worst case it's a paragraph attributed
|
||
// to Narrator that a human later moves to the right speaker.
|
||
function _abRecoverDroppedText(sourceText, segs) {
|
||
if (!segs.length) return { segs, recovered: 0 };
|
||
const sentences = _abSourceSentences(sourceText).filter(s => _abNormForCoverage(s).length >= 8);
|
||
if (!sentences.length) return { segs, recovered: 0 };
|
||
|
||
const segNorm = segs.map(s => _abNormForCoverage(s.text));
|
||
const matchIdx = sentences.map(s => segNorm.findIndex(t => t.includes(_abNormForCoverage(s))));
|
||
|
||
const out = [];
|
||
let copiedUpTo = -1;
|
||
let gap = [];
|
||
let recovered = 0;
|
||
const flushGap = (beforeSegIdx) => {
|
||
if (!gap.length) return;
|
||
while (copiedUpTo + 1 < beforeSegIdx) out.push(segs[++copiedUpTo]);
|
||
out.push({ speaker: 'Narrator', type: 'narration', text: gap.join(' '), emotion: '', _recovered: true });
|
||
recovered++;
|
||
gap = [];
|
||
};
|
||
sentences.forEach((s, i) => {
|
||
if (matchIdx[i] === -1) { gap.push(s); return; }
|
||
flushGap(matchIdx[i]);
|
||
});
|
||
while (copiedUpTo + 1 < segs.length) out.push(segs[++copiedUpTo]);
|
||
if (gap.length) { out.push({ speaker: 'Narrator', type: 'narration', text: gap.join(' '), emotion: '', _recovered: true }); recovered++; }
|
||
|
||
return { segs: out, recovered };
|
||
}
|
||
|
||
async function audiobookCast(overrideUrl, overrideModel, resume) {
|
||
if (_abBusyGuard('Casting')) return;
|
||
const text = audiobookScopeText();
|
||
if (!text) { toast('Import a document first', 'error'); return; }
|
||
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
|
||
|
||
_audiobook.running = true;
|
||
const hadServerAutosaveTarget = !!_abBookId();
|
||
if (!hadServerAutosaveTarget) {
|
||
const ready = await _abEnsureLibraryBook();
|
||
if (!ready && typeof toast === 'function') {
|
||
toast('Autosave will use this browser only until the book is saved to the library.', 'info');
|
||
}
|
||
}
|
||
|
||
const chunks = (typeof splitTextIntoChunks === 'function')
|
||
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
|
||
: [text];
|
||
|
||
// Resuming an interrupted cast: pick up at the passage the autosave stopped
|
||
// on instead of reprocessing everything from passage 1. Chunking is a pure
|
||
// function of the text, so the same text always re-splits into the same
|
||
// chunk boundaries, making the saved passage index safe to resume at.
|
||
const startIndex = (resume && resume.startIndex > 0 && resume.startIndex < chunks.length) ? resume.startIndex : 0;
|
||
const isResume = startIndex > 0;
|
||
|
||
_audiobook.cancel = false;
|
||
if (!isResume) {
|
||
// Deliberately does NOT null _audiobook.rehId here. It used to, on the
|
||
// theory that a fresh (non-resume) cast should always start a brand-new
|
||
// Rehearsal Library record — but "fresh cast" also covers "Recast all"
|
||
// and simply retrying after a failed/timed-out run for a book that
|
||
// already has a rehId (restored from its draft in _applyDraft above).
|
||
// Nulling it there meant every retry silently orphaned the previous
|
||
// record and created a new one, piling up near-duplicate scripts in the
|
||
// Rehearsal Library for the same book. Keeping whatever rehId is already
|
||
// known — null only for a genuinely first-ever cast — makes retries/
|
||
// recasts overwrite that one record instead.
|
||
_abClearDraft();
|
||
}
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
|
||
const ac = new AbortController();
|
||
_audiobook.abort = () => ac.abort();
|
||
|
||
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
||
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(text);
|
||
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
if (isResume && resume.segments && resume.segments.length) view.rebuild(resume.segments);
|
||
|
||
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
|
||
try {
|
||
await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
|
||
body: JSON.stringify({
|
||
text: 'Wake up.', known_characters: [], recent: '', language,
|
||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
|
||
})
|
||
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
_audiobook.cancel = true;
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
|
||
toast('Casting stopped before any passages were saved.', 'info');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const allSegments = isResume ? resume.segments.slice() : [];
|
||
_audiobook.liveSegments = allSegments; // expose so split works during casting
|
||
const roster = isResume ? (resume.roster || []).slice() : [];
|
||
let narrationOnly = isResume ? (resume.narrationOnly || 0) : 0; // passages with no quotes at all — legitimately all narration
|
||
let degraded = isResume ? (resume.degraded || 0) : 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
|
||
let recoveredGaps = isResume ? (resume.recoveredGaps || 0) : 0; // passages where the LLM silently dropped a sentence/paragraph, recovered as Narrator
|
||
let completedChunks = startIndex;
|
||
const saveCheckpoint = () => {
|
||
if (allSegments.length) _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
|
||
};
|
||
_abStartDraftAutosave(saveCheckpoint);
|
||
// Page-mark tracking for visual page-break rows in the casting feed. Segments
|
||
// are stamped with the resolved page number as they're created (see _curPageNum
|
||
// below) so the feed can be redrawn later purely from that stored field instead
|
||
// of re-guessing offsets from text — re-guessing is what used to silently merge
|
||
// pages after navigating away from the casting panel and back.
|
||
const _pgMarks = (_audiobook.pageMarks || []).slice();
|
||
if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0
|
||
let _pgMarkIdx = 0, _pgCharPos = 0, _curPageNum = 1;
|
||
if (isResume) {
|
||
// Fast-forward the page-mark cursor past the passages already cast so
|
||
// page-break rows aren't re-emitted into the feed on resume.
|
||
for (let k = 0; k < startIndex; k++) _pgCharPos += chunks[k].length + 1;
|
||
while (_pgMarkIdx < _pgMarks.length && _pgMarks[_pgMarkIdx].offset <= _pgCharPos) _pgMarkIdx++;
|
||
if (_pgMarkIdx > 0) _curPageNum = _pgMarks[_pgMarkIdx - 1].page + 1;
|
||
}
|
||
try {
|
||
for (let i = startIndex; i < chunks.length; i++) {
|
||
if (_audiobook.cancel) break;
|
||
view.update(i);
|
||
// Insert page-break markers in the feed when the source PDF page changes.
|
||
while (_pgMarkIdx < _pgMarks.length && _pgCharPos >= _pgMarks[_pgMarkIdx].offset) {
|
||
_curPageNum = _pgMarks[_pgMarkIdx].page + 1;
|
||
view.pagemark(_curPageNum);
|
||
_pgMarkIdx++;
|
||
}
|
||
_pgCharPos += chunks[i].length + 1; // +1 for joining space between chunks
|
||
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
|
||
if (!audiobookHasDialogue(chunks[i])) {
|
||
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '', page: _curPageNum };
|
||
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
|
||
completedChunks = i + 1;
|
||
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
|
||
continue;
|
||
}
|
||
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
|
||
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker))
|
||
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||
|
||
view.processing(chunks[i]);
|
||
// ── Helper: run one attribution call and return parsed segments (or null on error) ──
|
||
const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => {
|
||
const body = { text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) };
|
||
// Streaming first — shows the LLM's live thinking in the processing
|
||
// card. Any stream failure falls through to the blocking endpoint.
|
||
try {
|
||
const d = await audiobookAttributeStream(body, view, ac.signal, timeoutMs);
|
||
return Array.isArray(d.segments) ? d.segments : null;
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') throw err; // propagate cancel
|
||
}
|
||
try {
|
||
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
signal: ac.signal,
|
||
body: JSON.stringify(body),
|
||
}, timeoutMs + 5000);
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
||
const d = await r.json();
|
||
return Array.isArray(d.segments) ? d.segments : null;
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') throw err; // propagate cancel
|
||
return { error: err.message };
|
||
}
|
||
};
|
||
|
||
// The whole per-chunk attribution + cleanup pipeline is guarded so that any
|
||
// unexpected error for ONE passage (bad response shape, network hiccup mid
|
||
// retry, etc.) degrades gracefully to quote-splitting instead of throwing an
|
||
// uncaught error that kills the entire cast loop and strands the panel in a
|
||
// broken, un-navigable state. Abort (Stop Casting) still propagates normally.
|
||
let segs;
|
||
try {
|
||
// ── First attempt ──
|
||
let result = await attributeChunk(chunks[i], recent);
|
||
let data = null;
|
||
|
||
if (result && !result.error) {
|
||
data = result;
|
||
} else if (result && result.error) {
|
||
// ── Retry: split the failing chunk in half and run both halves ──
|
||
view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`);
|
||
const half = Math.floor(chunks[i].length / 2);
|
||
const splitAt = chunks[i].lastIndexOf(' ', half) || half;
|
||
const chunkA = chunks[i].slice(0, splitAt).trim();
|
||
const chunkB = chunks[i].slice(splitAt).trim();
|
||
const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
|
||
const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
|
||
|
||
const segsA = Array.isArray(resA) ? resA : null;
|
||
const segsB = Array.isArray(resB) ? resB : null;
|
||
|
||
if (segsA || segsB) {
|
||
const arrA = segsA || audiobookSplitByQuotes(chunkA);
|
||
const arrB = segsB || audiobookSplitByQuotes(chunkB);
|
||
data = [...arrA, ...arrB];
|
||
if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); }
|
||
if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); }
|
||
} else {
|
||
// Both halves also failed
|
||
view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`);
|
||
data = null;
|
||
}
|
||
}
|
||
|
||
segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []);
|
||
const hasDialogue = segs.some(s => s.type === 'dialogue');
|
||
if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) {
|
||
// LLM completely unavailable — extract dialogue from quotes without speaker attribution
|
||
segs = audiobookSplitByQuotes(chunks[i]);
|
||
degraded++;
|
||
const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length;
|
||
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
|
||
} else {
|
||
// Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration
|
||
let cleanedSegs = [];
|
||
for (let s of segs) {
|
||
s.text = s.text || '';
|
||
|
||
if (s.type === 'dialogue' && audiobookIsSpeechTagOnly(s.text)) {
|
||
s.type = 'narration';
|
||
s.speaker = 'Narrator';
|
||
s.emotion = '';
|
||
}
|
||
|
||
// Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination.
|
||
if (s.type === 'dialogue' && s.text.trim().length > 10) {
|
||
const tText = s.text.trim();
|
||
// Try to find the text in the original chunk to check its surroundings
|
||
const idx = chunks[i].indexOf(tText);
|
||
if (idx !== -1) {
|
||
const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8);
|
||
if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) {
|
||
s.type = 'narration';
|
||
s.speaker = 'Narrator';
|
||
s.emotion = '';
|
||
}
|
||
}
|
||
}
|
||
// The check above only covers text over 10 chars, so it never
|
||
// catches the shorter case: a »quoted title/name« word that DOES
|
||
// have real quote marks around it in the source (so the check
|
||
// above finds nothing wrong) but isn't actually a spoken line at
|
||
// all — see _abImplausibleDialoguePiece.
|
||
if (s.type === 'dialogue' && _abImplausibleDialoguePiece(s.text)) {
|
||
s.type = 'narration';
|
||
s.speaker = 'Narrator';
|
||
s.emotion = '';
|
||
}
|
||
|
||
if (cleanedSegs.length > 0) {
|
||
let last = cleanedSegs[cleanedSegs.length - 1];
|
||
if (s.text.trim() === last.text.trim()) {
|
||
if (s.type === 'dialogue' && last.type !== 'dialogue') {
|
||
cleanedSegs[cleanedSegs.length - 1] = s; continue;
|
||
} else if (s.type !== 'dialogue' && last.type === 'dialogue') {
|
||
continue;
|
||
} else { continue; }
|
||
}
|
||
if (s.type === 'dialogue' && last.type === 'narration') {
|
||
const tS = s.text.trim(), tL = last.text.trim();
|
||
if (tL.endsWith(tS)) {
|
||
last.text = tL.slice(0, -tS.length).trim();
|
||
if (!last.text) cleanedSegs.pop();
|
||
}
|
||
}
|
||
// The mirror image of the endsWith() case above, which was the
|
||
// only overlap this handled: the LLM commonly returns a quote
|
||
// as its own segment AND the full original sentence (quote +
|
||
// speech tag) as the next one, so the FOLLOWING segment starts
|
||
// with a repeat of the previous one rather than ending with it.
|
||
// Nothing caught that, so the same words were spoken twice and
|
||
// the leftover was re-split again downstream into a third
|
||
// fragment (confirmed live: "»Wir werden sehen«" appearing as
|
||
// two dialogue lines plus a stray ", murmelte der Ork."; and
|
||
// narration paragraphs restated verbatim before continuing).
|
||
// Strip the duplicated prefix and keep only the genuinely new
|
||
// remainder — usually the speech tag, which is narration.
|
||
const _cut = _abPrefixDupCut(s.text, last.text);
|
||
if (_cut > 0) {
|
||
s.text = s.text.slice(_cut).replace(/^[\s«»„“”"‟‚‘’›‹]*,?\s*/, '').trim();
|
||
// What remains after the quote is the inquit ("murmelte der
|
||
// Ork."), which belongs to the narrator, never to the
|
||
// character who just spoke.
|
||
if (s.text && last.type === 'dialogue' && s.type === 'dialogue') {
|
||
s.type = 'narration'; s.speaker = 'Narrator'; s.emotion = '';
|
||
}
|
||
}
|
||
}
|
||
if (s.text.trim()) cleanedSegs.push(s);
|
||
}
|
||
|
||
// Merge consecutive narration segments to preserve paragraph flow
|
||
let mergedSegs = [];
|
||
for (let s of cleanedSegs) {
|
||
if (mergedSegs.length > 0) {
|
||
let last = mergedSegs[mergedSegs.length - 1];
|
||
if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') {
|
||
// Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence)
|
||
// But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence.
|
||
// A safe heuristic: if it ends with punctuation, use double newline (paragraph break).
|
||
if (/[.!?]$/.test(last.text.trim())) {
|
||
last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart();
|
||
} else {
|
||
last.text = last.text.trimEnd() + ' ' + s.text.trimStart();
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
mergedSegs.push(s);
|
||
}
|
||
// The LLM can correctly split SOME dialogue in a chunk while
|
||
// leaving OTHER »...« lines merged into a narration segment right
|
||
// next to it — the `hasDialogue` check above only asks "did this
|
||
// chunk produce ANY dialogue at all", so it's satisfied by the
|
||
// first correct split and never re-examines the rest (confirmed
|
||
// live: "»Ich glaube, ich bin in dich verliebt.«" got attributed
|
||
// to Alrik correctly while "»Halt, bleib stehen.«" and "»Ich liebe
|
||
// dich«" a few lines later stayed silently merged into narration
|
||
// with no speaker at all). Re-scan every leftover narration
|
||
// segment individually and pull out anything still embedded —
|
||
// audiobookResolveUnknowns right after this already exists to
|
||
// firm up any "Unknown" speaker this isolated re-split can't infer
|
||
// from the surrounding chunk context.
|
||
let respiltSegs = [];
|
||
for (const s of mergedSegs) {
|
||
if (s.type === 'narration' && audiobookHasDialogue(s.text)) {
|
||
respiltSegs.push(...audiobookSplitByQuotes(s.text));
|
||
} else {
|
||
respiltSegs.push(s);
|
||
}
|
||
}
|
||
// This re-split runs AFTER the per-segment cleanup above, so the
|
||
// brand-new dialogue segments it invents never passed the
|
||
// plausibility check up there — which is exactly how a »quoted
|
||
// title« kept surviving as dialogue despite that fix (confirmed
|
||
// live: the "»Lilo« and »Zerwas«" byline still split into
|
||
// one-word dialogue segments). Re-apply it to this pass's output.
|
||
for (const s of respiltSegs) {
|
||
if (s.type === 'dialogue' && _abImplausibleDialoguePiece(s.text)) {
|
||
s.type = 'narration'; s.speaker = 'Narrator'; s.emotion = '';
|
||
}
|
||
}
|
||
segs = respiltSegs;
|
||
}
|
||
} catch (chunkErr) {
|
||
if (chunkErr.name === 'AbortError') throw chunkErr; // propagate Stop Casting
|
||
degraded++;
|
||
segs = audiobookSplitByQuotes(chunks[i]);
|
||
view.note(`❌ Passage ${i + 1} — unexpected error (${chunkErr.message}) — auto-detected dialogue instead`);
|
||
}
|
||
{ const _rec = _abRecoverDroppedText(chunks[i], segs);
|
||
if (_rec.recovered) {
|
||
segs = _rec.segs;
|
||
recoveredGaps += _rec.recovered;
|
||
view.note(`⚠️ Passage ${i + 1} — recovered ${_rec.recovered} sentence/paragraph gap${_rec.recovered !== 1 ? 's' : ''} the LLM dropped`);
|
||
} }
|
||
// Deterministic pass over what the LLM left Unknown — colon rule and
|
||
// post-quote inquit are mechanical enough to apply in code (with the
|
||
// previous chunk's tail as lookback context across the boundary).
|
||
audiobookResolveUnknowns(segs, allSegments.slice(-2), roster);
|
||
// harvest speaker names (from LLM or tag heuristic) into the running roster
|
||
segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); });
|
||
segs.forEach(s => { s.page = _curPageNum; allSegments.push(s); });
|
||
view.addSegments(segs);
|
||
completedChunks = i + 1;
|
||
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
|
||
}
|
||
view.update(_audiobook.cancel ? completedChunks : chunks.length);
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
_audiobook.cancel = true;
|
||
view.note('Casting stopped. Completed passages were preserved.');
|
||
} else {
|
||
// Surface unexpected errors instead of throwing an uncaught rejection —
|
||
// that used to strand the panel mid-cast with no way to recover short of
|
||
// reloading the whole app. Treat it like a stop: keep whatever completed
|
||
// and let the normal "stopped early" / recast flow take over below.
|
||
_audiobook.cancel = true;
|
||
view.note(`❌ Casting stopped — unexpected error: ${err.message}`);
|
||
}
|
||
} finally {
|
||
_abStopDraftAutosave();
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
_abHideCastingControl();
|
||
}
|
||
|
||
if (!allSegments.length) {
|
||
if (_audiobook.cancel) {
|
||
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
|
||
toast('Casting stopped before any passages were saved.', 'info');
|
||
return;
|
||
}
|
||
view.done();
|
||
toast('No segments produced', 'error');
|
||
return;
|
||
}
|
||
|
||
// Everything below only ever ran when nothing above threw, but wasn't
|
||
// itself guarded — a failure here (e.g. in the quote-fix/merge passes or
|
||
// saving the draft) used to leave the panel stuck showing "Stop Casting"
|
||
// forever, with no error and the LLM work already genuinely finished
|
||
// (confirmed live: GPU load back to idle, button never resets). Wrapping
|
||
// it means a failure here still reaches a terminal view.done() instead of
|
||
// silently hanging.
|
||
try {
|
||
// allSegments is declared const further up — replace its contents in
|
||
// place rather than rebinding, since it's captured by closures above.
|
||
// Fix stray quote-mark boundaries BEFORE merging same-speaker segments,
|
||
// so a narration row that's about to be merged away doesn't carry a
|
||
// misplaced guillemet into its neighbour first.
|
||
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
|
||
const { segments: _rejoined, merged: _mergedCount } = _audiobookMergeSplitSentences(_quoteFixedSegs);
|
||
if (_mergedCount) console.info('[cast] rejoined', _mergedCount, 'sentence(s) split by quoted terms');
|
||
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_rejoined);
|
||
allSegments.length = 0;
|
||
allSegments.push(..._mergedSegs);
|
||
{ const _ph = _audiobookDropPhantomSpeakers(allSegments);
|
||
if (_ph.dropped) console.info('[cast] dropped', _ph.dropped, 'phantom speaker line(s):', _ph.names.join(', ')); }
|
||
// Role/epithet labels -> proper names FIRST, so the subset consolidation
|
||
// below then folds any remaining name variants together ("Sharraz" into
|
||
// "Sharraz Garthai") on the already-resolved labels.
|
||
{ const _role = _audiobookResolveRoleAliases(allSegments, text);
|
||
if (_role.moved) {
|
||
console.info('[cast] resolved role labels to names:', _role.pairs.join(', '), `(${_role.moved} lines)`);
|
||
try { toast(`Resolved ${_role.merged} role label${_role.merged !== 1 ? 's' : ''} to character names (${_role.moved} lines)`, 'success'); } catch (_) {}
|
||
} }
|
||
{ const _ident = _audiobookConsolidateSpeakerAliases(allSegments);
|
||
if (_ident.merged) {
|
||
console.info('[cast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');
|
||
try { toast(`Merged ${_ident.merged} duplicate character label${_ident.merged !== 1 ? 's' : ''} (${_ident.moved} lines)`, 'success'); } catch (_) {}
|
||
} }
|
||
// Confidence last — see the note at the recast site: any pass above can
|
||
// change a speaker, so scoring earlier would leave stale colours behind.
|
||
_abStampConfidence(allSegments);
|
||
_audiobook.segments = allSegments;
|
||
_audiobook.lastText = text;
|
||
_audiobook.roster = roster;
|
||
_audiobook.narratedPassages = narrationOnly;
|
||
_audiobook.degraded = degraded;
|
||
_audiobook.recoveredGaps = recoveredGaps;
|
||
_audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length;
|
||
_audiobook.completedTotal = chunks.length;
|
||
_abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length);
|
||
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
|
||
|
||
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
|
||
|
||
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
|
||
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
|
||
const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
|
||
// A fresh cast usually leaves a tail of Unknown speakers and some spoken
|
||
// lines typed as narration. Rather than leaving the user to work out which
|
||
// of the six passes to run and in what order, chain the repair sequence
|
||
// automatically — but only when the cast actually finished and the count is
|
||
// genuinely high, and never after a stopped run (the partial state is not a
|
||
// fair basis for it). Switch it off via localStorage 'ab-auto-repair-enabled'.
|
||
if (!stoppedEarly && !_audiobook.cancel && audiobookAutoRepairEnabled()) {
|
||
const unknownNow = allSegments.filter(s =>
|
||
s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||
if (unknownNow > 10) {
|
||
setTimeout(() => { audiobookAutoRepairCast().catch(e => console.warn('[auto-repair]', e)); }, 800);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[audiobookCast] post-processing failed', e);
|
||
view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message + ' — your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted.' });
|
||
toast('Casting finished but cleanup failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Editable attribution preview ─────────────────────────────────────────────
|
||
|
||
async function audiobookOpenCurrentInRehearser() {
|
||
const segs = _audiobook.segments || [];
|
||
if (!segs.length) { toast('No cast to open', 'error'); return false; }
|
||
|
||
// Build the record straight from the CURRENT in-memory segments and load
|
||
// it directly — a prior version saved this to IndexedDB and then
|
||
// re-fetched it by id before loading, which could hand back a stale
|
||
// record (a read-after-write race, or a stale `_audiobook.rehId` left
|
||
// over from an earlier pass) instead of the just-corrected data. Building
|
||
// once and loading that same object removes the possibility entirely;
|
||
// the DB write below is now just for persistence, not the source of what
|
||
// gets loaded.
|
||
if (typeof window.rehLoadRecord === 'function' && typeof _audiobookBuildRehRecord === 'function') {
|
||
try {
|
||
const rec = await _audiobookBuildRehRecord(segs);
|
||
if (_audiobook.rehId) rec.id = _audiobook.rehId;
|
||
document.getElementById('audiobook-preview')?.remove();
|
||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||
window.rehLoadRecord(rec);
|
||
// rehLoadRecord always lands on the Cast phase, but by the time you're
|
||
// opening the Rehearser from the Audiobook pipeline, voices were already
|
||
// assigned in Assign Voices — Cast there is pure redundant re-work.
|
||
// Jump straight to Stage (phase 3), the line-by-line editing view.
|
||
// showPhase() alone only toggles which phase <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 & 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,
|
||
});
|