refactor: extract audiobook cast normalization

This commit is contained in:
mARTin-B78 2026-09-04 11:25:52 +02:00
parent 0876527554
commit 41349867a1
6 changed files with 40 additions and 49 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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 - **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. 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 - **Rehearser voice-design extraction.** Character development, backend selection, and backend-refresh synchronization

View File

@ -1 +1 @@
1.24.38 1.24.39

View File

@ -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. - ✅ `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-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-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/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles.
- ✅ `static/css/background-jobs.css` — isolated background-job history and retry-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. - ✅ `static/css/voice-design.css` — isolated Voice Design form, samples, and preview-match styles.

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.24.38"> <meta name="app-version" content="1.24.39">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,10 +27,10 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.24.38"> <link rel="stylesheet" href="/static/style.css?v=1.24.39">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.38"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.39">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.38"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.39">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.38"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.39">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.24.38"></script> <script src="/static/loader.js?v=1.24.39"></script>
</body> </body>
</html> </html>

View File

@ -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 = window.TTVC || {};
window.TTVC.audiobook = window.TTVC.audiobook || {}; window.TTVC.audiobook = window.TTVC.audiobook || {};
window.TTVC.audiobook.cast = { toMarkdown: _abCastMarkdown, stampPages: _abStampSegmentPages }; window.TTVC.audiobook.cast = {
toMarkdown: _abCastMarkdown,
stampPages: _abStampSegmentPages,
mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker,
};

View File

@ -863,46 +863,6 @@ function _audiobookFixOrphanedQuoteMarks(segments) {
return segments.filter(s => s.text.trim()); 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 // Removes a segment whose text is a near-duplicate of another segment within
// a nearby window — confirmed live: "Verify all characters" (audiobookRecastUnknown // a nearby window — confirmed live: "Verify all characters" (audiobookRecastUnknown
// with includeNarrator:true) can re-splice a passage's already-correct text back // with includeNarrator:true) can re-splice a passage's already-correct text back