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
|
### Changed
|
||||||
- **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in
|
- **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,
|
`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
|
- **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
|
||||||
|
|||||||
@ -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, 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/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.
|
||||||
|
|||||||
@ -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.41">
|
<meta name="app-version" content="1.24.42">
|
||||||
<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.41">
|
<link rel="stylesheet" href="/static/style.css?v=1.24.42">
|
||||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.41">
|
<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.41">
|
<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.41">
|
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.42">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── 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.41"></script>
|
<script src="/static/loader.js?v=1.24.42"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -144,6 +144,71 @@ function _audiobookDedupNearbyDuplicates(segments) {
|
|||||||
return { segments: out, removed };
|
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 = window.TTVC || {};
|
||||||
window.TTVC.audiobook = window.TTVC.audiobook || {};
|
window.TTVC.audiobook = window.TTVC.audiobook || {};
|
||||||
window.TTVC.audiobook.cast = {
|
window.TTVC.audiobook.cast = {
|
||||||
@ -153,4 +218,6 @@ window.TTVC.audiobook.cast = {
|
|||||||
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
|
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
|
||||||
mergeSplitSentences: _audiobookMergeSplitSentences,
|
mergeSplitSentences: _audiobookMergeSplitSentences,
|
||||||
dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates,
|
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
|
// 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"
|
// the other's after stripping articles and titles, so "Weber" and "Alter Weber"
|
||||||
// (equal after stripping) stay separate, and two unrelated names never merge.
|
// (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:
|
// Speaker labels that are really fragments of a German noun phrase, not people:
|
||||||
// "in seinem Inneren" -> Inneren, "die Hand" -> Hand, "mein Geliebter" ->
|
// "in seinem Inneren" -> Inneren, "die Hand" -> Hand, "mein Geliebter" ->
|
||||||
// Geliebten, "der Wind" -> Wind. The caster emits these as characters, each then
|
// 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".
|
// "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
|
// Confirmed by a reader hand-correcting a whole book: every one of these was
|
||||||
// deleted as a bogus character.
|
// 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;
|
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;
|
window._audiobookConsolidateSpeakerAliases = _audiobookConsolidateSpeakerAliases;
|
||||||
|
|
||||||
// A bare one-word "dialogue" piece with no sentence-ending punctuation is
|
// A bare one-word "dialogue" piece with no sentence-ending punctuation is
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user