diff --git a/CHANGELOG.md b/CHANGELOG.md index b8db678..51e3caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,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, keeping deterministic segment cleanup separate from live casting orchestration. + `audiobook-cast.js`, including dialogue-only merging, split-sentence repair, and nearby-duplicate removal, + 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 6d251e8..a64d1f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.40 +1.24.41 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 84ee43f..e7a0ca8 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 same-speaker/dialogue post-cast normalization helpers; live casting 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`. - ✅ `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 5146c26..1d6e459 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 4851372..6455150 100644 --- a/static/js/audiobook-cast.js +++ b/static/js/audiobook-cast.js @@ -100,6 +100,50 @@ function _audiobookMergeAdjacentDialogue(segments) { 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 }; +} + window.TTVC = window.TTVC || {}; window.TTVC.audiobook = window.TTVC.audiobook || {}; window.TTVC.audiobook.cast = { @@ -107,4 +151,6 @@ window.TTVC.audiobook.cast = { stampPages: _abStampSegmentPages, mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker, mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue, + mergeSplitSentences: _audiobookMergeSplitSentences, + dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates, }; diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 3320192..32f3e1e 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -979,53 +979,6 @@ function _audiobookConsolidateSpeakerAliases(segments) { } window._audiobookConsolidateSpeakerAliases = _audiobookConsolidateSpeakerAliases; -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); - // Never merge across an inquit (", flüsterte der Geist,") — that is a - // SPLIT QUOTE whose second half is really dialogue; fusing it into - // narration would bury a line the attribution passes could still fix. - 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; // generous vs. the ~9-segment max context overlap between adjacent recast groups - const MIN_LEN = 20; // skip short common lines ("Ja." "Nein.") that can legitimately repeat - 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 }; -} - // A bare one-word "dialogue" piece with no sentence-ending punctuation is // essentially never a real spoken turn ("Nein!", "Halt." always carry // punctuation) — it's the signature of »quoted title/name« text (German