From ab9c4f4938fa9bfc6f57374985282d7d78c8e401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 20 Jun 2026 20:08:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt):=20Ger=C3=A4te-STT=20auf=20Mobilger?= =?UTF-8?q?=C3=A4ten=20(Web=20Speech=20API)=20=E2=80=94=20getrennt,=20Date?= =?UTF-8?q?nschutz-Linie=20C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Geräte-STT erkennt Sprache lokal und sendet nur Text über den Text-Turn; spart Audio-Upload + Server-STT. Getrennter Schalter (STT ▾) unabhängig vom TTS. - Linie C: nur bei nachweislich lokaler Erkennung (iOS / Chrome on-device); Cloud (z. B. Chrome-Desktop -> Google) nur mit Admin-Flag ALLOW_CLOUD_STT. -> config.allow_cloud_stt, RUNTIME_SETTABLE, /api/me, Admin-Toggle. - Fix-only (SpeechRecognition braucht Sprach-Hint); Flex -> Server-STT-Fallback. Kein/instabiles SpeechRecognition (z. B. Firefox) -> Server-STT. Live-Interim im Eingabefeld. Terminal/Desktop/Laptop unverändert serverseitig (Option nur sichtbar, wenn das Gerät lokale Erkennung bietet). - Tests: /api/me-Flag + runtime-setzbar (169 grün). Doku §5.1.2 + §6.3.1. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 24 ++++++++++- app/api/me.py | 2 + app/config.py | 4 ++ app/runtime_config.py | 1 + app/web/app.js | 98 +++++++++++++++++++++++++++++++++++++++++- app/web/index.html | 9 +++- tests/test_audit.py | 16 +++++++ 7 files changed, 150 insertions(+), 4 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index c78be73..de7ee82 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -792,7 +792,8 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr | **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" | | **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) | | **Sprache ▾** | Antwortsprache wählen (→ § 6.6): `🔄 Flex` = folgt automatisch der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Eingabe wird in diese Sprache übersetzt. Die vorlesende Stimme folgt der Auswahl. | -| **Qualität ▾** | Vorlese-Quelle wählen: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter | +| **Qualität ▾** (Ausgabe/TTS) | Vorlese-Quelle: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter | +| **STT ▾** (Eingabe) | *Nur sichtbar auf Geräten mit lokaler Spracherkennung* (iOS/Android): `🎙 Gerät` = Spracherkennung im Gerät (nur Text wird gesendet) · `☁ Server` = Audio-Upload + Whisper (wie am PC/Laptop). Getrennt von der Ausgabe wählbar (→ § 6.3.1). | | **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem | | **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | | **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie | @@ -1012,6 +1013,27 @@ FASTER_WHISPER_DEVICE=cuda FASTER_WHISPER_COMPUTE_TYPE=float16 ``` +#### 6.3.1 Geräte-STT (Web Speech API) — „🎙 Gerät" + +Auf **Mobilgeräten** (iOS/Android) kann die Spracherkennung **im Gerät** laufen: Das +Handy wandelt Sprache in Text um und sendet nur den **Text** an den Server — kein +Audio-Upload, keine Server-STT-Last. Gegenstück zum Geräte-TTS (§ 6.5.0), aber +**getrennt** wählbar (STT-Dropdown in der Kopfzeile). + +- **Erscheint nur**, wenn das Gerät lokale Spracherkennung bietet. **Terminal, Desktop- + Browser und Laptop** zeigen die Option nicht — dort läuft STT wie bisher über den + Server (Whisper). +- **Datenschutz (Linie C):** Standardmäßig nur bei **nachweislich lokaler** Erkennung + (iOS-Safari; Chrome mit On-Device-Modus). **Chrome am Desktop** würde das Audio zur + Erkennung an Google schicken — das ist nur erlaubt, wenn ein Admin + `ALLOW_CLOUD_STT=true` setzt (Admin → Einstellungen → „Geräte-STT (mobil)"). +- **Nur Fix-Sprache:** Geräte-STT braucht einen Sprach-Hint → es nutzt die im + Sprach-Dropdown gewählte feste Sprache. Im **Flex-Modus** fällt es automatisch auf + Server-STT (Whisper-Auto-Erkennung) zurück. +- **Fallback:** Ohne Unterstützung (z. B. Firefox) oder bei Erkennungsfehlern wird der + Server-STT-Pfad genutzt. Während der Erkennung erscheint der Text **live** im + Eingabefeld; erneutes Tippen aufs Mikrofon beendet und sendet. + --- ### 6.4 LLM-Einstellungen (Sprachmodell, lokal) diff --git a/app/api/me.py b/app/api/me.py index f270820..de0c35a 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from app.auth import require_user, is_admin_user from app.config import settings from app.dependencies import get_store +from app.runtime_config import runtime_settings from app.schemas import UserPrefs, MemoryCreate, MemoryOut from app.store import User @@ -18,6 +19,7 @@ async def get_me(user: User = Depends(require_user)): "is_admin": user.is_admin or is_admin_user(user), "prefs": user.prefs, "sso_logout_url": settings.sso_logout_url, + "allow_cloud_stt": bool(runtime_settings.allow_cloud_stt), } diff --git a/app/config.py b/app/config.py index 14ce23f..56397f7 100644 --- a/app/config.py +++ b/app/config.py @@ -135,6 +135,10 @@ class Settings(BaseSettings): faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3 faster_whisper_device: str = "auto" # auto|cpu|cuda faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16 + # Geräte-STT (Web Speech API) nur bei nachweislich lokaler Erkennung (iOS, Chrome + # on-device). True erlaubt es auch bei Cloud-Erkennung (z. B. Chrome-Desktop -> + # Audio geht zu Google). Default False = strenger Datenschutz (Linie C). + allow_cloud_stt: bool = False # --- Lokales TTS (piper) ------------------------------------------------- piper_bin: str = "piper" # Pfad/Name des piper-Binaries piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices") diff --git a/app/runtime_config.py b/app/runtime_config.py index 231e413..cb809bc 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -34,6 +34,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), + "allow_cloud_stt": ("Geräte-STT via Cloud erlauben", "bool", "true = auch Cloud-Erkennung (z.B. Chrome-Desktop -> Audio zu Google); false = nur on-device"), } _TTL = 30.0 diff --git a/app/web/app.js b/app/web/app.js index 56dbeb3..135587a 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -9,6 +9,7 @@ const formEl = $("#prompt-form"); const micBtn = $("#mic"); const ttsSel = $("#tts"); const langSel = $("#lang-sel"); +const sttSel = $("#stt-sel"); // "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern // automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt). @@ -103,6 +104,90 @@ function unlockTTS() { try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} } +// ---------- Geräte-STT (Web Speech API SpeechRecognition) ---------- +// Gegenstück zum Geräte-TTS: das Gerät erkennt Sprache lokal und schickt nur Text. +// Linie C: standardmäßig nur bei nachweislich lokaler Erkennung (iOS, Chrome on-device); +// Cloud-Erkennung (z. B. Chrome-Desktop -> Audio zu Google) nur, wenn der Admin +// allow_cloud_stt aktiviert hat. Sonst greift weiter der Server-STT (Whisper). +const SR = window.SpeechRecognition || window.webkitSpeechRecognition; +const STT_SUPPORTED = !!SR; +const STT_KEY = "va-stt"; // geräte-lokal: "device" wenn Geräte-STT gewählt +let meAllowCloudStt = false; // aus /api/me (Admin-Flag) + +function isIOS() { + const ua = navigator.userAgent || ""; + return /iPhone|iPad|iPod/i.test(ua) || + (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); +} +// Heuristik: iOS erkennt on-device. Chrome mit processLocally-Unterstützung gilt +// ebenfalls als lokal. Sonst (Cloud) nur erlaubt, wenn der Admin es freigibt. +function sttIsLocal() { + if (isIOS()) return true; + try { return SR && "processLocally" in SR.prototype; } catch (e) { return false; } +} +function sttDeviceAllowed() { + return STT_SUPPORTED && (sttIsLocal() || meAllowCloudStt); +} +function isSttDeviceMode() { return !!sttSel && sttSel.value === "device"; } + +// Geräte-STT für DIESEN Turn nutzen? Nur Fix-Sprache (SpeechRecognition braucht einen +// Sprach-Hint); im Flex-Modus auf Server-STT (Whisper-Auto-Erkennung) zurückfallen. +function useDeviceStt() { + return isSttDeviceMode() && sttDeviceAllowed() && (langSel && langSel.value !== "flex"); +} + +// Stellt das STT-Dropdown beim Laden ein: Option ausblenden wenn nicht erlaubt; +// sonst lokale Wahl bzw. Mobil-Default. +function applySttChoice() { + if (!sttSel) return; + if (!sttDeviceAllowed()) { + sttSel.classList.add("hidden"); // kein Geräte-STT -> Server wie bisher + sttSel.value = "server"; + return; + } + sttSel.classList.remove("hidden"); + const local = localStorage.getItem(STT_KEY); + if (local === "device") sttSel.value = "device"; + else if (local === "server") sttSel.value = "server"; + else if (isMobile()) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); } +} + +let recognition = null; +function startDeviceStt() { + const rec = new SR(); + rec.lang = LANG_BCP47[langSel.value] || "de-DE"; + rec.interimResults = true; + rec.continuous = false; + try { if (sttIsLocal() && "processLocally" in rec) rec.processLocally = true; } catch (e) {} + recognition = rec; + let finalText = ""; + setMicRecording(); + statusEl.textContent = "höre zu … (zum Stoppen erneut tippen)"; + rec.onresult = (e) => { + let interim = ""; + for (let i = e.resultIndex; i < e.results.length; i++) { + const t = e.results[i][0].transcript; + if (e.results[i].isFinal) finalText += t; else interim += t; + } + promptEl.value = (finalText + interim).trim(); // Live-Feedback im Eingabefeld + }; + rec.onerror = (e) => { + recognition = null; + setMicIdle(); + statusEl.textContent = e.error === "no-speech" ? "nichts gehört — bitte erneut" + : "Spracherkennung fehlgeschlagen (" + (e.error || "?") + ")"; + }; + rec.onend = () => { + recognition = null; + setMicIdle(); + const text = (promptEl.value || "").trim(); + promptEl.value = ""; + if (text) sendText(text); // -> vorhandener Text-Turn (inkl. Geräte-/Server-TTS) + }; + try { rec.start(); } catch (e) { recognition = null; setMicIdle(); statusEl.textContent = "STT-Start fehlgeschlagen"; } +} +function stopDeviceStt() { if (recognition) { try { recognition.stop(); } catch (e) {} } } + const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt // Setzt das Qualität-Dropdown beim Laden. Vorrang: @@ -167,6 +252,8 @@ function loadMe() { : (prefs.language || "flex"); } applyPlaybackChoice(prefs.tts_provider); + meAllowCloudStt = !!me.allow_cloud_stt; + applySttChoice(); return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -525,10 +612,14 @@ micBtn.addEventListener("click", () => { stopAudio(); statusEl.textContent = "Unterbrochen"; // busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText) + } else if (recognition) { + stopDeviceStt(); // Geräte-STT läuft -> stoppen (onend sendet den Text) } else if (mediaRecorder && mediaRecorder.state === "recording") { stopRecording(); + } else if (useDeviceStt()) { + startDeviceStt(); // lokale Spracherkennung (nur Fix + erlaubt) } else { - startRecording(); + startRecording(); // Server-STT (Upload) — wie bisher, auch am Desktop } }); @@ -589,6 +680,8 @@ if (ttsSel) { } }); } +// STT-Wahl ist geräte-lokal (nicht server-seitig) -> nur in localStorage. +if (sttSel) sttSel.addEventListener("change", () => localStorage.setItem(STT_KEY, sttSel.value)); loadMe(); @@ -1393,6 +1486,8 @@ const FIELD_META = { labels: { "true":"Aktiviert","false":"Deaktiviert" } }, memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 }, daily_request_limit: { ui: "number", min: 0 }, + allow_cloud_stt: { ui: "select", opts: ["true","false"], + labels: { "true":"Erlaubt (auch Cloud-Erkennung)","false":"Nur on-device (Datenschutz)" } }, }; const FIELD_GROUPS = [ @@ -1403,6 +1498,7 @@ const FIELD_GROUPS = [ { label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] }, { label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] }, { label: "Limits", keys: ["daily_request_limit"] }, + { label: "Geräte-STT (mobil)", keys: ["allow_cloud_stt"] }, ]; let _settingsData = []; diff --git a/app/web/index.html b/app/web/index.html index 6f0601c..4224c85 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -214,13 +214,18 @@ - +