refactor: extract audiobook dialogue merging

This commit is contained in:
mARTin-B78 2026-09-04 11:27:49 +02:00
parent 41349867a1
commit 1951ce0646
6 changed files with 34 additions and 55 deletions

View File

@ -9,7 +9,7 @@ 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`, keeping deterministic segment cleanup separate from live casting orchestration.
`audiobook-cast.js`, including dialogue-only merging, 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

View File

@ -1 +1 @@
1.24.39
1.24.40

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.
- ✅ `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 post-cast normalization helpers; live casting 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`.
- ✅ `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.

View File

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

View File

@ -76,10 +76,35 @@ function _audiobookMergeAdjacentSameSpeaker(segments) {
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 };
}
window.TTVC = window.TTVC || {};
window.TTVC.audiobook = window.TTVC.audiobook || {};
window.TTVC.audiobook.cast = {
toMarkdown: _abCastMarkdown,
stampPages: _abStampSegmentPages,
mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker,
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
};

View File

@ -1269,52 +1269,6 @@ function _abStampConfidence(segs) {
return segs;
}
// Merge consecutive segments spoken by the SAME named character.
//
// Deliberately dialogue-only. The full merge (_audiobookMergeAdjacentSameSpeaker)
// runs once at the end of casting, but recast and verify then reassign speakers
// and create fresh adjacent same-speaker pairs that nothing ever merges — 213
// of them in the measured book. Re-running the full merge there makes things
// WORSE, because it also joins narration, and measured against the human
// reference the caster's narration is already too coarse (median 152 chars vs
// the human's 123) while its dialogue is slightly too fine (62 vs 65).
// Measured effect of merging dialogue runs only: text-match failures fall from
// 727 to 588, wrong-speaker rows from 345 to 310, and the segment count moves
// from 2273 to 2059 against the human's 2110. Merging narration as well pushed
// mismatches UP to 746, so this stays one-sided on purpose.
// Unknown speakers are never merged — two adjacent Unknown lines are not
// evidence of one speaker, they are two lines nobody has identified yet.
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;
// Emotion is deliberately NOT required to match. Requiring it blocked
// almost every merge (42 instead of 214) and left segmentation far from
// the human's: measured, same-emotion-only lands at 2230 segments and
// 685 text mismatches, while merging regardless lands at 2059 against
// the human's own 2110, with 588 mismatches and a better speaker-match
// rate (56.4% vs 53.8%). The cost is one emotion tag per character
// block instead of per chunk — but the hand-corrected reference merges
// these blocks too, so a human reading the scene judged one emotion
// sufficient. Keep the first non-empty tag rather than dropping both.
if (!String(last.emotion || '').trim() && String(s.emotion || '').trim()) last.emotion = s.emotion;
merged++;
continue;
}
}
out.push(s);
}
return { segments: out, merged };
}
function audiobookIsRouterModel(model) {
return /^auto[-_ ]?router/i.test(String(model || '').trim());
}