feat(voice): Wake-Word per Sprachbefehl ausschalten

"Computer, Wake-Word aus" / "mach das Ohr aus" / "hör auf zuzuhören" / "geh
schlafen" → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte
Ruhe). Wieder-An nur über den Ohr-Button (bewusst, weil dann taub — Voice-
Wieder-An ist physikalisch unmöglich, wenn nichts mehr hört).

Deterministischer Detektor _user_wants_wake_off im Brain (kein LLM-Call nötig,
still). Signal fließt als wake_off durch ChatOut → Bridge → App-Payload; die App
löst denselben Pfad wie toggleWakeWord-off aus (cancelRecording +
wakeWordService.stop() + setWakeWordActive(false)). Detektor gegen 13 Positiv-
+ 9 Negativ-Fällen verifiziert (fängt nicht 'Licht aus' / 'Konversation Ende').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 20:27:48 +02:00
co-authored by Claude Opus 4.8
parent c4e658446d
commit 93401d42d1
4 changed files with 66 additions and 2 deletions
+18 -1
View File
@@ -1406,8 +1406,25 @@ const ChatScreen: React.FC = () => {
// ner Bestaetigung) landete im 30s-Fenster. Jetzt: einzelne Befehle enden
// sofort (zurueck aufs Wake-Word), nur echte Gespraeche lauschen weiter.
converseRef.current = (message.payload as any).converse === true;
const _wakeOff = (message.payload as any).wake_off === true;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent) {
if (_wakeOff) {
// ARIA hat "Wake-Word aus" per Sprache bekommen → Listener KOMPLETT
// stoppen (Mikro frei, echte Ruhe). Gleicher Weg wie der Ohr-Button
// (toggleWakeWord-off). Wieder-An nur ueber den Button (dann taub).
(async () => {
try {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('wake-off-voice');
} else {
await audioService.stopRecording();
}
} catch {}
try { await wakeWordService.stop(); } catch {}
setWakeWordActive(false);
console.log('[Chat] Wake-Word per Sprachbefehl AUS — Ohr-Button zum Wieder-Anmachen');
})();
} else if (_isSilent) {
// Steuerbefehl (speak=false) ist ausgefuehrt und wird NICHT vorgelesen.
// Ohne TTS feuert onPlaybackFinished nie — der Mikro-/Konversations-
// Lifecycle muss hier selbst weitergeschaltet werden, sonst haengt das Ohr.
+37
View File
@@ -1442,6 +1442,31 @@ def _user_wants_conversation_continue(text: str) -> bool:
return bool(_CONTINUE_CONVERSATION_RE.search(_strip_leading_hint_blocks(text)))
# ── Wake-Word AUS per Sprachbefehl ──────────────────────────────────────────
# "Wake-Word aus", "mach das Ohr aus", "hoer auf zuzuhoeren", "geh schlafen" …
# → die App stoppt den Wake-Word-Listener KOMPLETT (Mikro frei, echte Ruhe).
# Wieder-An geht nur ueber den App-Button (bewusst, weil dann taub). Reiner
# Steuerbefehl: still (speak=false), kein Weiterlauschen (converse=false).
_WAKE_NOUN = r"(?:wake[\s-]?word|wakeword|ohr(?:en)?|mikro(?:fon)?|zuh[oö]r\w*|lausch\w*)"
_WAKE_OFF_VERB = (r"(?:aus(?:schalten|stellen)?|abschalten|abstellen|deaktivier\w*|"
r"beenden|beende|stopp?\w*|schlafen|ruhe)")
_WAKE_OFF_RE = re.compile(
rf"\b{_WAKE_NOUN}\b[^.!?]{{0,20}}\b{_WAKE_OFF_VERB}\b"
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
rf"|h[oö]r\s+nicht\s+mehr\s+zu"
rf"|geh\s+schlafen",
re.IGNORECASE,
)
def _user_wants_wake_off(text: str) -> bool:
"""True, wenn der User den Wake-Word-Listener per Sprache abschalten will."""
if not text:
return False
return bool(_WAKE_OFF_RE.search(_strip_leading_hint_blocks(text)))
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -1865,6 +1890,18 @@ class Agent:
active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Wake-Word AUS per Sprache: reiner Steuerbefehl, KEIN Claude/Fast-Path
# noetig. Still (speak=false) + kein Weiterlauschen (converse=false). Das
# tatsaechliche Stoppen des Listeners macht die App anhand von wake_off in
# der Antwort (ChatOut) — hier signalisieren wir es nur. Wieder-An: App-Button.
if _user_wants_wake_off(user_message):
reply = "Ohr aus — sag's mir per App-Button, wenn ich wieder lauschen soll. 🔇"
self.conversation.add("user", user_message, source=source,
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
return reply, "wake-off", False, False, False
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
# iteriert hier ueber alle aktiven Skills und matched. Spart 5-10s Latenz.
+4
View File
@@ -676,6 +676,9 @@ class ChatOut(BaseModel):
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False
# Der User hat per Sprache "Wake-Word aus" gesagt → die App stoppt den
# Wake-Word-Listener komplett (Mikro frei). Wieder-An nur ueber App-Button.
wake_off: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -785,6 +788,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
wake_off=(answered_by == "wake-off"),
)
finally:
_project_pending[pid] = [
+7 -1
View File
@@ -1622,6 +1622,9 @@ class ARIABridge:
# die Projekt-Queue und leitet die naechste Eingabe als Antwort auf
# DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen.
"awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False,
# User hat "Wake-Word aus" gesagt → App stoppt den Listener komplett
# (Mikro frei). Wieder-An nur ueber den App-Button.
"wake_off": bool(payload.get("wake_off", False)) if isinstance(payload, dict) else False,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -2017,6 +2020,8 @@ class ARIABridge:
# Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die
# Projekt-Queue und leitet die naechste Eingabe als Antwort weiter.
awaiting_reply = bool(data.get("awaiting_reply", False))
# User hat per Sprache "Wake-Word aus" gesagt → App stoppt den Listener.
wake_off = bool(data.get("wake_off", False))
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen
@@ -2108,7 +2113,8 @@ class ARIABridge:
"answeredBy": answered_by,
"speak": speak,
"converse": converse,
"awaiting_reply": awaiting_reply})
"awaiting_reply": awaiting_reply,
"wake_off": wake_off})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id)