feat(stt): Geräte-STT auf Mobilgeräten (Web Speech API) — getrennt, Datenschutz-Linie C

- 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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-20 20:08:10 +02:00
commit ab9c4f4938
7 changed files with 150 additions and 4 deletions

View file

@ -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 = [];

View file

@ -214,13 +214,18 @@
<option value="ru">🇷🇺 RU</option>
<option value="zh">🇨🇳 ZH</option>
</select>
<select id="tts" title="Sprachqualität"
<select id="tts" title="Sprachausgabe (TTS)"
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="device">📱 Gerät</option>
<option value="piper">Schnell</option>
<option value="chatterbox">Hoch</option>
<option value="openrouter">Cloud</option>
</select>
<select id="stt-sel" title="Spracherkennung (STT)"
class="hidden text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
<option value="device">🎙 Gerät</option>
<option value="server">☁ Server</option>
</select>
<button id="new-chat" title="Neues Gespräch (Verlauf zurücksetzen)"
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
@ -250,6 +255,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<script src="/app.js?v=31"></script>
<script src="/app.js?v=32"></script>
</body>
</html>