224 lines
9.7 KiB
JavaScript
224 lines
9.7 KiB
JavaScript
// ── Audiobook cast serialization ────────────────────────────────────────────
|
||
// Live casting remains in audiobook.js; these deterministic export/migration
|
||
// helpers have no casting-state side effects and can be loaded independently.
|
||
|
||
function _abCastMarkdown(data) {
|
||
const payload = data || {};
|
||
const segments = Array.isArray(payload.segments) ? payload.segments : [];
|
||
const speakers = [...new Set(segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker)))].sort();
|
||
const lines = [`# ${payload.title || 'Cast Script'}`, ''];
|
||
const metaBits = [];
|
||
if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' '));
|
||
metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`);
|
||
metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`);
|
||
lines.push(`*${metaBits.join(' · ')}*`, '');
|
||
if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, '');
|
||
let lastPage = null;
|
||
for (const seg of segments) {
|
||
const text = String(seg?.text || '').trim();
|
||
if (!text) continue;
|
||
if (seg.page != null && seg.page !== lastPage) {
|
||
lines.push('---', '', `## Page ${seg.page}`, '');
|
||
lastPage = seg.page;
|
||
}
|
||
if (seg.type === 'dialogue') {
|
||
const speaker = String(seg.speaker || 'Narrator');
|
||
const tag = seg.emotion ? ` *(${seg.emotion})*` : '';
|
||
lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`);
|
||
} else lines.push(text);
|
||
lines.push('');
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function _abStampSegmentPages(segs, pageMarks, text) {
|
||
if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return;
|
||
const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
|
||
const src = text || '';
|
||
if (!marks.length || !src) return;
|
||
let markIdx = 0, searchPos = 0, cur = null;
|
||
if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; }
|
||
for (const s of segs) {
|
||
const probe = String(s.text || '').trim().slice(0, 24);
|
||
const at = probe ? src.indexOf(probe, searchPos) : -1;
|
||
const pos = at >= 0 ? at : searchPos;
|
||
if (at >= 0) searchPos = at + probe.length;
|
||
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
|
||
cur = marks[markIdx].page + 1;
|
||
markIdx++;
|
||
}
|
||
if (cur != null) s.page = cur;
|
||
}
|
||
}
|
||
|
||
// Merge adjacent segments assigned to the same speaker without crossing a
|
||
// page boundary. Kept separate from the live casting orchestration because it
|
||
// is a deterministic post-cast normalization pass.
|
||
function _audiobookMergeAdjacentSameSpeaker(segments) {
|
||
const out = [];
|
||
for (const s of segments) {
|
||
const last = out[out.length - 1];
|
||
if (last && last.type === s.type && last.page === s.page) {
|
||
const lastSpeaker = (last.speaker || 'Narrator').trim().toLowerCase();
|
||
const curSpeaker = (s.speaker || 'Narrator').trim().toLowerCase();
|
||
// Dialogue keeps its emotion tag distinct so one mood is not silently
|
||
// discarded when two adjacent rows are combined.
|
||
const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase();
|
||
const unknownSpeaker = !lastSpeaker || lastSpeaker === 'unknown' || lastSpeaker === 'unbekannt';
|
||
if (lastSpeaker === curSpeaker && sameEmotion && !(s.type === 'dialogue' && unknownSpeaker)) {
|
||
const lastText = last.text.trimEnd(), curText = s.text.trimStart();
|
||
last.text = /[.!?…»«”"’']$/.test(lastText) ? lastText + '\n\n' + curText : lastText + ' ' + curText;
|
||
continue;
|
||
}
|
||
}
|
||
out.push(Object.assign({}, s));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Merge consecutive segments spoken by the same named character. This is a
|
||
// dialogue-only normalization pass; narration is intentionally left alone.
|
||
function _audiobookMergeAdjacentDialogue(segments) {
|
||
const out = [];
|
||
let merged = 0;
|
||
for (const s of segments) {
|
||
if (out.length) {
|
||
const last = out[out.length - 1];
|
||
const spk = String(s.speaker || '').trim().toLowerCase();
|
||
const known = spk && !/^(unknown|unbekannt|narrator)$/i.test(spk);
|
||
if (last.type === 'dialogue' && s.type === 'dialogue' && known
|
||
&& String(last.speaker || '').trim().toLowerCase() === spk) {
|
||
const a = String(last.text || '').trimEnd(), b = String(s.text || '').trimStart();
|
||
last.text = a + (/[.!?…»«"']$/.test(a) ? '\n\n' : ' ') + b;
|
||
if (!String(last.emotion || '').trim() && String(s.emotion || '').trim()) last.emotion = s.emotion;
|
||
merged++;
|
||
continue;
|
||
}
|
||
}
|
||
out.push(s);
|
||
}
|
||
return { segments: out, merged };
|
||
}
|
||
|
||
function _audiobookMergeSplitSentences(segments) {
|
||
const out = [];
|
||
let merged = 0;
|
||
for (const s of segments) {
|
||
const prev = out[out.length - 1];
|
||
const cur = String(s?.text || '').trim();
|
||
if (prev && prev.type === 'narration' && s?.type === 'narration' && cur) {
|
||
const prevText = String(prev.text || '').trimEnd();
|
||
const endsOpen = prevText && !/[.!?:;…»"'\)\]]$/.test(prevText);
|
||
const startsLower = /^[a-zäöüß]/.test(cur);
|
||
const prevIsInquit = new RegExp(_AB_INQUIT_VERBS + "[^.!?]{0,40},\\s*$", 'i').test(prevText);
|
||
if (endsOpen && startsLower && !prevIsInquit) {
|
||
prev.text = prevText + ' ' + cur;
|
||
merged++;
|
||
continue;
|
||
}
|
||
}
|
||
out.push(s);
|
||
}
|
||
return { segments: out, merged };
|
||
}
|
||
|
||
function _audiobookDedupNearbyDuplicates(segments) {
|
||
const QUOTE_CHARS = '»«„"‘’›‹';
|
||
const stripRe = new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`, 'g');
|
||
const norm = (t) => String(t || '').replace(stripRe, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||
const WINDOW = 30;
|
||
const MIN_LEN = 20;
|
||
const out = [];
|
||
let removed = 0;
|
||
for (const s of segments) {
|
||
const nt = norm(s?.text);
|
||
if (nt.length >= MIN_LEN) {
|
||
let isDup = false;
|
||
for (let k = out.length - 1; k >= 0 && out.length - k <= WINDOW; k--) {
|
||
if (out[k].type === s.type && norm(out[k].text) === nt) { isDup = true; break; }
|
||
}
|
||
if (isDup) { removed++; continue; }
|
||
}
|
||
out.push(s);
|
||
}
|
||
return { segments: out, removed };
|
||
}
|
||
|
||
const _AB_TITLE_PREFIX = /^(?:der|die|das|den|dem|ein|eine|einer|herr|frau|oberst|obrist|graf|gräfin|ritter|hauptmann|kommandant|meister|bruder|schwester|prinz|prinzessin|könig|königin|alter|alte|junger|junge|blonder|blonde)\s+/i;
|
||
function _abIdentityTokens(name) {
|
||
let n = String(name || '').trim().toLowerCase(), prev = null;
|
||
while (prev !== n) { prev = n; n = n.replace(_AB_TITLE_PREFIX, ''); }
|
||
return n.split(/[\s\-]+/).filter(Boolean);
|
||
}
|
||
|
||
const _AB_NOT_A_CHARACTER = new Set([
|
||
'hinter','vor','neben','über','unter','zwischen','gegenüber','keiner','keine','niemand','jemand','alle','beide',
|
||
'einige','viele','andere','anderen','jeder','jede','man','von','zu','bei','nach','aus','mit','ihm','ihr','ihnen',
|
||
'dort','hier','draußen','drinnen','oben','unten','plötzlich','endlich','schließlich',
|
||
'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen',
|
||
'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten',
|
||
'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde',
|
||
'himmel','sonne','mond','tod','leben','seele','geist der','wahrheit','zweifel','hoffnung','erinnerung','vergangenheit'
|
||
]);
|
||
function _abIsImplausibleSpeaker(name) {
|
||
const n = String(name || '').trim().toLowerCase().replace(/^(?:der|die|das|den|dem|ein|eine|einer|einem)\s+/, '');
|
||
return Boolean(n) && _AB_NOT_A_CHARACTER.has(n);
|
||
}
|
||
function _audiobookDropPhantomSpeakers(segments) {
|
||
let dropped = 0;
|
||
const names = new Set();
|
||
for (const s of segments) {
|
||
if (s?.type !== 'dialogue') continue;
|
||
if (_abIsImplausibleSpeaker(s.speaker)) { names.add(s.speaker); s.speaker = 'Unknown'; dropped++; }
|
||
}
|
||
return { dropped, names: [...names] };
|
||
}
|
||
|
||
function _audiobookConsolidateSpeakerAliases(segments) {
|
||
const count = new Map();
|
||
for (const s of segments) {
|
||
if (s?.type !== 'dialogue') continue;
|
||
const sp = String(s.speaker || '').trim();
|
||
if (!sp || /^(unknown|unbekannt|narrator)/i.test(sp)) continue;
|
||
count.set(sp, (count.get(sp) || 0) + 1);
|
||
}
|
||
const names = [...count.keys()];
|
||
const tokens = new Map(names.map(n => [n, new Set(_abIdentityTokens(n))]));
|
||
const mapping = new Map();
|
||
for (const a of names) {
|
||
const ta = tokens.get(a);
|
||
if (!ta.size) continue;
|
||
let best = null;
|
||
for (const b of names) {
|
||
if (a === b) continue;
|
||
const tb = tokens.get(b);
|
||
if (!tb.size || tb.size <= ta.size) continue;
|
||
let subset = true;
|
||
for (const t of ta) if (!tb.has(t)) { subset = false; break; }
|
||
if (!subset) continue;
|
||
if (!best || count.get(b) > count.get(best) || (count.get(b) === count.get(best) && b.length > best.length)) best = b;
|
||
}
|
||
if (best) mapping.set(a, best);
|
||
}
|
||
let moved = 0;
|
||
for (const s of segments) {
|
||
if (s?.type !== 'dialogue') continue;
|
||
const to = mapping.get(String(s.speaker || '').trim());
|
||
if (to) { s.speaker = to; moved++; }
|
||
}
|
||
return { merged: mapping.size, moved, mapping };
|
||
}
|
||
|
||
window.TTVC = window.TTVC || {};
|
||
window.TTVC.audiobook = window.TTVC.audiobook || {};
|
||
window.TTVC.audiobook.cast = {
|
||
toMarkdown: _abCastMarkdown,
|
||
stampPages: _abStampSegmentPages,
|
||
mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker,
|
||
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
|
||
mergeSplitSentences: _audiobookMergeSplitSentences,
|
||
dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates,
|
||
dropPhantomSpeakers: _audiobookDropPhantomSpeakers,
|
||
consolidateSpeakerAliases: _audiobookConsolidateSpeakerAliases,
|
||
};
|