Never merge adjacent Unknown dialogue lines into one segment (v1.20.27)

Adjacent segments merge when they share a speaker, but an unattributed line
carries the speaker "Unknown", so two consecutive Unknown lines compared as
equal and were fused into a single block joined by a blank line. Confirmed
against the source text: Lysandra's line and Perdia's interruption — two
separate paragraphs in the book — became one segment, after which no
attribution pass could separate them and one voice would read both sides of
the exchange.

"Unknown" means the speaker is not known, never that two lines share one.
Narration and lines with a real speaker name merge exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-12 10:46:55 +02:00
parent 14ed0c89e7
commit ca654608ac
5 changed files with 19 additions and 6 deletions

View File

@ -22,6 +22,11 @@ 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). - **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. - **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.27] — 2026-08-12
### Fixed
- **Two different characters' lines were being permanently fused into one segment.** Adjacent segments are merged when they share a speaker, but an unattributed line carries the speaker "Unknown" — so two consecutive Unknown lines compared as "the same speaker" and were joined into a single block. Confirmed against the source text: Lysandra's line and Perdia's interruption, two separate paragraphs in the book, became one segment, after which no attribution pass could separate them and a single voice would have read both sides of the exchange. "Unknown" means the speaker is *not known*, never that two lines share one, so Unknown dialogue lines are no longer merged. Narration and lines with a real speaker name merge exactly as before.
## [1.20.26] — 2026-08-12 ## [1.20.26] — 2026-08-12
### Fixed ### Fixed

View File

@ -1 +1 @@
1.20.26 1.20.27

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="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.20.26"> <meta name="app-version" content="1.20.27">
<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,7 +27,7 @@
<!-- ── 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.20.26"> <link rel="stylesheet" href="/static/style.css?v=1.20.27">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -378,7 +378,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.20.26"></script> <script src="/static/loader.js?v=1.20.27"></script>
</body> </body>
</html> </html>

View File

@ -1020,7 +1020,15 @@ function _audiobookMergeAdjacentSameSpeaker(segments) {
// Dialogue keeps its emotion tag distinct — merging "happy Marcus" text // Dialogue keeps its emotion tag distinct — merging "happy Marcus" text
// into "angry Marcus" text would silently discard one of the two moods. // into "angry Marcus" text would silently discard one of the two moods.
const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase(); const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase();
if (lastSpeaker === curSpeaker && sameEmotion) { // Two adjacent UNKNOWN lines are not evidence of one speaker — "Unknown"
// means precisely that we do not know who spoke. Both normalise to the
// same string here, so they compared as equal and were fused into a single
// block: confirmed live, Lysandra's line and Perdia's interruption (two
// separate paragraphs in the book) became one segment joined by a blank
// line, after which no attribution pass could ever separate them again and
// one voice would read both sides of the exchange. Never merge on Unknown.
const unknownSpeaker = !lastSpeaker || lastSpeaker === 'unknown' || lastSpeaker === 'unbekannt';
if (lastSpeaker === curSpeaker && sameEmotion && !(s.type === 'dialogue' && unknownSpeaker)) {
const lastText = last.text.trimEnd(), curText = s.text.trimStart(); const lastText = last.text.trimEnd(), curText = s.text.trimStart();
last.text = /[.!?…»«”"']$/.test(lastText) ? lastText + '\n\n' + curText : lastText + ' ' + curText; last.text = /[.!?…»«”"']$/.test(lastText) ? lastText + '\n\n' + curText : lastText + ' ' + curText;
continue; continue;