diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 5a0f43b..a73507b 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -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. diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 662b3cd..8f1757c 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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. diff --git a/aria-brain/main.py b/aria-brain/main.py index 3934954..9f275f0 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -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] = [ diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 5e4122a..dc5ac9f 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -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)