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