fix(tts): Geräte-TTS folgt der Geschlechtswahl (m/f), sonst Server-Fallback

Der Geräte-TTS-Pfad (Browser-SpeechSynthesis) wählte die Stimme bisher nur
nach Sprache -> der m/f-Schalter blieb dort folgenlos. pickVoice bevorzugt
jetzt die geschlechtspassende Stimme (Heuristik über Name/voiceURI, da die
Web Speech API kein Geschlechtsfeld kennt). Findet das Gerät keine passende
Stimme, weichen deviceSpeak/deviceSpeakBtn auf Server-TTS aus (echte
Piper-m/f-Stimmen), damit die Wahl hörbar wirkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-27 02:43:29 +02:00
commit 5c63f7b6cc

View file

@ -88,19 +88,55 @@ if (TTS_SUPPORTED) {
setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt
}
function pickVoice(bcp) {
// Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik
// über Stimmname/voiceURI. Liefert 'm', 'f' oder null (unbekannt). Bewusst konservativ:
// im Zweifel null, damit wir lieber auf Server-TTS ausweichen als falsch zu raten.
const _VOICE_F = ["female", "weiblich", "femme", "mujer", "femminile", "feminino",
"anna", "helena", "petra", "samantha", "victoria", "karen", "moira", "tessa", "fiona",
"susan", "zoe", "amelie", "amélie", "audrey", "aurelie", "aurélie", "marie", "alice",
"federica", "elsa", "monica", "mónica", "paulina", "luciana", "joana", "catarina",
"milena", "alena", "irina", "tatyana", "ting-ting", "mei-jia", "sin-ji", "yaoyao",
"huihui", "hedda", "katja", "hazel", "zira", "heami"];
const _VOICE_M = ["male", "männlich", "homme", "hombre", "maschile", "masculino",
"markus", "yannick", "reed", "aaron", "fred", "daniel", "oliver", "arthur", "rishi",
"gordon", "conrad", "thomas", "luca", "cosimo", "diego", "jorge", "carlos", "felipe",
"bruno", "yuri", "pavel", "maxim", "liang", "kangkang", "stefan", "david", "mark",
"george", "pablo", "ravi"];
function _voiceGenderOf(v) {
if (!v) return null;
if (v.gender === "male") return "m"; // nicht-Standard, aber manche Engines liefern es
if (v.gender === "female") return "f";
const s = ((v.name || "") + " " + (v.voiceURI || "")).toLowerCase();
if (_VOICE_F.some((n) => s.includes(n))) return "f";
if (_VOICE_M.some((n) => s.includes(n))) return "m";
return null;
}
// Sprach- UND (wenn erkennbar) geschlechtspassende Gerätestimme.
// Rückgabe: { voice, genderMatch }. genderMatch=false signalisiert dem Aufrufer, dass
// keine Stimme des Wunschgeschlechts gefunden wurde (-> Server-Fallback ist sinnvoll).
function pickVoice(bcp, gender) {
if (!_voices.length) loadVoices();
const lc = bcp.toLowerCase();
const two = lc.slice(0, 2);
return _voices.find((v) => v.lang && v.lang.toLowerCase() === lc) ||
_voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(two)) || null;
const cands = _voices.filter(
(v) => v.lang && (v.lang.toLowerCase() === lc || v.lang.toLowerCase().startsWith(two)),
);
if (!cands.length) return { voice: null, genderMatch: false };
if (gender === "m" || gender === "f") {
const hit = cands.find((v) => _voiceGenderOf(v) === gender);
if (hit) return { voice: hit, genderMatch: true };
return { voice: cands[0], genderMatch: false }; // keine passende -> Caller weicht aus
}
return { voice: cands[0], genderMatch: true };
}
function _makeUtterance(text, lang) {
const u = new SpeechSynthesisUtterance(text);
u.lang = LANG_BCP47[lang] || lang || "de-DE";
const v = pickVoice(u.lang);
if (v) u.voice = v;
const { voice } = pickVoice(u.lang, _voiceGender);
if (voice) u.voice = voice;
return u;
}
@ -111,6 +147,14 @@ 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) {
const bcp = LANG_BCP47[lang] || lang || "de-DE";
return pickVoice(bcp, _voiceGender).genderMatch;
}
// Server-Fallback: holt Audio via /api/speak (ohne tts_provider -> Server-Default,
// niemals "device") und spielt es ab. Greift, wenn Geräte-TTS keine Stimme hat.
async function serverSpeakFallback(text, lang, btn) {
@ -132,18 +176,18 @@ async function serverSpeakFallback(text, lang, btn) {
// Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort).
function deviceSpeak(text, lang) {
if (!text) return;
if (deviceVoicesReady()) {
if (deviceVoicesReady() && deviceGenderReady(lang)) {
speechSynthesis.cancel();
speechSynthesis.speak(_makeUtterance(text, lang));
} else {
serverSpeakFallback(text, lang, null); // keine Stimmen -> Server-Audio
serverSpeakFallback(text, lang, null); // keine (passende) Stimme -> 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()) { serverSpeakFallback(text, lang, btn); return; }
if (!deviceVoicesReady() || !deviceGenderReady(lang)) { serverSpeakFallback(text, lang, btn); return; }
speechSynthesis.cancel();
const u = _makeUtterance(text, lang);
u.onend = u.onerror = () => setReplayPlaying(btn, false);