// ── 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 }); 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 }); return; } segs.push({ speaker, type: 'dialogue', text: sp.text, emotion: (hit && hit.emotion) || '', page }); }); 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); if (!stoppedEarly && !_audiobook.cancel && audiobookAutoRepairEnabled()) { const unknown = allSegments.filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length; if (unknown > 10) setTimeout(() => { audiobookAutoRepairCast().catch(e => console.warn('[auto-repair]', e)); }, 800); } } catch (e) { console.error('[audiobookCastSpans] post-processing failed', e); view.done({ stopped: true, message: 'Finished casting, but saving/cleanup failed: ' + e.message }); } return true; } root.audiobookSpanModeEnabled = audiobookSpanModeEnabled; root.audiobookCastSpans = audiobookCastSpans; root.TTVC = root.TTVC || {}; root.TTVC.audiobook = root.TTVC.audiobook || {}; root.TTVC.audiobook.castSpans = audiobookCastSpans; })(window);