348 lines
19 KiB
JavaScript
348 lines
19 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;
|
|
}
|
|
|
|
// A cold model server can take minutes to load the model; without this the first real request would
|
|
// time out and its passage fall back to tag guessing. Best effort: only a user abort propagates.
|
|
async function _abWarmUp(view, llm, language, signal) {
|
|
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,
|
|
body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, llm_url: llm.url, model: llm.model,
|
|
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS) }),
|
|
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
|
|
} catch (err) { if (err.name === 'AbortError') throw err; }
|
|
}
|
|
|
|
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);
|
|
|
|
_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 {
|
|
await _abWarmUp(view, llm, language, ac.signal);
|
|
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 {
|
|
await _abWarmUp(view, llm, language, ac.signal);
|
|
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;
|
|
root.TTVC.audiobook.verifySpans = audiobookVerifySpans;
|
|
})(window);
|