tts-voice-creator-clone-and.../static/js/audiobook-cast-spans.js
mARTin-B78 e7cb92c572 feat(audiobook): span-mode casting - deterministic quote spans, LLM only names speakers
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>
2026-09-19 08:30:26 +02:00

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);