"use strict"; const $ = (sel) => document.querySelector(sel); const messagesEl = $("#messages"); const statusEl = $("#status"); const promptEl = $("#prompt"); const formEl = $("#prompt-form"); const micBtn = $("#mic"); const ttsSel = $("#tts"); const langSel = $("#lang-sel"); const cartesiaHint = $("#cartesia-lang-hint"); let cartesiaUnsupportedLangs = []; // von /api/config: Sprachen, die Cartesia nicht spricht const CARTESIA_LANG_LABELS = { ar: "Arabisch", it: "Italienisch", ru: "Russisch", pl: "Polnisch", nl: "Niederländisch", sv: "Schwedisch", tr: "Türkisch" }; // Die gewählte Sprache ist immer fest (Flex-Modus wurde entfernt). function langOverrides() { return { language: (langSel && langSel.value) || "de" }; } // Nutzer-Präferenzen als Request-Overrides für jeden Turn. // Geschlechtspräferenz: 'm', 'f', oder 'any' (= weiblich bevorzugt) let _voiceGender = 'f'; function applyGenderChoice(gender) { // 'any' aus alten Prefs → weiblich als Standardwert _voiceGender = (gender === 'm') ? 'm' : 'f'; const btnM = document.getElementById('gender-m'); const btnF = document.getElementById('gender-f'); if (!btnM || !btnF) return; const mActive = (_voiceGender === 'm'); const fActive = (_voiceGender === 'f'); // Aktiv: blauer Hintergrund + weiße Schrift // Inaktiv: kein Hintergrund + dunkle Schrift (explizit, damit kein Vererbungsproblem) btnM.classList.toggle('bg-blue-600', mActive); btnM.classList.toggle('text-white', mActive); btnM.classList.toggle('border-blue-600', mActive); btnM.classList.toggle('text-slate-700', !mActive); btnF.classList.toggle('bg-blue-600', fActive); btnF.classList.toggle('text-white', fActive); btnF.classList.toggle('border-blue-600', fActive); btnF.classList.toggle('text-slate-700', !fActive); } function overrides() { const out = { ...langOverrides() }; if (isDeviceMode()) { out.text_only = true; // kein Server-Audio -> Gerät spricht selbst } else if (ttsSel && ttsSel.value) { out.tts_provider = ttsSel.value; } out.voice_gender = _voiceGender; return out; } async function savePrefs(obj) { try { await fetch("/api/me/prefs", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj), }); } catch (e) { console.error("savePrefs", e); } } // ---------- Geräte-TTS (Web Speech API) ---------- // "Gerät" liest die Antwort lokal vor -> der Server schickt kein Audio (spart Bytes/Kosten). const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; const LANG_BCP47 = { de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", }; function isMobile() { const ua = navigator.userAgent || ""; return /Android|iPhone|iPad|iPod/i.test(ua) || (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); // iPad ab iOS 13 } function isDeviceMode() { return !!ttsSel && ttsSel.value === "device"; } let _voices = []; function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : []; } if (TTS_SUPPORTED) { loadVoices(); // Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen). speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); applyTtsVisibility(); }); setTimeout(() => applyTtsVisibility(), 1500); // Backstop, falls kein voiceschanged kommt } // 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); 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 { voice } = pickVoice(u.lang, _voiceGender); if (voice) u.voice = voice; return u; } // Hat der Browser nutzbare Sprachstimmen? (Linux-Desktops haben oft KEINE -> stumm.) function deviceVoicesReady() { if (!TTS_SUPPORTED) return false; if (!_voices.length) loadVoices(); 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) { try { const body = { text }; if (lang) body.language = lang; const r = await fetch("/api/speak", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error("HTTP " + r.status); startReplay([await r.arrayBuffer()], btn); } catch (e) { setReplayPlaying(btn, false); statusEl.textContent = "Vorlesen fehlgeschlagen"; } } // Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort). function deviceSpeak(text, lang) { if (!text) return; if (deviceVoicesReady() && deviceGenderReady(lang)) { speechSynthesis.cancel(); speechSynthesis.speak(_makeUtterance(text, lang)); } else { 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() || !deviceGenderReady(lang)) { serverSpeakFallback(text, lang, btn); return; } speechSynthesis.cancel(); const u = _makeUtterance(text, lang); u.onend = u.onerror = () => setReplayPlaying(btn, false); setReplayPlaying(btn, true); speechSynthesis.speak(u); } // iOS: sowohl SpeechSynthesis (Geräte-TTS) als auch der Web-Audio-Kontext (Server-Chunks) // dürfen nur nach einer Nutzergeste starten -> beim ersten Tap beide freischalten. let _ttsUnlocked = false; let _audioUnlocked = false; function unlockTTS() { // Geräte-TTS per Leer-Utterance freischalten. if (TTS_SUPPORTED && !_ttsUnlocked) { try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} } // Web-Audio-Kontext jetzt (in der Geste) anlegen + per Stille-Buffer aufwecken, damit // die satzweise Chunk-Wiedergabe in den Server-Modi auch auf iOS läuft (sonst bleibt ein // erst im WS-Callback erzeugter AudioContext dort "suspended"). if (!_audioUnlocked) { try { audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === "suspended") audioCtx.resume(); const src = audioCtx.createBufferSource(); src.buffer = audioCtx.createBuffer(1, 1, 22050); // 1 Sample Stille src.connect(audioCtx.destination); src.start(0); _audioUnlocked = true; } catch (e) {} } } const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt // Setzt das verborgene Quell-Select (#tts) beim Laden + spiegelt es in die Presets. // Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät. function applyPlaybackChoice(serverProvider) { if (!ttsSel) return; const local = localStorage.getItem(PLAYBACK_KEY); if (local === "device" && TTS_SUPPORTED) { ttsSel.value = "device"; } else if (serverProvider) { ttsSel.value = serverProvider; } else if (TTS_SUPPORTED && isMobile() && local === null) { ttsSel.value = "device"; // Mobil-Default, solange nichts gewählt wurde localStorage.setItem(PLAYBACK_KEY, "device"); } else if (!ttsSel.value) { ttsSel.value = "piper"; // Fallback: Server } applyTtsVisibility(); // blendet Provider passend ein/aus + syncPresetUI } // ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ── // Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel // „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut. let _ttsMenu = null; // zuletzt geladenes tts_menu von /api/config const TTS_DEFAULT = "piper"; // garantiert sichtbarer Fallback -> Menü nie leer function _ttsVisible(provider) { if (provider === "device") return TTS_SUPPORTED && isMobile(); // nur mobil if (provider === TTS_DEFAULT) return true; // garantierter Default if (!_ttsMenu) return true; // vor dem Laden nichts verstecken const e = _ttsMenu.find((m) => m.provider === provider); return e ? e.visible !== false : true; } // Blendet Provider-Buttons (und die zugehörigen