diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e3caa..4cd1b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ### Changed - **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in `audiobook-cast.js`, including dialogue-only merging, split-sentence repair, and nearby-duplicate removal, - keeping deterministic segment cleanup separate from live casting orchestration. + phantom-speaker filtering, and speaker-alias consolidation, keeping deterministic segment cleanup separate from + live casting orchestration. - **Audiobook segmentation extraction.** Quote segmentation, conservative turn-taking, and dialogue-piece validation now live in `audiobook-segmentation.js`; the live casting pipeline remains compatible with the same global helpers. - **Rehearser voice-design extraction.** Character development, backend selection, and backend-refresh synchronization diff --git a/VERSION b/VERSION index a64d1f1..9f5aeb8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.41 +1.24.42 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7a0ca8..4c966c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. - ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. - ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`. -- ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, and deterministic speaker/sentence/duplicate normalization helpers; live casting remains in `audiobook.js`. +- ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, and deterministic speaker/sentence/duplicate/alias normalization helpers; live casting remains in `audiobook.js`. - ✅ `static/css/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles. - ✅ `static/css/background-jobs.css` — isolated background-job history and retry-panel styles. - ✅ `static/css/voice-design.css` — isolated Voice Design form, samples, and preview-match styles. diff --git a/static/index.html b/static/index.html index 1d6e459..363c673 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,10 +27,10 @@ - - - - + + + + @@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook-cast.js b/static/js/audiobook-cast.js index 6455150..1af7803 100644 --- a/static/js/audiobook-cast.js +++ b/static/js/audiobook-cast.js @@ -144,6 +144,71 @@ function _audiobookDedupNearbyDuplicates(segments) { 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 = { @@ -153,4 +218,6 @@ window.TTVC.audiobook.cast = { mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue, mergeSplitSentences: _audiobookMergeSplitSentences, dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates, + dropPhantomSpeakers: _audiobookDropPhantomSpeakers, + consolidateSpeakerAliases: _audiobookConsolidateSpeakerAliases, }; diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 32f3e1e..b819b0e 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -897,12 +897,6 @@ function _audiobookFixOrphanedQuoteMarks(segments) { // A variant is only folded into another when its words are a strict SUBSET of // the other's after stripping articles and titles, so "Weber" and "Alter Weber" // (equal after stripping) stay separate, and two unrelated names never merge. -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); -} // Speaker labels that are really fragments of a German noun phrase, not people: // "in seinem Inneren" -> Inneren, "die Hand" -> Hand, "mein Geliebter" -> // Geliebten, "der Wind" -> Wind. The caster emits these as characters, each then @@ -916,67 +910,7 @@ function _abIdentityTokens(name) { // "Keiner sagte mehr etwas." -> Keiner, a dangling "Von" from "von Blautann". // Confirmed by a reader hand-correcting a whole book: every one of these was // deleted as a bogus character. -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+/, ''); - if (!n) return false; - return _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] }; -} window._audiobookDropPhantomSpeakers = _audiobookDropPhantomSpeakers; - -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; // strict subset only - 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; - if (mapping.size) { - 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._audiobookConsolidateSpeakerAliases = _audiobookConsolidateSpeakerAliases; // A bare one-word "dialogue" piece with no sentence-ending punctuation is