refactor: extract audiobook speaker normalization

This commit is contained in:
mARTin-B78 2026-09-04 11:32:20 +02:00
parent 0d1299b19c
commit 7c14f41cb9
6 changed files with 77 additions and 75 deletions

View File

@ -10,7 +10,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, split-sentence repair, and nearby-duplicate removal,
keeping deterministic segment cleanup separate from live casting orchestration.
phantom-speaker filtering, and speaker-alias consolidation, 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.41
1.24.42

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 speaker/sentence/duplicate normalization helpers; live casting remains in `audiobook.js`.
- ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, and deterministic speaker/sentence/duplicate/alias 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.41">
<meta name="app-version" content="1.24.42">
<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.41">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.41">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.41">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.41">
<link rel="stylesheet" href="/static/style.css?v=1.24.42">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.42">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.42">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.42">
<!-- ── 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.41"></script>
<script src="/static/loader.js?v=1.24.42"></script>
</body>
</html>

View File

@ -144,6 +144,71 @@ function _audiobookDedupNearbyDuplicates(segments) {
return { segments: out, removed };
}
const _AB_TITLE_PREFIX = /^(?:der|die|das|den|dem|ein|eine|einer|herr|frau|oberst|obrist|graf|gräfin|ritter|hauptmann|kommandant|meister|bruder|schwester|prinz|prinzessin|könig|königin|alter|alte|junger|junge|blonder|blonde)\s+/i;
function _abIdentityTokens(name) {
let n = String(name || '').trim().toLowerCase(), prev = null;
while (prev !== n) { prev = n; n = n.replace(_AB_TITLE_PREFIX, ''); }
return n.split(/[\s\-]+/).filter(Boolean);
}
const _AB_NOT_A_CHARACTER = new Set([
'hinter','vor','neben','über','unter','zwischen','gegenüber','keiner','keine','niemand','jemand','alle','beide',
'einige','viele','andere','anderen','jeder','jede','man','von','zu','bei','nach','aus','mit','ihm','ihr','ihnen',
'dort','hier','draußen','drinnen','oben','unten','plötzlich','endlich','schließlich',
'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen',
'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten',
'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde',
'himmel','sonne','mond','tod','leben','seele','geist der','wahrheit','zweifel','hoffnung','erinnerung','vergangenheit'
]);
function _abIsImplausibleSpeaker(name) {
const n = String(name || '').trim().toLowerCase().replace(/^(?:der|die|das|den|dem|ein|eine|einer|einem)\s+/, '');
return Boolean(n) && _AB_NOT_A_CHARACTER.has(n);
}
function _audiobookDropPhantomSpeakers(segments) {
let dropped = 0;
const names = new Set();
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
if (_abIsImplausibleSpeaker(s.speaker)) { names.add(s.speaker); s.speaker = 'Unknown'; dropped++; }
}
return { dropped, names: [...names] };
}
function _audiobookConsolidateSpeakerAliases(segments) {
const count = new Map();
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
const sp = String(s.speaker || '').trim();
if (!sp || /^(unknown|unbekannt|narrator)/i.test(sp)) continue;
count.set(sp, (count.get(sp) || 0) + 1);
}
const names = [...count.keys()];
const tokens = new Map(names.map(n => [n, new Set(_abIdentityTokens(n))]));
const mapping = new Map();
for (const a of names) {
const ta = tokens.get(a);
if (!ta.size) continue;
let best = null;
for (const b of names) {
if (a === b) continue;
const tb = tokens.get(b);
if (!tb.size || tb.size <= ta.size) continue;
let subset = true;
for (const t of ta) if (!tb.has(t)) { subset = false; break; }
if (!subset) continue;
if (!best || count.get(b) > count.get(best) || (count.get(b) === count.get(best) && b.length > best.length)) best = b;
}
if (best) mapping.set(a, best);
}
let moved = 0;
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
const to = mapping.get(String(s.speaker || '').trim());
if (to) { s.speaker = to; moved++; }
}
return { merged: mapping.size, moved, mapping };
}
window.TTVC = window.TTVC || {};
window.TTVC.audiobook = window.TTVC.audiobook || {};
window.TTVC.audiobook.cast = {
@ -153,4 +218,6 @@ window.TTVC.audiobook.cast = {
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
mergeSplitSentences: _audiobookMergeSplitSentences,
dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates,
dropPhantomSpeakers: _audiobookDropPhantomSpeakers,
consolidateSpeakerAliases: _audiobookConsolidateSpeakerAliases,
};

View File

@ -897,12 +897,6 @@ function _audiobookFixOrphanedQuoteMarks(segments) {
// A variant is only folded into another when its words are a strict SUBSET of
// the other's after stripping articles and titles, so "Weber" and "Alter Weber"
// (equal after stripping) stay separate, and two unrelated names never merge.
const _AB_TITLE_PREFIX = /^(?:der|die|das|den|dem|ein|eine|einer|herr|frau|oberst|obrist|graf|gräfin|ritter|hauptmann|kommandant|meister|bruder|schwester|prinz|prinzessin|könig|königin|alter|alte|junger|junge|blonder|blonde)\s+/i;
function _abIdentityTokens(name) {
let n = String(name || '').trim().toLowerCase(), prev = null;
while (prev !== n) { prev = n; n = n.replace(_AB_TITLE_PREFIX, ''); }
return n.split(/[\s\-]+/).filter(Boolean);
}
// Speaker labels that are really fragments of a German noun phrase, not people:
// "in seinem Inneren" -> Inneren, "die Hand" -> Hand, "mein Geliebter" ->
// Geliebten, "der Wind" -> Wind. The caster emits these as characters, each then
@ -916,67 +910,7 @@ function _abIdentityTokens(name) {
// "Keiner sagte mehr etwas." -> Keiner, a dangling "Von" from "von Blautann".
// Confirmed by a reader hand-correcting a whole book: every one of these was
// deleted as a bogus character.
const _AB_NOT_A_CHARACTER = new Set([
'hinter','vor','neben','über','unter','zwischen','gegenüber','keiner','keine','niemand','jemand','alle','beide',
'einige','viele','andere','anderen','jeder','jede','man','von','zu','bei','nach','aus','mit','ihm','ihr','ihnen',
'dort','hier','draußen','drinnen','oben','unten','plötzlich','endlich','schließlich',
'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen',
'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten',
'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde',
'himmel','sonne','mond','tod','leben','seele','geist der','wahrheit','zweifel','hoffnung','erinnerung','vergangenheit'
]);
function _abIsImplausibleSpeaker(name) {
const n = String(name || '').trim().toLowerCase().replace(/^(?:der|die|das|den|dem|ein|eine|einer|einem)\s+/, '');
if (!n) return false;
return _AB_NOT_A_CHARACTER.has(n);
}
function _audiobookDropPhantomSpeakers(segments) {
let dropped = 0;
const names = new Set();
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
if (_abIsImplausibleSpeaker(s.speaker)) { names.add(s.speaker); s.speaker = 'Unknown'; dropped++; }
}
return { dropped, names: [...names] };
}
window._audiobookDropPhantomSpeakers = _audiobookDropPhantomSpeakers;
function _audiobookConsolidateSpeakerAliases(segments) {
const count = new Map();
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
const sp = String(s.speaker || '').trim();
if (!sp || /^(unknown|unbekannt|narrator)/i.test(sp)) continue;
count.set(sp, (count.get(sp) || 0) + 1);
}
const names = [...count.keys()];
const tokens = new Map(names.map(n => [n, new Set(_abIdentityTokens(n))]));
const mapping = new Map();
for (const a of names) {
const ta = tokens.get(a);
if (!ta.size) continue;
let best = null;
for (const b of names) {
if (a === b) continue;
const tb = tokens.get(b);
if (!tb.size || tb.size <= ta.size) continue;
let subset = true;
for (const t of ta) if (!tb.has(t)) { subset = false; break; }
if (!subset) continue; // strict subset only
if (!best || count.get(b) > count.get(best) || (count.get(b) === count.get(best) && b.length > best.length)) best = b;
}
if (best) mapping.set(a, best);
}
let moved = 0;
if (mapping.size) {
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
const to = mapping.get(String(s.speaker || '').trim());
if (to) { s.speaker = to; moved++; }
}
}
return { merged: mapping.size, moved, mapping };
}
window._audiobookConsolidateSpeakerAliases = _audiobookConsolidateSpeakerAliases;
// A bare one-word "dialogue" piece with no sentence-ending punctuation is