Split the book into narration/dialogue spans from its own quotation marks and ask the LLM only who speaks each dialogue span (POST /api/attribute-spans), so text can no longer be fused, dropped or duplicated and a novel takes ~10 min instead of ~4 h. Book 1 vs the hand-corrected reference: every-word-right 87.0% -> 92.3%, type agreement 93.6% -> 97.4%; Books 2/3 (not tuned on) 90.8% / 89.9%. - static/js/audiobook-spans.js (pure, unit-tested) + audiobook-cast-spans.js - core/attribution_spans.py, route, tests; scripts/score_attribution.py - default on; localStorage ab-span-mode=0 restores the classic engine - architecture/audiobook-attribution.md, Manual, CHANGELOG 1.32.0, TODO Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
8.9 KiB
JavaScript
178 lines
8.9 KiB
JavaScript
// ── 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);
|