From 41349867a198e18b10d2fac5360a7210c761de11 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:25:52 +0200 Subject: [PATCH] refactor: extract audiobook cast normalization --- CHANGELOG.md | 2 ++ VERSION | 2 +- docs/ARCHITECTURE.md | 2 +- static/index.html | 12 +++++------ static/js/audiobook-cast.js | 31 +++++++++++++++++++++++++++- static/js/audiobook.js | 40 ------------------------------------- 6 files changed, 40 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 791f873..daa4452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in + `audiobook-cast.js`, 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 c2ea603..2264402 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.38 +1.24.39 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 994df25..8462b77 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 and legacy page-stamping migration helpers; live casting remains in `audiobook.js`. +- ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, and deterministic same-speaker post-cast 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 7aaac00..3a12ecd 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 63a7963..9986035 100644 --- a/static/js/audiobook-cast.js +++ b/static/js/audiobook-cast.js @@ -51,6 +51,35 @@ function _abStampSegmentPages(segs, pageMarks, text) { } } +// 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; +} + window.TTVC = window.TTVC || {}; window.TTVC.audiobook = window.TTVC.audiobook || {}; -window.TTVC.audiobook.cast = { toMarkdown: _abCastMarkdown, stampPages: _abStampSegmentPages }; +window.TTVC.audiobook.cast = { + toMarkdown: _abCastMarkdown, + stampPages: _abStampSegmentPages, + mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker, +}; diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 0cb6093..1f672c1 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -863,46 +863,6 @@ function _audiobookFixOrphanedQuoteMarks(segments) { return segments.filter(s => s.text.trim()); } -// Two adjacent segments assigned to the exact same speaker (narrator or a -// character) with no page break between them are either a casting mistake -// (a line that should never have been split off on its own — attribution -// noise from the per-passage LLM pass, especially near dialogue tags) or -// genuinely one continuous block that just got chopped into several tiny -// rows in the casting feed. Either way, splicing them back into one segment -// is the safe, mechanical fix: it can't make a correct attribution worse, -// and it turns a wall of one-line "NARRATOR" cards into the actual -// paragraph flow. Runs once over the whole book after casting finishes -// (not just within one passage chunk, unlike the narration-only merge -// further up) so it also catches passage-boundary duplicates. -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 — merging "happy Marcus" text - // into "angry Marcus" text would silently discard one of the two moods. - const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase(); - // Two adjacent UNKNOWN lines are not evidence of one speaker — "Unknown" - // means precisely that we do not know who spoke. Both normalise to the - // same string here, so they compared as equal and were fused into a single - // block: confirmed live, Lysandra's line and Perdia's interruption (two - // separate paragraphs in the book) became one segment joined by a blank - // line, after which no attribution pass could ever separate them again and - // one voice would read both sides of the exchange. Never merge on Unknown. - 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; -} - // Removes a segment whose text is a near-duplicate of another segment within // a nearby window — confirmed live: "Verify all characters" (audiobookRecastUnknown // with includeNarrator:true) can re-splice a passage's already-correct text back