From e82e07e3a2cfdd172bdb90dbaada930db6157417 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 6 Jun 2026 08:27:08 +0200 Subject: [PATCH] =?UTF-8?q?fix:=205er-Bundle=20=E2=80=94=20Wake-Word,=20Sp?= =?UTF-8?q?otify-Latenz,=20File-Limit,=20Connection-Refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WakeWord Doppel-Trigger: detectionInProgress-Guard gegen Native-Event- Race + setBackground/setForeground statt setResumeCooldown im AppState. - Media-Pause beim App-Oeffnen: 1.5s Startup-Suppression im Kotlin emitDetected() — Mikro-Spin-up-Spike triggert kein false-positive mehr. - Spotify Fast-Path im Brain: einfache Media-Commands (naechster Track, pause, play, lauter, ...) matchen via Regex und gehen direkt aufs spotify-Skill statt durch Claude. ~1.5s statt 5-10s pro Befehl. - File-Limit auf 1 GB hochgezogen (war 70 MB). RVS maxPayload + Bridge max_size auf 1500 MB; Node-Heap im RVS-Container auf 4 GB. - TriggerBrowser / Datei-Manager Connection-Refused: brainApi._send fast-failt bei disconnected RVS statt 30s zu timeouten, und beide UIs reloaden automatisch beim Reconnect-Event. Co-Authored-By: Claude Opus 4.7 --- .../com/ariacockpit/OpenWakeWordModule.kt | 14 ++ android/src/components/TriggerBrowser.tsx | 12 ++ android/src/screens/ChatScreen.tsx | 3 +- android/src/screens/SettingsScreen.tsx | 14 ++ android/src/services/brainApi.ts | 9 ++ android/src/services/wakeword.ts | 47 +++++++ aria-brain/agent.py | 132 ++++++++++++++++++ bridge/aria_bridge.py | 11 +- rvs/docker-compose.yml | 3 + rvs/server.js | 15 +- 10 files changed, 249 insertions(+), 11 deletions(-) diff --git a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt index ff0c75d..d77adae 100644 --- a/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/OpenWakeWordModule.kt @@ -49,6 +49,12 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa private const val EMBEDDING_DIM = 96 private const val MEL_BINS = 32 private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt + // Nach record.startRecording() erzeugt das Mikro fuer ~1s einen Spin-up-Spike + // (DC-Offset, AGC-Settling) der vom Wake-Word-Klassifikator faelschlich als + // Trigger eingestuft werden kann. Folge: App pausiert beim Oeffnen die Musik, + // weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026). + // Loesung: in dieser Phase keine Detections an JS weiterleiten. + private const val STARTUP_SUPPRESSION_MS = 1500L } private val env: OrtEnvironment = OrtEnvironment.getEnvironment() @@ -95,6 +101,8 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa private val embBuffer: ArrayDeque = ArrayDeque(32) // Ringpuffer letzter Embeddings private var consecutiveAboveThreshold: Int = 0 private var lastDetectionMs: Long = 0L + // Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster + private var recordingStartedMs: Long = 0L /** * Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word. @@ -206,6 +214,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa resetInferenceState() running.set(true) record.startRecording() + recordingStartedMs = System.currentTimeMillis() // PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und // die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet. @@ -313,6 +322,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa } private fun emitDetected() { + val sinceStart = System.currentTimeMillis() - recordingStartedMs + if (sinceStart in 0 until STARTUP_SUPPRESSION_MS) { + Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)") + return + } val params = com.facebook.react.bridge.Arguments.createMap().apply { putString("model", modelName) } diff --git a/android/src/components/TriggerBrowser.tsx b/android/src/components/TriggerBrowser.tsx index 6392f21..f4647cd 100644 --- a/android/src/components/TriggerBrowser.tsx +++ b/android/src/components/TriggerBrowser.tsx @@ -23,6 +23,7 @@ import { } from 'react-native'; import brainApi, { Trigger } from '../services/brainApi'; +import rvs from '../services/rvs'; const COL_ACTIVE = '#34C759'; const COL_INACTIVE = '#555570'; @@ -65,6 +66,17 @@ export const TriggerBrowser: React.FC = () => { useEffect(() => { load(); }, [load]); + // Auto-Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail- + // Fehler aus brainApi ewig an obwohl die Verbindung schon wieder da ist. + useEffect(() => { + const unsub = rvs.onStateChange((state) => { + if (state === 'connected') { + load(); + } + }); + return () => unsub(); + }, [load]); + const visible = items.filter(t => { if (filter === 'active') return t.active; if (filter === 'inactive') return !t.active; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index b079bf0..fb4cee1 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -522,8 +522,9 @@ const ChatScreen: React.FC = () => { const sub = AppState.addEventListener('change', (next) => { if (next === 'background' || next === 'inactive') { lastBackgroundAt = Date.now(); + wakeWordService.setBackground(); } else if (lastState !== 'active' && next === 'active') { - wakeWordService.setResumeCooldown(3000); + wakeWordService.setForeground(); const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0; // Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches // Wake-Word getriggert wurde wahrend die App weg war — wenn ja, diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 2e9a612..62e2731 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -708,6 +708,20 @@ const SettingsScreen: React.FC = () => { }; }, []); + // Datei-Manager: Auto-Reload bei RVS-Reconnect — sonst zeigt das offene + // Modal den Fehler "Connection refused" ewig an, obwohl die Verbindung + // schon wieder da ist. Triggered nur wenn das Modal gerade offen ist. + useEffect(() => { + const unsub = rvs.onStateChange((state) => { + if (state === 'connected' && fileManagerOpen) { + setFileManagerError(''); + setFileManagerLoading(true); + rvs.send('file_list_request' as any, {}); + } + }); + return () => unsub(); + }, [fileManagerOpen]); + // --- QR-Code scannen --- const openQRScanner = useCallback(() => { diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index 6d1ec59..a478110 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -77,6 +77,15 @@ interface SendOpts { function _send(path: string, opts: SendOpts = {}): Promise { _ensureListener(); + // Fast-Fail wenn RVS nicht verbunden — sonst tickt der Timeout 30s und + // der TriggerBrowser / Dateimanager zeigt ne ewig drehende Spinner. + // Stefan-Bug 06/2026: "Connection refused, App haengt 30 Sekunden". + const rvsState = rvs.getState(); + if (rvsState !== 'connected') { + return Promise.reject(new Error( + `Keine Verbindung zum Brain (RVS: ${rvsState}). Warte auf Reconnect...`, + )); + } return new Promise((resolve, reject) => { const requestId = _newRequestId(); const timer = setTimeout(() => { diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index e535fae..9fa345b 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -91,6 +91,18 @@ class WakeWordService { * ein false-positive war (Wake-Word im Hintergrund getriggert waehrend * Stefan gar nicht in der App war). */ private lastTriggerAt: number = 0; + /** App liegt im Hintergrund — alle Detections sperren. Wird vom + * AppState-Listener im ChatScreen via setBackground/setForeground gesetzt. + * Hintergrund-Detections sind quasi immer false-positives (TV, Husten, + * AudioFocus-Switch beim Wechsel zu Musik etc.). */ + private inBackground: boolean = false; + /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere + * WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS + * resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite + * Event sofort verworfen. Reset beim Verlassen von 'conversing'. + * Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger + * waehrend ARIA noch redet, NICHT vom Guard blockieren. */ + private detectionInProgress: boolean = false; private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; @@ -228,14 +240,44 @@ class WakeWordService { console.log('[WakeWord] Cooldown aktiv fuer %dms', ms); } + /** App in den Hintergrund: alle Wake-Word-Detections sperren. + * Im Hintergrund will Stefan praktisch nie einen neuen Dialog starten — + * was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */ + setBackground(): void { + this.inBackground = true; + console.log('[WakeWord] App im Hintergrund — Detections gesperrt'); + } + + /** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown + * als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach + * dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */ + setForeground(): void { + this.inBackground = false; + this.cooldownUntilMs = Date.now() + 3000; + console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv'); + } + /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ private async onWakeDetected(): Promise { + if (this.inBackground) { + console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)'); + import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{}); + return; + } + // Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft. + // Ausnahme: Barge-In waehrend ARIA-TTS ist ein legitimer neuer Trigger. + if (this.detectionInProgress && !this.bargeListening) { + console.log('[WakeWord] Trigger ignoriert (Detection-Zyklus laeuft schon — Native-Doppel-Event-Race)'); + import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: detectionInProgress')).catch(()=>{}); + return; + } const now = Date.now(); if (now < this.cooldownUntilMs) { const left = this.cooldownUntilMs - now; console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left); return; } + this.detectionInProgress = true; console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', this.keyword, this.state, this.bargeListening); import('./logger').then(m => m.reportAppDebug('wake.detect', @@ -503,7 +545,12 @@ class WakeWordService { private setState(state: WakeWordState): void { if (this.state !== state) { + const wasConversing = this.state === 'conversing'; this.state = state; + // Re-Entry-Guard freigeben sobald wir 'conversing' verlassen — Zyklus ist durch + if (wasConversing && state !== 'conversing') { + this.detectionInProgress = false; + } this.stateCallbacks.forEach(cb => cb(state)); } } diff --git a/aria-brain/agent.py b/aria-brain/agent.py index a6cdcae..ab0c75c 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -782,6 +782,63 @@ META_TOOLS = [ ] +# ── Spotify Fast-Path ────────────────────────────────────────────────── +# +# Einfache Media-Commands (nächster Track, Pause, lauter, ...) gehen +# direkt aufs spotify-Skill statt durch die volle Claude-Reasoning-Pipeline. +# Latenz: ~1-1.5s statt 5-10s. Stefan-Bug 06/2026: "ARIA braucht ewig nur +# fuer 'nächster Track'". Wenn ein Pattern nicht matcht, faellt der Call +# wie bisher in die normale chat()-Loop und Claude entscheidet — keine +# Funktionalitaet geht verloren. +# +# Patterns sind anchored (^...$) gegen normalisierten Text (lowercase, +# Endsatzzeichen weg, Whitespace gestrafft). Bewusst eng gefasst: lieber +# einmal in Claude fallen als ein Kontextsatz wie "ich war kurz zurueck" +# faelschlich als "previous track" interpretieren. +_SPOTIFY_FAST_PATTERNS: list[tuple[str, str, str, Optional[int]]] = [ + # (regex, action, http-method, volume-delta) + # NEXT + (r"^(naechster|nächster|naechste|nächste) (track|song|titel|lied)$", "next", "POST", None), + (r"^(weiter|skip|ueberspringen|überspringen|ueberspring|überspring)$", "next", "POST", None), + # PREVIOUS + (r"^(vorheriger|vorheriges|letzter|letztes) (track|song|titel|lied)$", "previous", "POST", None), + (r"^(zurueck|zurück)$", "previous", "POST", None), + # PAUSE + (r"^(pause|pausiere|pausieren|stop|stopp|halt)$", "pause", "PUT", None), + (r"^(musik|spotify) (pause|aus|stop|stopp)$", "pause", "PUT", None), + # PLAY / RESUME + (r"^(play|weiterspielen|weiter spielen|fortsetzen|abspielen)$", "play", "PUT", None), + (r"^(musik|spotify) (an|wieder an|weiter|fortsetzen)$", "play", "PUT", None), + # VOLUME — Delta wird auf den aktuell ermittelten Volume-Wert aufaddiert + (r"^(lauter|musik lauter|spotify lauter|volume hoch|lautstärke hoch)$", "volume", "PUT", 10), + (r"^(leiser|musik leiser|spotify leiser|volume runter|lautstärke runter)$", "volume", "PUT", -10), + (r"^(viel lauter|deutlich lauter)$", "volume", "PUT", 20), + (r"^(viel leiser|deutlich leiser)$", "volume", "PUT", -20), +] + + +def _spotify_fast_match(text: str) -> Optional[tuple[str, str, Optional[int]]]: + """Returns (action, method, volume_delta) wenn ein Pattern matcht — sonst None.""" + norm = (text or "").strip().lower() + norm = re.sub(r"[.!?]+$", "", norm) + norm = re.sub(r"\s+", " ", norm) + if not norm: + return None + for rx, action, method, delta in _SPOTIFY_FAST_PATTERNS: + if re.match(rx, norm): + return action, method, delta + return None + + +def _run_spotify_call(path: str, method: str, body: Optional[dict] = None) -> dict: + """Fuehrt einen Spotify-Skill-Call aus. Skill-Args: path, method, body (JSON-String). + Returns das run_skill-Ergebnis.""" + args: dict = {"path": path, "method": method} + if body is not None: + args["body"] = json.dumps(body) + return skills_mod.run_skill("spotify", args, timeout_sec=15) + + def _skill_to_tool(s: dict) -> dict: """Mappt einen Skill auf ein OpenAI-Function-Tool.""" args = s.get("args") or [] @@ -849,6 +906,73 @@ class Agent: self._pending_events = [] return events + def _try_spotify_fast_path(self, user_message: str) -> Optional[str]: + """Wenn die Nachricht ein einfacher Media-Command ist, direkt aufs + spotify-Skill routen und ein kurzes Reply zurueckgeben — Claude wird + komplett uebersprungen. Returnt None wenn kein Pattern matcht oder das + spotify-Skill nicht installiert ist (dann faellt's normal in Claude).""" + m = _spotify_fast_match(user_message) + if m is None: + return None + action, method, delta = m + + # Skill muss installiert + aktiv sein. Sonst Fall-Through zu Claude. + try: + manifest = skills_mod.read_manifest("spotify") + except Exception: + manifest = None + if not manifest or not manifest.get("active", True): + logger.info("[spotify-fast] skill nicht verfuegbar — fall through zu Claude") + return None + + logger.info("[spotify-fast] match action=%s method=%s delta=%s msg=%r", + action, method, delta, user_message[:60]) + + def _err_reply(label: str, res: dict) -> str: + # ok=False kommt von 401 (nicht eingeloggt), 404 (kein aktives + # Gerät) etc. — Skill schreibt den Spotify-Error nach stderr. + tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines() + hint = (tail[-1] if tail else "")[:120] + return f"Spotify: {label} fehlgeschlagen — {hint or 'siehe Brain-Log'}" + + try: + if action == "next": + res = _run_spotify_call("/v1/me/player/next", method) + return "Spotify: nächster Track ⏭" if res.get("ok") else _err_reply("Skip", res) + if action == "previous": + res = _run_spotify_call("/v1/me/player/previous", method) + return "Spotify: vorheriger Track ⏮" if res.get("ok") else _err_reply("Zurück", res) + if action == "pause": + res = _run_spotify_call("/v1/me/player/pause", method) + return "Spotify: pausiert ⏸" if res.get("ok") else _err_reply("Pause", res) + if action == "play": + res = _run_spotify_call("/v1/me/player/play", method) + return "Spotify: spielt ▶" if res.get("ok") else _err_reply("Play", res) + if action == "volume" and delta is not None: + state = _run_spotify_call("/v1/me/player", "GET") + if not state.get("ok"): + return _err_reply("Lautstärke-Status", state) + cur_vol = 50 + try: + out = (state.get("stdout") or "").strip() + if out: + data = json.loads(out) + dev = data.get("device") or {} + cur_vol = int(dev.get("volume_percent", 50)) + except Exception as exc: + logger.warning("[spotify-fast] volume-state parse: %s", exc) + new_vol = max(0, min(100, cur_vol + delta)) + res = _run_spotify_call(f"/v1/me/player/volume?volume_percent={new_vol}", "PUT") + if not res.get("ok"): + return _err_reply("Lautstärke", res) + arrow = "🔊" if delta > 0 else "🔉" + return f"Spotify: Lautstärke {new_vol}% {arrow}" + except Exception as exc: + logger.warning("[spotify-fast] action=%s exception — fall through zu Claude: %s", + action, exc) + return None + return None + # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops @@ -861,6 +985,14 @@ class Agent: # Events vom letzten Turn weglassen self._pending_events = [] + # Spotify Fast-Path: einfache Media-Commands ueberspringen Claude komplett. + # Spart 4-9s Latenz fuer 'naechster Track', 'Pause', 'lauter' etc. + fast_reply = self._try_spotify_fast_path(user_message) + if fast_reply is not None: + self.conversation.add("user", user_message, source=source) + self.conversation.add("assistant", fast_reply) + return fast_reply + # 1. User-Turn an die Konversation self.conversation.add("user", user_message, source=source) diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 185e3a0..75a7c63 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1606,11 +1606,12 @@ class ARIABridge: try: url = f"{current_url}?token={self.rvs_token}" logger.info("[rvs] Verbinde: %s", current_url) - # max_size=100MB synchron zum RVS-Server (siehe rvs/server.js). + # max_size=1500MB synchron zum RVS-Server (siehe rvs/server.js). # File-Re-Download fuer Anhaenge braucht Platz fuer base64- - # inflate (~1.33×). Groessere Files lehnt der file_request- - # Handler proaktiv ab bevor's zur 1009-Disconnection kommt. - async with websockets.connect(url, max_size=100 * 1024 * 1024) as ws: + # inflate (~1.33×) — 1 GB binaer ≈ 1.34 GB base64, plus Margin. + # Groessere Files lehnt der file_request-Handler proaktiv ab + # bevor's zur 1009-Disconnection kommt. + async with websockets.connect(url, max_size=1500 * 1024 * 1024) as ws: self.ws_rvs = ws retry_delay = 2 logger.info("[rvs] Verbunden — warte auf App-Nachrichten") @@ -2594,7 +2595,7 @@ class ARIABridge: # Code 1009 (message too big) — RVS-Server droppt, Bridge crasht # im cleanup (websockets-Lib-Bug). Limit deckt typische Videos # und Bilder ab; alles drueber soll der User per SSH abholen. - FILE_MAX_BYTES = 70 * 1024 * 1024 + FILE_MAX_BYTES = 1024 * 1024 * 1024 # 1 GB binaer try: file_size = os.path.getsize(server_path) except OSError as exc: diff --git a/rvs/docker-compose.yml b/rvs/docker-compose.yml index d2f8627..7c25ce6 100644 --- a/rvs/docker-compose.yml +++ b/rvs/docker-compose.yml @@ -26,6 +26,9 @@ services: - ./updates:/updates # APK-Dateien fuer Auto-Update environment: - MAX_SESSIONS=10 + # 4 GB V8-Heap — sonst OOM beim Empfang von 1 GB-Files + # (base64 inflated ~1.34 GB plus WS-Frame-Margin). + - NODE_OPTIONS=--max-old-space-size=4096 networks: - aria-rvs-net diff --git a/rvs/server.js b/rvs/server.js index da9b873..2c26d5f 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -93,15 +93,20 @@ function cleanupRooms() { // als WS-Message `oauth_callback` und antwortet dem Browser mit einer // schoenen "Tab schliessen"-Seite. // -// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) + +// maxPayload 1500MB: TTS-Streaming + Voice-Upload (WAV als base64) + // audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten. // Plus: file_request/file_response fuer Re-Download von Anhaengen. // 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen -// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB -// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig -// abgewiesen (siehe file_request-Handler) bevor die WS abreisst. +// (Code 1009 message too big, Bridge crashed im cleanup). 1500 MB +// deckt bis ~1 GB binaer ab (mit base64 ~33% Overhead + WS-Frame- +// Margin); groessere Files werden Bridge-seitig abgewiesen (siehe +// file_request-Handler) bevor die WS abreisst. +// +// WICHTIG: Node-Default-Heap ist ~1.5 GB. Fuer 1 GB-Files muss der +// Container mit --max-old-space-size=4096 (oder NODE_OPTIONS env var) +// gestartet werden, sonst OOM-Crash beim Empfang. const httpServer = http.createServer(handleHttpRequest); -const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 }); +const wss = new WebSocketServer({ noServer: true, maxPayload: 1500 * 1024 * 1024 }); // HTTP-Upgrade-Pfad → an WebSocket-Server reichen httpServer.on("upgrade", (req, socket, head) => {