fix: preserve audiobook dialogue boundaries

This commit is contained in:
mARTin-B78 2026-09-04 15:36:26 +02:00
parent 29f5243f6b
commit 2caa417401
7 changed files with 61 additions and 15 deletions

View File

@ -8,6 +8,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- **Initial audiobook casts preserve dialogue boundaries.** The final cast
normalization now merges adjacent narration only; same-speaker dialogue
rows remain separate so Run 3 segmentation errors stay visible and do not
become fused multi-turn lines. Recast/verify retains its explicit
dialogue-only merge behavior.
- **Dialogue-only merge regression coverage.** Recast/verify normalization now has explicit browser coverage proving that - **Dialogue-only merge regression coverage.** Recast/verify normalization now has explicit browser coverage proving that
only adjacent known-speaker dialogue merges; narration and unknown-speaker lines remain separate. only adjacent known-speaker dialogue merges; narration and unknown-speaker lines remain separate.
- **Visual identity regression coverage.** Portrait and concept-sheet prompt tests now verify shared identity anchors; - **Visual identity regression coverage.** Portrait and concept-sheet prompt tests now verify shared identity anchors;

10
TODO.md
View File

@ -270,14 +270,16 @@ codebase; benefit is relative user/reliability impact.
requests share canonical identity anchors and persist the prompt version used for each generated asset. requests share canonical identity anchors and persist the prompt version used for each generated asset.
- [x] **P1 / S–M — Medium benefit — Preserve dialogue-only merging.** Keep the dialogue-only merge for recast/verify - [x] **P1 / S–M — Medium benefit — Preserve dialogue-only merging.** Keep the dialogue-only merge for recast/verify
outputs where adjacent pairs still occur, with regression coverage for the non-dialogue boundaries. outputs where adjacent pairs still occur, with regression coverage for the non-dialogue boundaries.
- [ ] **P1 / L–XL — Very high benefit — Rework segmentation from Run 3.** Inspect the earlier dialogue split/fusion - [x] **P1 / L–XL — Very high benefit — Rework segmentation from Run 3.** The initial-cast final normalization now
stages and correct the dominant unmatched-segment class using the measured Run 3 artifacts. merges narration only, preserving dialogue turn boundaries; Run 3 showed same-speaker dialogue joins were obscuring
upstream split/fusion errors. Recast/verify retains its separately tested dialogue-only merge.
- [ ] **P1 / M — High benefit — Re-run Book 1.** Re-measure the hand-corrected Book 1 reference after segmentation - [ ] **P1 / M — High benefit — Re-run Book 1.** Re-measure the hand-corrected Book 1 reference after segmentation
changes; do not treat a short or incomplete run as a valid result. changes; do not treat a short or incomplete run as a valid result.
- [ ] **P1 / L–XL — Very high benefit — Run Book 2 generalization.** Execute the backed-up Book 2 reference regression - [ ] **P1 / L–XL — Very high benefit — Run Book 2 generalization.** Execute the backed-up Book 2 reference regression
to measure whether segmentation and attribution rules generalize beyond Book 1. to measure whether segmentation and attribution rules generalize beyond Book 1.
- [ ] **P1 / L — Very high benefit — Unify visual identity.** Ensure portrait and concept-sheet generation use one - [x] **P1 / L — Very high benefit — Unify visual identity.** Portrait and concept-sheet generation now use one
canonical identity description, stored prompt version, and stale-asset review path. canonical identity description, stored prompt version, identity fingerprint, asset digests, and stale-asset review
path; shared-anchor and metadata persistence are covered by automated tests.
- [ ] **P2 / M–L — Medium–high benefit — Enrich character-form UX.** Add richer inline form editing and per-form - [ ] **P2 / M–L — Medium–high benefit — Enrich character-form UX.** Add richer inline form editing and per-form
bulk-generation controls while preserving one character ID and one voice. bulk-generation controls while preserving one character ID and one voice.

View File

@ -1 +1 @@
1.25.0 1.25.1

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.25.0"> <meta name="app-version" content="1.25.1">
<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,12 +27,12 @@
<!-- ── 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.25.0"> <link rel="stylesheet" href="/static/style.css?v=1.25.1">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.25.0"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.25.1">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.25.0"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.25.1">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.25.0"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.25.1">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.25.0"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.25.1">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.25.0"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.25.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -397,7 +397,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.25.0"></script> <script src="/static/loader.js?v=1.25.1"></script>
</body> </body>
</html> </html>

View File

