From e814b5548df7dc290a12419226da15d0147f89df Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sun, 28 Jun 2026 16:15:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(tts):=20Ger=C3=A4te-TTS-Regression=20f?= =?UTF-8?q?=C3=BCr=20Sprachen=20ohne=20Geschlechtsstimme=20beheben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 5c63f7b hatte deviceGenderReady als harte Bedingung eingeführt: das Gerät musste eine Stimme des WAHLgeschlechts haben, sonst Server-Fallback. Die konservative Geschlechts-Heuristik liefert für viele Geräte-Stimmen null (z.B. NL am iPhone: nur eine Stimme ohne erkennbares Geschlecht) -> der Server-Fallback griff und lieferte eine schlechte/fremdsprachige Aussprache. Für NL kam (im noch nicht neu gestarteten Prozess) die deutsche Default- Stimme -> "deutscher Akzent". Lösung: deviceGenderReady -> deviceVoiceReady. Eine sprachrichtige Stimme reicht (pickVoice wählt cands[0] als Best-Effort-Fallback). Die m/f-Wahl wirkt am Gerät nur für Sprachen mit mehreren Stimmen (DE, EN), sonst folgenlos — aber eine falsche Sprache ist schlechter als ein folgenloser Schalter. + Test: NL in test_piper_single_voice_languages_ignore_gender aufgenommen (serverseitige Komplementär-Logik; sichert voice_for_route NL-Auflösung). Der client-seitige app.js-Fix ist mangels JS-Test-Framework manuell verifiziert (Geräte-TTS für NL spricht niederländisch, DE/EN behalten Geschlechtswahl). Co-Authored-By: Claude --- app/web/app.js | 19 +++++++++++-------- tests/test_routing.py | 5 +++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/web/app.js b/app/web/app.js index ecb1b3a..45ae652 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -147,12 +147,15 @@ function deviceVoicesReady() { return _voices.length > 0; } -// Hat das Gerät eine zum Wunschgeschlecht passende Stimme für diese Sprache? Wenn nicht, -// soll auf Server-TTS ausgewichen werden (dort gibt es echte m/f-Stimmen), damit die -// Geschlechtswahl hörbar wirkt, statt am Gerät folgenlos zu bleiben. -function deviceGenderReady(lang) { +// Hat das Gerät eine Stimme für diese Sprache? Geschlecht ist Best-Effort: pickVoice wählt +// bei Treffer die geschlechtspassende, sonst die erste sprachrichtige Stimme (cands[0]). +// Früher wurde bei fehlendem Geschlechtsmatch hart auf Server-TTS ausgewichen — das +// verbannte Sprachen mit nur einer Geräte-Stimme (z.B. NL) auf eine schlechte Server- +// Aussprache. Jetzt gilt: eine sprachrichtige Stimme reicht; die m/f-Wahl wirkt am Gerät +// nur für Sprachen mit mehreren Stimmen (DE, EN …), sonst folgenlos. +function deviceVoiceReady(lang) { const bcp = LANG_BCP47[lang] || lang || "de-DE"; - return pickVoice(bcp, _voiceGender).genderMatch; + return pickVoice(bcp, _voiceGender).voice != null; } // Server-Fallback: holt Audio via /api/speak (ohne tts_provider -> Server-Default, @@ -176,18 +179,18 @@ async function serverSpeakFallback(text, lang, btn) { // Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort). function deviceSpeak(text, lang) { if (!text) return; - if (deviceVoicesReady() && deviceGenderReady(lang)) { + if (deviceVoicesReady() && deviceVoiceReady(lang)) { speechSynthesis.cancel(); speechSynthesis.speak(_makeUtterance(text, lang)); } else { - serverSpeakFallback(text, lang, null); // keine (passende) Stimme -> Server-Audio + serverSpeakFallback(text, lang, null); // keine Stimme für diese Sprache -> Server-Audio } } // Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück. function deviceSpeakBtn(text, lang, btn) { if (!text) { setReplayPlaying(btn, false); return; } - if (!deviceVoicesReady() || !deviceGenderReady(lang)) { serverSpeakFallback(text, lang, btn); return; } + if (!deviceVoicesReady() || !deviceVoiceReady(lang)) { serverSpeakFallback(text, lang, btn); return; } speechSynthesis.cancel(); const u = _makeUtterance(text, lang); u.onend = u.onerror = () => setReplayPlaying(btn, false); diff --git a/tests/test_routing.py b/tests/test_routing.py index 1cf8fcb..ebcbf43 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -95,10 +95,15 @@ def test_piper_gendered_voice_exact(lang, gender, expected): @pytest.mark.parametrize("lang,expected", [ ("ar", "ar_JO-kareem-medium"), ("pt", "pt_BR-faber-medium"), + ("nl", "nl_NL-mls-medium"), ("zh", "zh_CN-huayan-medium"), ]) def test_piper_single_voice_languages_ignore_gender(lang, expected): # Diese Sprachen haben keinen Geschlechtspartner -> selbe Stimme fuer m und f. + # Wichtig fuer NL: die m/f-Wahl greift hier folgenlos (einzig verfuegbare Stimme); + # die Geraete-TTS darf deshalb nicht auf Server-Fallback ausweichen (s. app.js + # deviceVoiceReady). Ohne diesen Eintrag wuerde voice_for_route None liefern und + # den Server-Default (deutsche Stimme) nach sich ziehen. assert voice_for_route("piper", lang, "f") == expected assert voice_for_route("piper", lang, "m") == expected