fix(tts): Geräte-TTS-Regression für Sprachen ohne Geschlechtsstimme beheben

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-28 16:15:04 +02:00
commit e814b5548d
2 changed files with 16 additions and 8 deletions

View file

@ -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);

View file

@ -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