// ── Audiobook casting, span mode ──────────────────────────────────────────── // Structure (narration vs dialogue, and the exact text of each piece) comes // from the book's own quotation marks, deterministically. The LLM is only asked // who speaks each dialogue span (POST /api/attribute-spans), so it can never // drop, duplicate or fuse text, and a whole book takes minutes instead of hours. // Used by audiobookCast() unless switched off (localStorage 'ab-span-mode' = '0') // or the book has no recognisable quotation style, in which case the classic // whole-passage engine runs instead. (function (root) { 'use strict'; function audiobookSpanModeEnabled() { try { return localStorage.getItem('ab-span-mode') !== '0'; } catch (_) { return true; } } const RECENT_LINES = 6; const ROSTER_LIMIT = 40; // Page (1-based) of a character offset, from the reader's page marks. function _abPageAt(marks, offset) { let page = 1; for (const m of marks) { if (m.offset <= offset) page = m.page + 1; else break; } return page; } function _abWindowRequest(win, next, roster, recent, language, llm) { const following = (next ? next.spans.slice(0, 2) : []).map(s => s.llmText.slice(0, 300)).join(' '); return { spans: win.spans.map(s => ({ id: s.id, kind: s.kind, text: s.llmText })), known_characters: roster.slice(-ROSTER_LIMIT), recent, following, language, llm_url: llm.url, model: llm.model, timeout_seconds: llm.timeout, }; } async function audiobookCastSpans(overrideUrl, overrideModel, resume) { const text = audiobookScopeText(); if (!text || !root.TTVC?.audiobook?.spans) return false; const S = root.TTVC.audiobook.spans; const spans = S.segmentByQuotes(text); if (!spans || !spans.some(s => s.kind === 'dialogue')) return false; // no quote style: classic engine const windows = S.buildWindows(spans, text); const byId = new Map(); for (const w of windows) for (const sp of w.spans) byId.set(sp.id, sp); _audiobook.running = true; _audiobook.castMode = 'spans'; if (!_abBookId()) await _abEnsureLibraryBook(); const startIndex = (resume && resume.startIndex > 0 && resume.startIndex < windows.length) ? resume.startIndex : 0; if (!startIndex) _abClearDraft(); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); const ac = new AbortController(); _audiobook.cancel = false; _audiobook.abort = () => ac.abort(); if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null; const llmUrl = overrideUrl || audiobookLlmUrl(); const model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel()); const language = audiobookLang(text); const view = audiobookCastView(windows.length, llmUrl, model, false); if (startIndex && resume.segments && resume.segments.length) view.rebuild(resume.segments); const llm = { url: document.getElementById('ab-cv-llm-url')?.value.trim() || llmUrl, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout: audiobookTimeoutSeconds(AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS), }; const allSegments = startIndex ? resume.segments.slice() : []; _audiobook.liveSegments = allSegments; const roster = startIndex ? (resume.roster || []).slice() : []; let completed = startIndex, degraded = startIndex ? (resume.degraded || 0) : 0, narrationOnly = startIndex ? (resume.narrationOnly || 0) : 0; const marks = (_audiobook.pageMarks || []).slice(); _abStartDraftAutosave(() => { if (allSegments.length) _abSaveDraft(allSegments, roster, text, completed, windows.length); }); try { for (let i = startIndex; i < windows.length; i++) { if (_audiobook.cancel) break; const win = windows[i]; view.update(i); const segs = []; let answer = { speakers: {} }; if (win.hasDialogue) { const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) .slice(-RECENT_LINES).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n'); view.processing(win.spans.map(s => s.text).join(' ').slice(0, 900)); try { const r = await audiobookFetchWithTimeout('/api/attribute-spans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify(_abWindowRequest(win, windows[i + 1], roster, recent, language, llm)), }, AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS + 5000); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || ('HTTP ' + r.status)); answer = await r.json(); } catch (err) { if (err.name === 'AbortError') throw err; answer = { failed: true, speakers: {} }; view.note(`⚠️ Passage ${i + 1}: speaker lookup failed (${err.message}) — falling back to speech tags`); } if (answer.failed) degraded++; } else narrationOnly++; win.spans.forEach((sp, k) => { const page = _abPageAt(marks, sp.start); if (sp.kind === 'narration') { segs.push({ speaker: 'Narrator', type: 'narration', text: sp.text, emotion: '', page, spanId: sp.id }); return; } const hit = answer.speakers && answer.speakers[sp.id]; let speaker = hit && hit.speaker; if (!speaker || answer.failed) { const before = win.spans[k - 1]?.text || '', after = win.spans[k + 1]?.text || ''; speaker = audiobookGuessSpeaker(after, before) || 'Unknown'; } if (speaker === 'Narrator') { segs.push({ speaker: 'Narrator', type: 'narration', text: sp.text, emotion: '', page, spanId: sp.id }); return; } segs.push({ speaker, type: 'dialogue', text: sp.text, emotion: (hit && hit.emotion) || '', page, spanId: sp.id }); }); audiobookResolveUnknowns(segs, allSegments.slice(-2), roster); segs.forEach(s => { if (s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker) && !roster.includes(s.speaker)) roster.push(s.speaker); allSegments.push(s); }); view.addSegments(segs); completed = i + 1; _abSaveDraft(allSegments, roster, text, completed, windows.length); } view.update(_audiobook.cancel ? completed : windows.length); } catch (err) { _audiobook.cancel = true; view.note(err.name === 'AbortError' ? 'Casting stopped. Completed passages were preserved.' : `❌ Casting stopped — unexpected error: ${err.message}`); } finally { _abStopDraftAutosave(); _audiobook.running = false; _audiobook.abort = null; _abHideCastingControl(); } if (!allSegments.length) { view.done({ stopped: true, message: 'Casting stopped before any passage completed.' }); return true; } try { { const ph = _audiobookDropPhantomSpeakers(allSegments); if (ph.dropped) console.info('[cast] dropped', ph.dropped, 'phantom speaker line(s):', ph.names.join(', ')); } { const role = _audiobookResolveRoleAliases(allSegments, text); if (role.moved) console.info('[cast] resolved role labels to names:', role.pairs.join(', '), `(${role.moved} lines)`); } { const ident = _audiobookConsolidateSpeakerAliases(allSegments); if (ident.merged) console.info('[cast] consolidated', ident.merged, 'speaker label variant(s),', ident.moved, 'line(s)'); } _abStampConfidence(allSegments); _audiobook.segments = allSegments; _audiobook.lastText = text; _audiobook.roster = roster; _audiobook.narratedPassages = narrationOnly; _audiobook.degraded = degraded; _audiobook.recoveredGaps = 0; _audiobook.completedChunks = _audiobook.cancel ? completed : windows.length; _audiobook.completedTotal = windows.length; _abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, windows.length); audiobookSaveAsRehearsal({ silent: true }); const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const stoppedEarly = _audiobook.cancel && completed < windows.length; view.complete(`${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); } catch (e) { console.error('[audiobookCastSpans] post-processing failed', e); view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message }); } return true; } // ── Verify / recast for span casts ────────────────────────────────────────── // Verify (the 2nd quality run) is ADVISORY: a second, differently-windowed pass over every dialogue // line. It never changes a speaker. Lines both passes agree on are marked `verified: 'agreed'`; where // they disagree the first answer is kept, the line is marked `disputed` and the other pass's answer is // stored as `altSpeaker` for the reviewer. Measured on a hand-corrected book: agreed lines were 82 % // right, contested first answers 67 %, and letting a reasoning pass overrule the first answer made those // lines worse (61 %), so nothing is overruled. Recast unknown (`unknownOnly`) re-asks only the Unknown // lines, with reasoning and fuller context; a line the model cannot place stays Unknown. const VERIFY_WINDOW = { targetChars: 5000, hardChars: 7000 }; const VERIFY_CLUSTER_GAP = 10; async function _abAsk(body, signal, timeoutMs) { const r = await audiobookFetchWithTimeout('/api/attribute-spans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify(body), }, timeoutMs + 5000); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || ('HTTP ' + r.status)); return r.json(); } function _abIsUnknownSpeaker(name) { return !name || /^(unknown|unbekannt)/i.test(String(name).trim()); } async function audiobookVerifySpans(overrideUrl, overrideModel, options = {}) { const segs = _audiobook.segments || []; const text = _audiobook.lastText || ''; const S = root.TTVC?.audiobook?.spans; if (!S || !text || !segs.some(s => s.spanId)) return false; const spans = S.segmentByQuotes(text); if (!spans) return false; const idOf = (sp) => (sp.kind === 'dialogue' ? 'D' : 'N') + sp.index; const posInSpans = new Map(spans.map((sp, k) => [idOf(sp), k])); const tagged = segs.filter(s => s.spanId); if (tagged.filter(s => posInSpans.has(s.spanId)).length < 0.95 * tagged.length) return false; // not this text if (_abBusyGuard('Verify speakers')) return true; const unknownOnly = !!options.unknownOnly; const segById = new Map(tagged.map(s => [s.spanId, s])); const dialogueIds = spans.filter(sp => sp.kind === 'dialogue').map(idOf); const posInSegs = new Map(segs.map((s, k) => [s.spanId, k])); const rosterNow = () => [...new Set(segs.filter(s => s.type === 'dialogue' && !_abIsUnknownSpeaker(s.speaker)).map(s => s.speaker))]; const recentBefore = (id) => { const out = []; for (let k = (posInSegs.get(id) ?? 0) - 1; k >= 0 && out.length < RECENT_LINES; k--) { const s = segs[k]; if (s.type === 'dialogue' && !_abIsUnknownSpeaker(s.speaker)) out.unshift(`${s.speaker}: ${(s.text || '').slice(0, 80)}`); } return out.join('\n'); }; _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 llmUrl = overrideUrl || audiobookLlmUrl(); const model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel()); const language = audiobookLang(text); const llm = { url: document.getElementById('ab-cv-llm-url')?.value.trim() || llmUrl, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), }; const decisions = new Map(); // unknownOnly: span id -> speaker const flags = new Map(); // verify: span id -> { verified, alt } const stats = { checked: 0, agreed: 0, disputed: 0, resolvedUnknown: 0 }; let finished = false; const clusters = (ids) => { const out = []; ids.forEach(id => { const pos = posInSpans.get(id); if (out.length && pos - out[out.length - 1].last <= VERIFY_CLUSTER_GAP) { out[out.length - 1].ids.push(id); out[out.length - 1].last = pos; } else out.push({ ids: [id], first: pos, last: pos }); }); return out; }; const windows = unknownOnly ? [] : S.buildWindows(spans, text, VERIFY_WINDOW).filter(w => w.hasDialogue); const unknownIds = unknownOnly ? dialogueIds.filter(id => _abIsUnknownSpeaker(segById.get(id)?.speaker)) : []; const groups = unknownOnly ? clusters(unknownIds) : []; const view = audiobookCastView(Math.max(1, unknownOnly ? groups.length : windows.length), llmUrl, model); const asWindow = (lo, hi) => spans.slice(lo, hi).map(sp => ({ id: idOf(sp), kind: sp.kind, text: sp.text.length > 1500 ? sp.text.slice(0, 900) + ' [...] ' + sp.text.slice(-600) : sp.text })); try { if (unknownOnly) { const pre = audiobookResolveUnknowns(segs, [], _audiobook.roster || []); if (pre.length) toast(`${pre.length} Unknown line${pre.length !== 1 ? 's' : ''} resolved by grammar rules`, 'success'); if (!unknownIds.length) { toast('No Unknown speakers found', 'success'); view.done(); audiobookShowPreview(); return true; } for (let g = 0; g < groups.length && !_audiobook.cancel; g++) { const grp = groups[g]; view.update(g, `Re-reading Unknown lines ${g + 1} / ${groups.length}…`); let ans; try { ans = await _abAsk({ spans: asWindow(Math.max(0, grp.first - 6), Math.min(spans.length, grp.last + 3)), attribute_only: grp.ids, known_characters: rosterNow().slice(-ROSTER_LIMIT), recent: recentBefore(grp.ids[0]), language, want_reasoning: true, llm_url: llm.url, model: llm.model, timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS), }, ac.signal, AUDIOBOOK_RECAST_TIMEOUT_MS); } catch (err) { if (err.name === 'AbortError') throw err; view.note(`⚠️ Unknown lines ${g + 1}: ${err.message}`); continue; } for (const id of grp.ids) { const hit = ans.speakers && ans.speakers[id]; if (hit && !_abIsUnknownSpeaker(hit.speaker) && hit.speaker !== 'Narrator') decisions.set(id, hit.speaker); } } } else { for (let i = 0; i < windows.length && !_audiobook.cancel; i++) { view.update(i, `Second pass ${i + 1} / ${windows.length}…`); const win = windows[i].spans; let ans; try { ans = await _abAsk({ spans: win.map(sp => ({ id: sp.id, kind: sp.kind, text: sp.llmText })), known_characters: rosterNow().slice(-ROSTER_LIMIT), recent: recentBefore(win.find(sp => sp.kind === 'dialogue').id), language, llm_url: llm.url, model: llm.model, timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS), }, ac.signal, AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS); } catch (err) { if (err.name === 'AbortError') throw err; view.note(`⚠️ Second pass ${i + 1}: ${err.message}`); continue; } for (const [id, v] of Object.entries(ans.speakers || {})) { const first = segById.get(id)?.speaker, other = v.speaker; if (!segById.has(id) || other === 'Narrator' || _abIsUnknownSpeaker(other)) continue; // no usable second opinion stats.checked++; if (_abIsUnknownSpeaker(first)) flags.set(id, { verified: 'unresolved', alt: other }); else if (first === other) { flags.set(id, { verified: 'agreed' }); stats.agreed++; } else { flags.set(id, { verified: 'disputed', alt: other }); stats.disputed++; } } } } finished = !_audiobook.cancel; } catch (err) { if (err.name !== 'AbortError') view.note('Error while verifying: ' + err.message); } finally { _audiobook.running = false; _audiobook.abort = null; _abHideCastingControl(); } if (!finished) { view.done({ stopped: true, message: 'Verification stopped. Existing cast preserved.' }); toast('Verification stopped. Existing cast preserved.', 'info'); return true; } try { decisions.forEach((speaker, id) => { const seg = segById.get(id); if (!seg || !_abIsUnknownSpeaker(seg.speaker)) return; seg.speaker = speaker; stats.resolvedUnknown++; if (!_audiobook.roster.includes(speaker)) _audiobook.roster.push(speaker); }); flags.forEach((f, id) => { const seg = segById.get(id); if (seg) { seg.verified = f.verified; if (f.alt) seg.altSpeaker = f.alt; else delete seg.altSpeaker; } }); { const ph = _audiobookDropPhantomSpeakers(segs); if (ph.dropped) console.info('[verify] dropped', ph.dropped, 'phantom speaker line(s)'); } _audiobookResolveRoleAliases(segs, text); _audiobookConsolidateSpeakerAliases(segs); _abStampConfidence(segs); _abSaveDraft(segs, _audiobook.roster || [], text, _audiobook.completedChunks || segs.length, _audiobook.completedTotal || _audiobook.completedChunks || segs.length); audiobookSaveAsRehearsal({ silent: true }); const summary = unknownOnly ? `${stats.resolvedUnknown} Unknown line${stats.resolvedUnknown !== 1 ? 's' : ''} resolved` : `${stats.checked} lines read twice · ${stats.agreed} confirmed, ${stats.disputed} flagged for review`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); toast(summary, 'success'); } catch (e) { console.error('[audiobookVerifySpans] apply failed', e); view.done({ stopped: true, message: 'Verification finished, but applying it failed: ' + e.message }); } return true; } root.audiobookVerifySpans = audiobookVerifySpans; root.audiobookSpanModeEnabled = audiobookSpanModeEnabled; root.audiobookCastSpans = audiobookCastSpans; root.TTVC = root.TTVC || {}; root.TTVC.audiobook = root.TTVC.audiobook || {}; root.TTVC.audiobook.castSpans = audiobookCastSpans; })(window);