refactor: extract audiobook speaker normalization
This commit is contained in:
parent
0d1299b19c
commit
7c14f41cb9
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user