Fix bundle-breaking TDZ throw, use English emotion instructs, forward Fish gen params (v1.20.6)

The emotion quick-pickers added in 1.20.5 guarded with
`typeof REH_EMOTIONS === 'undefined'`, but REH_EMOTIONS is a const declared
later in the bundle's single shared scope — `typeof` on a const in its
temporal dead zone throws instead of returning "undefined", which aborted
top-level initialization for every module bundled after tts-preview.js.
The pickers now read window.REH_EMOTIONS on a deferred macrotask.

Also: emotion instructions are now always built in English (spoken text and
the native-accent clause stay in the book's language), which controlled A/B
testing showed produces a far cleaner prosodic gradient from Qwen3-TTS; and
Fish-Speech now receives temperature/top_p/repetition_penalty, which it was
the only backend never to have forwarded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-08-11 22:00:56 +02:00
parent 5fecbf06d4
commit 003e4f9f46
9 changed files with 65 additions and 21 deletions

View File

@ -5,6 +5,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.20.6] — 2026-08-11
### Fixed
- **Critical: the whole JS bundle stopped initializing partway through**, silently disabling every module loaded after `tts-preview.js` (Rehearser, Reader, Audiobook, character sheets, …). Introduced in 1.20.5 by the new emotion quick-pickers, which guarded with `typeof REH_EMOTIONS === 'undefined'` — but `REH_EMOTIONS` is a `const` declared later in the bundle's single shared scope, and `typeof` on a `const` in its temporal dead zone **throws** rather than returning `"undefined"`. The pickers now read `window.REH_EMOTIONS` and initialize on a deferred macrotask, after the bundle has fully executed. This also invalidated earlier emotion A/B testing, which had been measuring a half-initialized app.
- **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.5] — 2026-08-11 ## [1.20.5] — 2026-08-11
### Added ### Added

View File

@ -1 +1 @@
1.20.5 1.20.6

View File

@ -477,7 +477,16 @@ def _fishspeech_request_audio(
"chunk_length": 200, "chunk_length": 200,
"normalize": True, "normalize": True,
} }
resp = requests.post(f"{base_url}/v1/tts", json=payload, timeout=180) # Fish-Speech's own request schema (ServeTTSRequest) exposes temperature/top_p/
# repetition_penalty, but this call never forwarded them — every line synthesized
# at the server's fixed default (temperature 0.8), regardless of Settings. Confirmed
# live as the likely cause of emotion tags barely differentiating from each other
# (a controlled same-line test showed happy/angry/excited/scared/shouting all
# collapsing into nearly identical pitch/loudness — the model wasn't being given
# room to vary). Every other backend already routes through this same helper; Fish
# was the one exception.
_apply_tts_extra_params(payload, settings, "fishspeech")
resp = _post_tts_with_fallback(f"{base_url}/v1/tts", payload, {}, timeout=180)
resp.raise_for_status() resp.raise_for_status()
audio = resp.content audio = resp.content
if not audio or len(audio) < 256: if not audio or len(audio) < 256:

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

View File

