tts-voice-creator-clone-and.../static/js/audiobook-cast.js

224 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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,
};