Reject phantom speakers invented from German noun phrases (v1.20.35)

The caster emits fragments of noun phrases as characters — "in seinem Inneren"
-> Inneren, "die Hand" -> Hand, "mein Geliebter" -> Geliebten, "der Wind" ->
Wind. Each became a cast entry with its own voice while the real speaker's line
was lost to a phantom; measured against a hand-corrected book this was a large
part of why a fresh cast produced 73 speakers where the human had 62.

Such lines are now reset to Unknown — visibly unresolved and still recoverable
by the repair passes, rather than silently wrong. Applied to a real cast:
5 phantoms removed, 73 -> 68 speakers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-12 16:52:27 +02:00
parent 4d3e4f8e7c
commit 35cb21ad70
4 changed files with 41 additions and 7 deletions

View File

@ -1 +1 @@
1.20.34
1.20.35

File diff suppressed because one or more lines are too long

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.20.34">
<meta name="app-version" content="1.20.35">
<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,7 +27,7 @@
<!-- ── 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.20.34">
<link rel="stylesheet" href="/static/style.css?v=1.20.35">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.20.34"></script>
<script src="/static/loader.js?v=1.20.35"></script>
</body>
</html>

View File

@ -1079,6 +1079,36 @@ function _abIdentityTokens(name) {
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
// gets a cast entry and its own voice, and the real speaker's line is lost to a
// phantom — measured against a hand-corrected book, they were a large part of
// why a fresh cast produced 73 speakers where the human had 62. A line credited
// to one of these is better left Unknown: visibly unresolved, and still
// recoverable by the repair passes, instead of silently wrong.
const _AB_NOT_A_CHARACTER = new Set([
'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) {
@ -4790,6 +4820,8 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
// label variants the cast-time consolidation just removed — confirmed live:
// a clean cast gained "Alrik" alongside "Alrik von Blautann" during
// auto-repair, which would have split one character across two voices again.
{ const _ph = _audiobookDropPhantomSpeakers(segs);
if (_ph.dropped) console.info('[recast] dropped', _ph.dropped, 'phantom speaker line(s):', _ph.names.join(', ')); }
{ const _ident = _audiobookConsolidateSpeakerAliases(segs);
if (_ident.merged) {
console.info('[recast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');
@ -5352,6 +5384,8 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_rejoined);
allSegments.length = 0;
allSegments.push(..._mergedSegs);
{ const _ph = _audiobookDropPhantomSpeakers(allSegments);
if (_ph.dropped) console.info('[cast] dropped', _ph.dropped, 'phantom speaker line(s):', _ph.names.join(', ')); }
{ const _ident = _audiobookConsolidateSpeakerAliases(allSegments);
if (_ident.merged) {
console.info('[cast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');