diff --git a/app/web/app.js b/app/web/app.js index a09fd10..c72294f 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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);