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