diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
index de7ee82..99dc59c 100644
--- a/BEDIENUNGSANLEITUNG.md
+++ b/BEDIENUNGSANLEITUNG.md
@@ -1030,9 +1030,17 @@ Audio-Upload, keine Server-STT-Last. Gegenstück zum Geräte-TTS (§ 6.5.0), abe
- **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
+- **On-Device-Sprachpakete (Chrome):** Vor der lokalen Erkennung wird per
+ `SpeechRecognition.available()` geprüft, ob das Sprachpaket vorhanden ist. Fehlt es,
+ wird es im Hintergrund heruntergeladen (`install()`) und für **diesen** Turn auf
+ Cloud (falls erlaubt) bzw. Server zurückgefallen — so erscheint kein
+ „language not supported" mehr.
+- **Fallback:** Ohne Unterstützung (z. B. Firefox), bei fehlendem Sprachpaket oder
+ Erkennungsfehlern (`language-not-supported`, `network` …) wird automatisch der
Server-STT-Pfad genutzt. Während der Erkennung erscheint der Text **live** im
Eingabefeld; erneutes Tippen aufs Mikrofon beendet und sendet.
+- **Schnellwahl „📴 Lokal":** Setzt mit einem Tipp **STT + TTS** auf „Gerät" — dann
+ sendet/empfängt das Handy nur noch Text (maximale Daten-/Kostenersparnis).
---
diff --git a/app/web/app.js b/app/web/app.js
index 135587a..b8615df 100644
--- a/app/web/app.js
+++ b/app/web/app.js
@@ -10,6 +10,7 @@ const micBtn = $("#mic");
const ttsSel = $("#tts");
const langSel = $("#lang-sel");
const sttSel = $("#stt-sel");
+const allLocalBtn = $("#all-local");
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
@@ -150,15 +151,53 @@ function applySttChoice() {
if (local === "device") sttSel.value = "device";
else if (local === "server") sttSel.value = "server";
else if (isMobile()) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); }
+ // Schnellwahl "Alles lokal" nur zeigen, wenn STT- UND TTS-on-device möglich sind.
+ if (allLocalBtn && TTS_SUPPORTED) allLocalBtn.classList.remove("hidden");
+}
+
+// Schnellwahl: STT + TTS auf "Gerät" (nur Text zum Server). Setzt beide Schalter.
+function setAllLocal() {
+ unlockTTS();
+ if (sttSel) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); }
+ if (ttsSel) { ttsSel.value = "device"; localStorage.setItem(PLAYBACK_KEY, "device"); }
+ statusEl.textContent = "Alles lokal: Erkennung + Vorlesen im Gerät";
+ setTimeout(() => {
+ if (statusEl.textContent.startsWith("Alles lokal")) statusEl.textContent = "";
+ }, 2500);
}
let recognition = null;
-function startDeviceStt() {
+
+// Bestimmt den besten STT-Modus für eine Sprache: "local" (on-device), "cloud" oder
+// "server" (Whisper-Fallback). Nutzt die Chrome-API available()/install() für die
+// On-Device-Sprachpakete — verhindert "language not supported", wenn das Paket fehlt.
+async function resolveSttMode(bcp) {
+ if (SR && typeof SR.available === "function") {
+ try {
+ const a = await SR.available({ langs: [bcp], processLocally: true });
+ if (a === "available") return "local";
+ if (a === "downloadable" || a === "downloading") {
+ // On-Device-Paket anstoßen (für künftige Turns); jetzt Cloud/Server.
+ try { if (typeof SR.install === "function") SR.install({ langs: [bcp], processLocally: true }); } catch (e) {}
+ }
+ } catch (e) { /* API-Fehler -> unten */ }
+ }
+ return meAllowCloudStt ? "cloud" : "server";
+}
+
+async function startDeviceStt() {
+ const bcp = LANG_BCP47[langSel.value] || "de-DE";
+ const mode = await resolveSttMode(bcp);
+ if (mode === "server") { // lokal (für diese Sprache) nicht da & Cloud verboten
+ statusEl.textContent = "erkenne über Server …";
+ startRecording();
+ return;
+ }
const rec = new SR();
- rec.lang = LANG_BCP47[langSel.value] || "de-DE";
+ rec.lang = bcp;
rec.interimResults = true;
rec.continuous = false;
- try { if (sttIsLocal() && "processLocally" in rec) rec.processLocally = true; } catch (e) {}
+ if (mode === "local") { try { rec.processLocally = true; } catch (e) {} }
recognition = rec;
let finalText = "";
setMicRecording();
@@ -173,9 +212,16 @@ function startDeviceStt() {
};
rec.onerror = (e) => {
recognition = null;
+ const err = e.error || "?";
+ if (err === "no-speech") { setMicIdle(); statusEl.textContent = "nichts gehört — bitte erneut"; return; }
+ // Sprache/Dienst/Netz nicht verfügbar -> sauber auf Server-STT zurückfallen statt Fehler.
+ if (err === "language-not-supported" || err === "service-not-allowed" || err === "network") {
+ statusEl.textContent = "erkenne über Server …";
+ startRecording();
+ return;
+ }
setMicIdle();
- statusEl.textContent = e.error === "no-speech" ? "nichts gehört — bitte erneut"
- : "Spracherkennung fehlgeschlagen (" + (e.error || "?") + ")";
+ statusEl.textContent = "Spracherkennung fehlgeschlagen (" + err + ")";
};
rec.onend = () => {
recognition = null;
@@ -184,7 +230,11 @@ function startDeviceStt() {
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"; }
+ try { rec.start(); } catch (e) {
+ recognition = null;
+ statusEl.textContent = "erkenne über Server …";
+ startRecording(); // Start scheiterte -> Server-Fallback
+ }
}
function stopDeviceStt() { if (recognition) { try { recognition.stop(); } catch (e) {} } }
@@ -682,6 +732,7 @@ 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));
+if (allLocalBtn) allLocalBtn.addEventListener("click", setAllLocal);
loadMe();
diff --git a/app/web/index.html b/app/web/index.html
index 4224c85..6d5fae0 100644
--- a/app/web/index.html
+++ b/app/web/index.html
@@ -226,6 +226,8 @@
+