@ -78,6 +78,27 @@ function _audiobookMergeAdjacentSameSpeaker(segments) {
return out; return out;
} }
// Initial casting must retain dialogue turn boundaries. Run 3 showed that
// same-speaker joins obscure upstream segmentation errors. Narration can
// still be compacted for paragraph flow; recast/verify uses the narrower
// dialogue-only merge below when that behavior is wanted.
function _audiobookMergeAdjacentNarration(segments) {
const out = [];
for (const s of segments) {
const last = out[out.length - 1];
const lastSpeaker = String(last?.speaker || 'Narrator').trim().toLowerCase();
const curSpeaker = String(s?.speaker || 'Narrator').trim().toLowerCase();
if (last && last.type === 'narration' && s?.type === 'narration'
&& last.page === s.page && lastSpeaker === 'narrator' && curSpeaker === 'narrator') {
const a = String(last.text || '').trimEnd(), b = String(s.text || '').trimStart();
last.text = /[.!?…»«”"’']$/.test(a) ? a + '\n\n' + b : a + ' ' + b;
continue;
}
out.push(Object.assign({}, s));
}
return out;
}
// Merge consecutive segments spoken by the same named character. This is a // Merge consecutive segments spoken by the same named character. This is a
// dialogue-only normalization pass; narration is intentionally left alone. // dialogue-only normalization pass; narration is intentionally left alone.
function _audiobookMergeAdjacentDialogue(segments) { function _audiobookMergeAdjacentDialogue(segments) {
@ -3491,6 +3512,7 @@ window.TTVC.audiobook.cast = {
toMarkdown: _abCastMarkdown, toMarkdown: _abCastMarkdown,
stampPages: _abStampSegmentPages, stampPages: _abStampSegmentPages,
mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker, mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker,
mergeAdjacentNarration: _audiobookMergeAdjacentNarration,
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue, mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
mergeSplitSentences: _audiobookMergeSplitSentences, mergeSplitSentences: _audiobookMergeSplitSentences,
dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates, dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates,
@ -3500,7 +3522,7 @@ window.TTVC.audiobook.cast = {
Object.assign(root, { Object.assign(root, {
_abCastMarkdown, _abStampSegmentPages, audiobookCastView, _abCastMarkdown, _abStampSegmentPages, audiobookCastView,
_audiobookMergeAdjacentSameSpeaker, _audiobookMergeAdjacentDialogue, _audiobookMergeAdjacentSameSpeaker, _audiobookMergeAdjacentNarration, _audiobookMergeAdjacentDialogue,
_audiobookMergeSplitSentences, _audiobookDedupNearbyDuplicates, _audiobookMergeSplitSentences, _audiobookDedupNearbyDuplicates,
_audiobookDropPhantomSpeakers, _audiobookConsolidateSpeakerAliases, _audiobookDropPhantomSpeakers, _audiobookConsolidateSpeakerAliases,
}); });

View File

@ -1000,7 +1000,10 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments); const _quoteFixedSegs = _audiobookFixOrphanedQuoteMarks(allSegments);
const { segments: _rejoined, merged: _mergedCount } = _audiobookMergeSplitSentences(_quoteFixedSegs); const { segments: _rejoined, merged: _mergedCount } = _audiobookMergeSplitSentences(_quoteFixedSegs);
if (_mergedCount) console.info('[cast] rejoined', _mergedCount, 'sentence(s) split by quoted terms'); if (_mergedCount) console.info('[cast] rejoined', _mergedCount, 'sentence(s) split by quoted terms');
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_rejoined); // Preserve dialogue boundaries during the initial cast. Recast/verify
// uses the narrower dialogue-only merge; initial casting should retain
// each returned turn so segmentation errors remain visible and fixable.
const _mergedSegs = _audiobookMergeAdjacentNarration(_rejoined);
allSegments.length = 0; allSegments.length = 0;
allSegments.push(..._mergedSegs); allSegments.push(..._mergedSegs);
{ const _ph = _audiobookDropPhantomSpeakers(allSegments); { const _ph = _audiobookDropPhantomSpeakers(allSegments);

View File

@ -101,3 +101,17 @@ test('pure leaf modules keep private declarations behind compatibility exports',
split: typeof window.TTVC?.audiobook?.segmentation?.splitByQuotes, split: typeof window.TTVC?.audiobook?.segmentation?.splitByQuotes,
}))).toEqual({ parse: 'function', compare: 'function', dialogue: 'function', split: 'function' }); }))).toEqual({ parse: 'function', compare: 'function', dialogue: 'function', split: 'function' });
}); });
test('initial cast normalization merges narration without fusing dialogue turns', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-reader'));
await page.waitForFunction(() => typeof window.TTVC?.audiobook?.cast?.mergeAdjacentNarration === 'function', null, { timeout: 15_000 });
const result = await page.evaluate(() => window.TTVC.audiobook.cast.mergeAdjacentNarration([
{ type: 'narration', speaker: 'Narrator', page: 1, text: 'A.' },
{ type: 'narration', speaker: 'Narrator', page: 1, text: 'B.' },
{ type: 'dialogue', speaker: 'Mira', page: 1, text: 'C.' },
{ type: 'dialogue', speaker: 'Mira', page: 1, text: 'D.' },
]));
expect(result.map(s => s.text)).toEqual(['A.\n\nB.', 'C.', 'D.']);
});