From d49ec64e27dbf9e5c1bc80c5d904a90462f5ad07 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 3 Jul 2026 01:50:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(voice-router):=20Voice=20folgt=20App-Focus?= =?UTF-8?q?=20+=20=E2=80=9Ehauptmen=C3=BC"=20als=20back-to-main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Bugs aus dem ersten Live-Test des Multi-Threading-Designs. Bug 1 — Voice ignorierte App-Focus: Stefan hat in Projekt X reingeguckt und was reingesagt — Message landete im Hauptchat statt in X. Der Voice-Router auf der Bridge kannte den sichtbaren Kontext der App nicht. Fix: - audio.ts.startStreamingRecording nimmt neuen opts.projectId und schickt es im stt_stream_start-Payload mit. - ChatScreen.tsx: alle 4 startStreamingRecording-Callsites (wake, barge-in, passive, manuell) uebergeben focusedProjectIdRef.current. Neuer useRef-Spiegel damit die Focus-ID auch in useCallbacks/ useEffects mit alten Closures aktuell bleibt. - aria_bridge.py: neuer Handler fuer stt_stream_start speichert die projectId in self._stt_stream_projects[requestId], stt_stream_end loescht wieder. Beim stt_endpoint wird sie an _process_endpoint_text weitergereicht und dort als default_project_id in den Voice-Router. - _apply_voice_router bekommt neuen Prio-Rank 4: „App-Focus als Default" — greift wenn kein Meta, kein Prefix und kein aktiver Sticky. So folgt Voice ohne extra Marker dem sichtbaren Kontext. Bug 2 — Back-to-Main-Regex zu eng: „zurück ins hauptmenü" wurde nicht als Meta erkannt (Regex matchte nur „zurück zum hauptchat") und landete deshalb im aktiven Sticky-Projekt. Fix: Regex akzeptiert jetzt auch hauptmenü, menü, haupt, main mit Praepositionen „zum/zur/ins/in den". Bonus — Burger-Button heller: Stefan konnte den ☰-Toggle im Header kaum sehen. Farbe von Default (dunkelgrau) auf #E0E0F0 (hell) mit fontWeight 700 gesetzt. Co-Authored-By: Claude Opus 4.7 --- android/src/screens/ChatScreen.tsx | 14 +++++- android/src/services/audio.ts | 5 +++ bridge/aria_bridge.py | 72 ++++++++++++++++++++++++++---- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index cabdd88..fd377e8 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -499,8 +499,14 @@ const ChatScreen: React.FC = () => { // fuer den Auto-Fall angenehm. useEffect(() => { AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {}); + focusedProjectIdRef.current = focusedProjectId; }, [focusedProjectId]); + // Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen + // ohne dass wir die Deps in jedes Callback muessen (sonst re-createn + // die sich bei jedem Wechsel). + const focusedProjectIdRef = useRef(''); + // Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und // fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout). useEffect(() => { @@ -1414,6 +1420,7 @@ const ChatScreen: React.FC = () => { noSpeechTimeoutMs: windowMs, endpointMs: 1500, hardCapMs: 60000, + projectId: focusedProjectIdRef.current, }); import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{}); if (ok) { @@ -1509,6 +1516,7 @@ const ChatScreen: React.FC = () => { noSpeechTimeoutMs: windowMs, endpointMs: 1500, hardCapMs: 60000, + projectId: focusedProjectIdRef.current, }); if (ok) { ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT); @@ -1565,6 +1573,7 @@ const ChatScreen: React.FC = () => { noSpeechTimeoutMs: Math.min(passiveMs, 30000), endpointMs: 1500, hardCapMs: Math.max(passiveMs + 5000, 35000), + projectId: focusedProjectIdRef.current, }); if (!ok) { console.warn('[Chat] passive streaming start failed — exit passive listening'); @@ -1880,7 +1889,7 @@ const ChatScreen: React.FC = () => { const location = await getCurrentLocation(); const cmid = nextClientMsgId(); - const activePid = focusedProjectId; + const activePid = focusedProjectIdRef.current; const userMsg: ChatMessage = { id: nextId(), sender: 'user', @@ -1963,6 +1972,7 @@ const ChatScreen: React.FC = () => { noSpeechTimeoutMs: 0, endpointMs: 1500, hardCapMs: 300000, + projectId: focusedProjectIdRef.current, }); if (!ok) { // Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg. @@ -2584,7 +2594,7 @@ const ChatScreen: React.FC = () => { style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} hitSlop={{top:6,bottom:6,left:6,right:6}} > - + {otherActive > 0 && ( {otherActive} diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index aab6b23..927a911 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -982,6 +982,10 @@ class AudioService { noSpeechTimeoutMs?: number; endpointMs?: number; hardCapMs?: number; + /** Focused projectId — Bridge nutzt das als Default fuer den Voice-Router. + * Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit + * automatisch in dem Kontext den Stefan gerade sieht. */ + projectId?: string; }): Promise<{ requestId: string; ok: boolean }> { if (this.recordingState !== 'idle') { console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState); @@ -1055,6 +1059,7 @@ class AudioService { endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500, hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000, sampleRate: 16000, + projectId: opts.projectId || '', }); // No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer. diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index c028cbe..4cb292a 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -618,6 +618,11 @@ class ARIABridge: # interceptiert und aendern hier den Sticky OHNE Brain-Roundtrip. self._voice_sticky_project_id: str = "" self._voice_sticky_expires_at: float = 0.0 + # Focused-project pro Stream: die App schickt bei stt_stream_start + # die projectId ihres aktuellen Focus mit. Wenn das Voice-Ergebnis + # weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen, + # nutzen wir das als Default (Voice folgt dem sichtbaren Kontext). + self._stt_stream_projects: dict[str, str] = {} # Voice-Override aus letzter Chat-Nachricht einer App. # Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt. # So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request). @@ -2788,6 +2793,25 @@ class ARIABridge: future.set_result(text) return + elif msg_type == "stt_stream_start": + # App startet eine neue Streaming-STT-Session. Wir merken uns + # ihre Focus-projectId damit der Voice-Router beim spaeteren + # stt_endpoint einen sinnvollen Default hat (Voice folgt dem + # visuellen Focus). + req_id = payload.get("requestId", "") or "" + focused_pid = str(payload.get("projectId") or "") + if req_id: + self._stt_stream_projects[req_id] = focused_pid + logger.info("[rvs] stt_stream_start id=%s focus=%s", + req_id[:12], focused_pid or "(main)") + return + + elif msg_type == "stt_stream_end": + # Session vorbei — Focus-Tracking fuer diese requestId aufraeumen. + req_id = payload.get("requestId", "") or "" + self._stt_stream_projects.pop(req_id, None) + return + elif msg_type == "stt_endpoint": # Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus # einen Endpoint erkannt und schickt den finalen Text direkt. @@ -2836,9 +2860,15 @@ class ARIABridge: if self._is_duplicate_client_msg(client_msg_id): return + # App-Focus aus stt_stream_start-Registry auflösen (falls die + # App-Version die projectId noch nicht mitschickt: leer = Hauptchat). + stream_req_id = payload.get("requestId", "") or "" + focused_pid = self._stt_stream_projects.pop(stream_req_id, "") + asyncio.create_task(self._process_endpoint_text( text, interrupted, audio_request_id, location, - client_msg_id=client_msg_id)) + client_msg_id=client_msg_id, + focused_project_id=focused_pid)) return elif msg_type == "oauth_callback": @@ -2986,7 +3016,20 @@ class ARIABridge: # Voice-Router-Konstanten _VOICE_STICKY_TIMEOUT_SEC = 30.0 _VOICE_META_BACK_TO_MAIN = re.compile( - r"^\s*(?:aria[,.]?\s+)?(?:zur(?:ü|ue)ck\s+zum\s+hauptchat|hauptchat\s+bitte|aria\s+hauptchat)\s*[.!?]?\s*$", + r"^\s*(?:aria[,.]?\s+)?" + r"(?:" + # „zurück zum hauptchat / hauptmenü / haupt / menü / main" + r"zur(?:ü|ue)ck\s+(?:zum|zur|ins?|in\s+den)\s+" + r"(?:hauptchat|hauptmen(?:ü|ue)|haupt|men(?:ü|ue)|main)" + r"|" + # „zurück hauptchat / zurück haupt" + r"zur(?:ü|ue)ck\s+(?:hauptchat|hauptmen(?:ü|ue)|haupt|main)" + r"|" + # „hauptchat bitte", „aria hauptchat" (auch mit Menü/Main) + r"(?:hauptchat|hauptmen(?:ü|ue)|main)\s+bitte" + r"|" + r"aria[,.]?\s+(?:hauptchat|hauptmen(?:ü|ue)|haupt|main)" + r")\s*[.!?]?\s*$", re.IGNORECASE, ) _VOICE_META_PROJECT_PREFIX = re.compile( @@ -2994,7 +3037,8 @@ class ARIABridge: re.IGNORECASE | re.DOTALL, ) - def _apply_voice_router(self, text: str) -> tuple[bool, str, str, str]: + def _apply_voice_router(self, text: str, + default_project_id: str = "") -> tuple[bool, str, str, str]: """Voice-Router: entscheidet ob ein STT-Text ans Brain geht und wenn ja an welchen Projekt-Kontext. @@ -3004,9 +3048,14 @@ class ARIABridge: - should_forward=True: cleaned_text ans Brain, project_id ist Focus. Bei Prefix wird der Prefix aus dem Text entfernt. - Sticky-Logik: nach einem projekt-getaggten Voice-Turn wird der Sticky - 30s lang gehalten. Innerhalb dieses Fensters gehen weitere Voice-Msgs - OHNE Prefix in dasselbe Projekt. Nach Ablauf: Default Hauptchat. + Prioritaets-Reihenfolge: + 1. Meta „zurueck zum hauptchat" → Sticky reset, kein Forward. + 2. „fuer :"-Prefix → Sticky auf gematchtes Projekt. + 3. Sticky aktiv (<=30s alt) → dessen Projekt. + 4. default_project_id (App-Focus) — Voice folgt dem sichtbaren + Kontext. Wenn App in Projekt X guckt, geht die STT-Nachricht + ohne weitere Marker dort rein. + 5. Fallback: Hauptchat. """ import time as _time now = _time.time() @@ -3056,13 +3105,18 @@ class ARIABridge: return (True, stripped, self._voice_sticky_project_id, "sticky") # Sticky abgelaufen — zurücksetzen self._voice_sticky_project_id = "" + # 4) App-Focus als Default: Voice folgt dem sichtbaren Kontext + if default_project_id: + return (True, stripped, default_project_id, "app_focus") + # 5) Fallback Hauptchat return (True, stripped, "", "default") async def _process_endpoint_text(self, text: str, interrupted: bool = False, audio_request_id: str = "", location: Optional[dict] = None, - client_msg_id: Optional[str] = None) -> None: + client_msg_id: Optional[str] = None, + focused_project_id: str = "") -> None: """Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain. @@ -3075,7 +3129,9 @@ class ARIABridge: selbst geht NICHT ans Brain, sondern broadcastet als project_changed- Event → App+Diagnostic wechseln den Focus. """ - should_forward, cleaned, project_id, meta_action = self._apply_voice_router(text) + should_forward, cleaned, project_id, meta_action = self._apply_voice_router( + text, default_project_id=focused_project_id, + ) if meta_action in ("back_to_main", "project_prefix"): # UI-Focus-Update broadcasten