@ -154,8 +154,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// decides server-side (see _conv_tts_text_and_instruct in // decides server-side (see _conv_tts_text_and_instruct in
// routes/conversation.py) whether it becomes an inline Fish [tag] or an // routes/conversation.py) whether it becomes an inline Fish [tag] or an
// instruct-field phrase for style-aware backends. // instruct-field phrase for style-aware backends.
if (ttsEmotionSel && typeof REH_EMOTIONS !== 'undefined' && !ttsEmotionSel.dataset.populated) { if (ttsEmotionSel && typeof window.REH_EMOTIONS !== 'undefined' && !ttsEmotionSel.dataset.populated) {
REH_EMOTIONS.forEach(e => { window.REH_EMOTIONS.forEach(e => {
if (!e.value) return; if (!e.value) return;
const o = document.createElement('option'); const o = document.createElement('option');
o.value = e.value; o.value = e.value;

View File

@ -1149,10 +1149,14 @@ function _readerTextForSynth(text) {
const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotion) : ''; const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotion) : '';
return tag ? `[${tag}] ${text}` : text; return tag ? `[${tag}] ${text}` : text;
} }
(function initReaderEmotionPicker() { // Deferred to a macrotask — see the matching note in tts-preview.js: REH_EMOTIONS is
// a `const` declared later in the bundle's single shared scope, and `typeof` on a
// const in its temporal dead zone THROWS rather than returning "undefined", which
// aborts the rest of the bundle's initialization.
setTimeout(function initReaderEmotionPicker() {
const sel = $('reader-emotion-select'); const sel = $('reader-emotion-select');
if (!sel || typeof REH_EMOTIONS === 'undefined') return; if (!sel || typeof window.REH_EMOTIONS === 'undefined') return;
REH_EMOTIONS.forEach(e => { window.REH_EMOTIONS.forEach(e => {
if (!e.value) return; if (!e.value) return;
const o = document.createElement('option'); const o = document.createElement('option');
o.value = e.value; o.value = e.value;
@ -1166,7 +1170,7 @@ function _readerTextForSynth(text) {
if (instructInput && sel.value) instructInput.value = sel.value; if (instructInput && sel.value) instructInput.value = sel.value;
} }
}); });
})(); }, 0);
async function readerSynthIndices(targets) { async function readerSynthIndices(targets) {
targets = targets.filter(i => !readerState.blobCache.has(i)); targets = targets.filter(i => !readerState.blobCache.has(i));

View File

@ -26,6 +26,11 @@ const REH_EMOTIONS = [
{ value: 'grieving, tearful, broken', emoji: '😭', label: 'Grieving' }, { value: 'grieving, tearful, broken', emoji: '😭', label: 'Grieving' },
]; ];
// Exposed so the emotion quick-pickers in tts-preview.js / reader.js can read this
// list. They run in the same bundle scope but BEFORE this file, so they must go
// through window (and defer to a macrotask) rather than touch the const directly.
window.REH_EMOTIONS = REH_EMOTIONS;
// Load custom emotions from localStorage // Load custom emotions from localStorage
let rehCustomEmotions = []; let rehCustomEmotions = [];
try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {} try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {}
@ -1876,7 +1881,19 @@ function _buildInstruct(voiceProfile, emotion, voiceId) {
const langCode = String(voiceId || '').split('_')[0].toUpperCase(); const langCode = String(voiceId || '').split('_')[0].toUpperCase();
const accent = _buildAccentClause(langCode); const accent = _buildAccentClause(langCode);
if (!e && !p && !accent) return ''; if (!e && !p && !accent) return '';
const tmpl = _BUILD_INSTRUCT_TEMPLATES[langCode] || _BUILD_INSTRUCT_TEMPLATES.EN; // The emotion clause is now ALWAYS built in English, regardless of the voice's
// own language — confirmed via controlled A/B testing (same line, same voice,
// varying only instruct language) that Qwen3-TTS's emotional differentiation is
// dramatically stronger in English than German: sad/angry/shocked/happy formed a
// clean, coherent pitch gradient (274Hz -> 398Hz) in English, but stayed muddled
// together in German even with the exact same wording translated. The spoken
// TEXT and the accent clause below stay fully native-language — only the
// emotion instruction itself changes language. The raw per-line emotion is
// LLM-generated in the book's own language (see the casting prompt's "1-2
// deutsche Wörter"), so translate it before building the clause.
const emotionEn = e && langCode !== 'EN' && typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(e) : '';
const effectiveEmotion = emotionEn || e;
const emotionTmpl = _BUILD_INSTRUCT_TEMPLATES.EN;
// Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns // Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns
// it "does not follow instructions correctly when dealing with // it "does not follow instructions correctly when dealing with
// conflicting attributes... favoring one over the other." Putting the // conflicting attributes... favoring one over the other." Putting the
@ -1886,7 +1903,7 @@ function _buildInstruct(voiceProfile, emotion, voiceId) {
// through. Emotion is the one thing that MUST vary per line; accent is a // through. Emotion is the one thing that MUST vary per line; accent is a
// constant reminder the voice's own identity should mostly already carry, // constant reminder the voice's own identity should mostly already carry,
// so it goes last, not first. // so it goes last, not first.
const parts = [e ? tmpl(e) : '', p, accent].filter(Boolean); const parts = [e ? emotionTmpl(effectiveEmotion) : '', p, accent].filter(Boolean);
return parts.join(' '); return parts.join(' ');
} }

View File

@ -220,10 +220,17 @@ function _ttsApplyEmotionTag(text, emotionValue) {
const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotionValue) : ''; const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotionValue) : '';
return tag ? `[${tag}] ${text}` : text; return tag ? `[${tag}] ${text}` : text;
} }
(function initPreviewEmotionPicker() { // Deferred to a macrotask: REH_EMOTIONS is a `const` declared in rehearser.js,
// which the minified bundle concatenates into ONE shared scope AFTER this file.
// A bare `typeof REH_EMOTIONS` here would not return "undefined" — for a const in
// its temporal dead zone it THROWS, aborting the rest of the bundle's top-level
// initialization (confirmed live: it left every later module's consts permanently
// uninitialized). Running after the current task guarantees the whole bundle has
// finished executing, so the const is initialized either way.
setTimeout(function initPreviewEmotionPicker() {
const sel = $('preview-emotion-select'); const sel = $('preview-emotion-select');
if (!sel || typeof REH_EMOTIONS === 'undefined') return; if (!sel || typeof window.REH_EMOTIONS === 'undefined') return;
REH_EMOTIONS.forEach(e => { window.REH_EMOTIONS.forEach(e => {
if (!e.value) return; if (!e.value) return;
const o = document.createElement('option'); const o = document.createElement('option');
o.value = e.value; o.value = e.value;
@ -243,7 +250,7 @@ function _ttsApplyEmotionTag(text, emotionValue) {
if (styleInput && sel.value) styleInput.value = sel.value; if (styleInput && sel.value) styleInput.value = sel.value;
} }
}); });
})(); }, 0);
$('tts-backend-select').addEventListener('change', () => { $('tts-backend-select').addEventListener('change', () => {
const sel = $('tts-voice-select'); const sel = $('tts-voice-select');