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 @@
-
+