Consolidate speaker label variants after casting (v1.20.33)

One character is referred to by several labels across a book — bare vs full
name, a title on its own, a stray fragment — and each variant became its own
cast entry with its own voice, so the character audibly changed voice
mid-scene. Measured against a hand-corrected book this accounted for more of
the disagreements than genuine misattributions.

Variants are folded together only when one label's words are a strict subset
of another's after stripping articles and titles, so "Weber" and "Alter Weber"
stay separate and unrelated names never merge. Applied to a real cast:
Sharraz -> Sharraz Garthai, Baronin -> Baronin Ira von Seewiesen,
Von -> Oberst Alrik von Blautann (40 lines, 58 -> 54 speakers).

Also moves the quotation-mark toggle into the A- / A+ toolbar row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-12 13:34:40 +02:00
parent f837ba1a4f
commit cd5c325265
5 changed files with 83 additions and 10 deletions

View File

@ -22,6 +22,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Emotion instructions are now always written in English**, even for non-English voices (the spoken text and the native-accent clause stay in the book's own language). Confirmed by controlled A/B testing — same line, same voice, only the instruct language varying — that Qwen3-TTS follows English emotion instructions far more reliably: German instructs produced barely-differentiated output, while English instructs yield a clean, correctly-ordered prosodic gradient (whisper 128 Hz → sad 142 → neutral 179 → scared 203 → happy 225 → angry 269 Hz), with sensible duration changes too (sad slowest, scared fastest).
- **Fish-Speech generation parameters (`temperature` / `top_p` / `repetition_penalty`) were never forwarded.** Every Fish-Speech line synthesized at the server's fixed defaults, ignoring the app's per-backend stability settings — the only backend not routed through the shared `_apply_tts_extra_params` helper.
## [1.20.33] — 2026-08-12
### Added
- **Speaker label variants are consolidated after casting.** The caster refers to one character by several labels across a book — a bare name and a full one ("Sharraz" / "Sharraz Garthai"), a title alone ("Baronin" / "Baronin Ira von Seewiesen"), or a stray fragment ("Von" / "Oberst Alrik von Blautann"). Each variant became its own cast entry and therefore got its own voice, so a character audibly changed voice mid-scene — measured against a hand-corrected book, this accounted for more of the disagreements than genuine misattributions did. Variants are now folded together when one label's words are a strict subset of another's after stripping articles and titles, so "Weber" and "Alter Weber" stay separate and unrelated names never merge.
### Fixed
- Quotation-mark toggle moved into the A / A+ toolbar row, where it belongs.
## [1.20.31] — 2026-08-12
### Changed

View File

@ -1 +1 @@
1.20.31
1.20.33

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.31">
<meta name="app-version" content="1.20.33">
<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.31">
<link rel="stylesheet" href="/static/style.css?v=1.20.33">
<!-- ── 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.31"></script>
<script src="/static/loader.js?v=1.20.33"></script>
</body>
</html>

View File

@ -1061,6 +1061,62 @@ function _audiobookMergeAdjacentSameSpeaker(segments) {
// merges NARRATION neighbours, and only when the break is grammatically
// impossible (no sentence-ending punctuation before, lower-case continuation
// after), so a genuine narration→dialogue→narration sequence is never touched.
// ── Speaker identity consolidation ──────────────────────────────────────────
// The caster refers to one character by several labels across a book — a bare
// name and a full one ("Sharraz" / "Sharraz Garthai"), a title on its own
// ("Baronin" / "Baronin Ira von Seewiesen"), a stray fragment ("Von" /
// "Oberst Alrik von Blautann"). Each variant becomes its own cast entry and so
// gets its OWN VOICE, which is far more damaging in the finished audiobook than
// an unresolved line: the same person audibly changes voice mid-scene.
// Measured against a hand-corrected book, label fragmentation accounted for a
// large share of the disagreements — more than genuine misattributions.
// 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);
}
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;
function _audiobookMergeSplitSentences(segments) {
const out = [];
let merged = 0;
@ -1637,7 +1693,6 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:64%"></div></div>
</div>
</div>
<button class="btn-secondary ab-cv-quote-toggle" id="ab-cv-quotes" type="button" title="Show quotation marks" style="position:absolute; bottom:12px; right:16px; z-index:5; padding:8px 14px; border-radius:20px; box-shadow:0 2px 8px rgba(0,0,0,.18)"><span class="mdi mdi-format-quote-close"></span> <span class="ab-cv-quotes-label">Quotes</span></button>
<button class="ab-cv-jump-btn" id="ab-cv-jump-btn" hidden title="Jump to latest"><span class="mdi mdi-chevron-double-down"></span> Live</button>
</div>
<div class="ab-cv-side" id="ab-cv-side">
@ -2192,8 +2247,13 @@ STRIKTE FORMAT- UND TEXTREGELN:
_abFontCtl.className = 'ab-font-ctl';
_abFontCtl.innerHTML = `
<button class="ab-page-nav-btn ab-font-dec" type="button" title="Decrease text size">A</button>
<button class="ab-page-nav-btn ab-font-inc" type="button" title="Increase text size">A+</button>`;
<button class="ab-page-nav-btn ab-font-inc" type="button" title="Increase text size">A+</button>
<button class="ab-page-nav-btn ab-cv-quote-toggle" id="ab-cv-quotes" type="button" title="Show quotation marks"><span class="mdi mdi-format-quote-close"></span></button>`;
_abTopbar.appendChild(_abFontCtl);
{ const _qb = _abFontCtl.querySelector('#ab-cv-quotes');
if (_qb) { _qb.addEventListener('click', () => audiobookToggleQuotes());
_qb.classList.toggle('is-on', _abShowQuotes);
_qb.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; } }
const _abApplyFont = () => {
let v = 1;
try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {}
@ -5283,6 +5343,11 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
const _mergedSegs = _audiobookMergeAdjacentSameSpeaker(_rejoined);
allSegments.length = 0;
allSegments.push(..._mergedSegs);
{ const _ident = _audiobookConsolidateSpeakerAliases(allSegments);
if (_ident.merged) {
console.info('[cast] consolidated', _ident.merged, 'speaker label variant(s),', _ident.moved, 'line(s)');
try { toast(`Merged ${_ident.merged} duplicate character label${_ident.merged !== 1 ? 's' : ''} (${_ident.moved} lines)`, 'success'); } catch (_) {}
} }
_audiobook.segments = allSegments;
_audiobook.lastText = text;
_audiobook.roster = roster;