Extend German inquit attribution: missing verbs, subject choice, mistyped inquits (v1.20.24)

Driven by unresolved lines in a real German novel:

- AB_SPEECH_VERBS was missing many common inquit verbs (grunzte, versetzte,
  unterbrach, drängte, herrschte, keuchte, erklärte, widersprach, warf ein …)
  and had no thought verbs at all, so inner monologue ("dachte Lysandra") was
  never attributed.
- A trailing inquit naming two people ("unterbrach die blonde Perdia Lysandra")
  resolved to the wrong one; the grammatical subject speaks, so articles and
  adjectives are skipped and the first name after the verb wins.
- A quote whose own inquit had been mistyped as dialogue could never resolve,
  because the rule required the following segment to be narration. A segment
  opening with a lower-case speech verb is an inquit whatever its type; it is
  now accepted and its type corrected.

Verified on the book: resolves further lines with no LLM call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-12 10:41:06 +02:00
parent 3434293375
commit d3b162f81f
5 changed files with 24 additions and 8 deletions

View File

@ -22,6 +22,13 @@ 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.24] — 2026-08-12
### Fixed
- **Many German inquit verbs were missing from speaker attribution**, so ordinary lines stayed Unknown: `grunzte`, `versetzte`, `unterbrach`, `drängte`, `herrschte`, `keuchte`, `hauchte`, `stöhnte`, `spottete`, `höhnte`, `erklärte`, `widersprach`, `warf ein`, `setzte hinzu` and others. Also adds the thought verbs `dachte` / `überlegte` / `sinnierte`, since German inner monologue is punctuated like speech and belongs to the character thinking it.
- **Trailing inquits naming two people picked the wrong one.** ", unterbrach die blonde Perdia Lysandra." assigned the line to whoever matched last; the grammatical subject is the speaker, so an optional article and adjectives are now skipped and the first name after the verb wins (→ Perdia, not Lysandra).
- **A quote stayed Unknown when its own inquit was mistyped as dialogue.** A segment opening with a lower-case speech verb ("grunzte der Ork und hob den Dolch.") is an inquit by construction, whatever type the caster gave it. Those are now recognised regardless of type, and the inquit segment is corrected back to narration.
## [1.20.22] — 2026-08-12 ## [1.20.22] — 2026-08-12
### Fixed ### Fixed

View File

@ -1 +1 @@
1.20.22 1.20.24

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

View File

@ -677,7 +677,7 @@ function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); } function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down. // Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)'; const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|grunzte|versetzte|unterbrach|drängte|herrschte|keuchte|hauchte|ächzte|stöhnte|jammerte|spottete|höhnte|warf\\s+ein|setzte\\s+hinzu|erklärte|verkündete|widersprach|beharrte|gestand|bestätigte|erinnerte|dachte|überlegte|sinnierte|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
const AB_NOTNAME = new Set([ const AB_NOTNAME = new Set([
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man', 'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now', 'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
@ -799,7 +799,13 @@ function audiobookResolveUnknowns(segs, prevTail, roster) {
if (m2 && !AB_NOTNAME.has(m2[1])) who = m2[1]; if (m2 && !AB_NOTNAME.has(m2[1])) who = m2[1];
} }
} }
if (!who && next?.type === 'narration') { // A segment that OPENS with a lower-case speech verb is an inquit by
// construction ("grunzte der Ork und hob den Dolch."), even when the caster
// mistyped it as dialogue — confirmed live: the quote before such a segment
// stayed Unknown purely because the inquit carried the wrong type. Accept
// either type here and repair the type while we are at it.
const _nextIsInquitText = next && new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\b').test(String(next.text || '').trim());
if (!who && (next?.type === 'narration' || _nextIsInquitText)) {
const nt = String(next.text || '').trim(); const nt = String(next.text || '').trim();
// Split quote whose inquit uses a PRONOUN rather than a name: // Split quote whose inquit uses a PRONOUN rather than a name:
// "»Ja, ungewöhnlich«" + ", antwortete er knapp." The rules below all // "»Ja, ungewöhnlich«" + ", antwortete er knapp." The rules below all
@ -812,7 +818,7 @@ function audiobookResolveUnknowns(segs, prevTail, roster) {
const byPronoun = lastNameIn(String(prev.text || '')); const byPronoun = lastNameIn(String(prev.text || ''));
if (byPronoun) who = byPronoun; if (byPronoun) who = byPronoun;
} }
let m = who ? null : nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME)); let m = who ? null : nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:(?:der|die|das|den|dem)\\s+)?(?:[a-zäöüß]+e[nrms]?\\s+){0,2}' + _AB_NAME));
if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte')); if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte'));
// "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula, // "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula,
// the next sentence's subject is the speaker. // the next sentence's subject is the speaker.
@ -836,6 +842,9 @@ function audiobookResolveUnknowns(segs, prevTail, roster) {
} }
if (prevDialogues.length === 2 && prevDialogues[0] !== prevDialogues[1]) who = prevDialogues[1]; if (prevDialogues.length === 2 && prevDialogues[0] !== prevDialogues[1]) who = prevDialogues[1];
} }
if (who && _nextIsInquitText && next && next.type !== 'narration') {
next.type = 'narration'; next.speaker = 'Narrator'; next.emotion = '';
}
if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) { if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) {
s.speaker = who; s.speaker = who;
resolved.push(who); resolved.push(who);