From 048d231b60d49ed422451bfd7f225ae251fe5939 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 16 May 2026 15:54:07 +0200 Subject: [PATCH] fix(wake): false-positive nach langer Hintergrund-Pause verwerfen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: Ohr aktiv, App im Hintergrund (jetzt mit Foreground-Service permanent lebendig), nach laengerer Zeit oeffnet Stefan die App und sie nimmt schon auf — angeblich Wake-Word getriggert. War aber TV/Husten/ sonstige Hintergrund-Geraeusche waehrend Stefan nicht da war. Mit dem neuen Hintergrund-Modus laeuft openWakeWord jetzt permanent und faengt jedes False-Positive im Hintergrund auf. Ohne dieser Fall war das nicht moeglich weil die JS-Engine pausiert war. Fix: Heuristik beim AppState-Resume in ChatScreen.tsx - backgroundDauer wird gemerkt (lastBackgroundAt vs Resume-Zeit) - Wenn >30s im Hintergrund UND state='conversing' UND letzter Wake- Trigger juenger als 15s: false-positive — Aufnahme abbrechen + zurueck zu armed - Resume-Cooldown 1500 → 3000 ms (Audio-Spikes beim AppState-Switch haben gelegentlich nach 1.5s noch nicht verklungen) Neue Methoden: - wakeword.ts: lastTriggerAt-Tracking + discardIfFreshlyTriggered(maxAge) - audio.ts: cancelRecording() — bricht recorder ab ohne Result zu emittieren, loescht die Audio-Datei Setzt voraus dass Stefan nicht laenger als 30s im Hintergrund mit ARIA spricht ueber Wake-Word. Falls doch: bei Resume waere die Aufnahme weg und er muesste nochmal triggern. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 31 +++++++++++++++++++++++----- android/src/services/audio.ts | 25 ++++++++++++++++++++++ android/src/services/wakeword.ts | 33 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index b0a6df4..bc4cd3d 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -480,14 +480,35 @@ const ChatScreen: React.FC = () => { return () => { phoneCallService.stop().catch(() => {}); }; }, []); - // App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground - // gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route) - // die openWakeWord sonst faelschlich als Wake-Word interpretiert. + // App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger + // beim Wechsel Background→Foreground: + // (a) Cooldown 3s — Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack + // re-route) sollen openWakeWord nicht faelschlich triggern + // (b) Wenn die App laenger im Hintergrund war und in 'conversing' + // zurueckkommt: vermutlich false-positive durch ein Hintergrund- + // Geraeusch (TV, Husten etc.) waehrend Stefan gar nicht da war. + // Wir verwerfen den Trigger und gehen zurueck zu 'armed'. + // (c) Aktuelle Aufnahme abbrechen falls sie aus dem false-positive + // gerade gestartet wurde. useEffect(() => { let lastState: string = AppState.currentState; + let lastBackgroundAt = 0; const sub = AppState.addEventListener('change', (next) => { - if (lastState !== 'active' && next === 'active') { - wakeWordService.setResumeCooldown(1500); + if (next === 'background' || next === 'inactive') { + lastBackgroundAt = Date.now(); + } else if (lastState !== 'active' && next === 'active') { + wakeWordService.setResumeCooldown(3000); + 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, + // verwerfen + laufende Aufnahme stoppen. + if (bgDur > 30_000) { + wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => { + if (discarded) { + try { audioService.cancelRecording(); } catch {} + } + }).catch(() => {}); + } } lastState = next; }); diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index f2cdc68..faa6693 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -727,6 +727,31 @@ class AudioService { } } + /** Aufnahme abbrechen ohne RecordingResult zu emittieren — z.B. bei + * Wake-Word-False-Positive beim App-Resume aus laengerem Hintergrund. + * Aufgenommene Datei wird sofort verworfen. */ + async cancelRecording(): Promise { + if (this.recordingState !== 'recording') return; + console.log('[Audio] Aufnahme abgebrochen (cancel)'); + this.vadEnabled = false; + if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; } + if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; } + if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; } + try { + const path = await this.recorder.stopRecorder(); + this.recorder.removeRecordBackListener(); + // Datei loeschen wenn da + if (path && path !== 'Already stopped') { + const local = path.replace(/^file:\/\//, ''); + try { await RNFS.unlink(local); } catch {} + } + } catch (err) { + console.warn('[Audio] cancelRecording stop fehlgeschlagen:', err); + } + this._releaseFocusDeferred(); + this.setState('idle'); + } + /** Aufnahme stoppen und Ergebnis zurueckgeben */ async stopRecording(): Promise { if (this.recordingState !== 'recording') { diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 929bd08..7b5f591 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -86,6 +86,11 @@ class WakeWordService { * oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route), * der openWakeWord faelschlich triggern kann. */ private cooldownUntilMs: number = 0; + /** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit + * ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume + * ein false-positive war (Wake-Word im Hintergrund getriggert waehrend + * Stefan gar nicht in der App war). */ + private lastTriggerAt: number = 0; private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; @@ -231,6 +236,7 @@ class WakeWordService { } console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', this.keyword, this.state, this.bargeListening); + this.lastTriggerAt = now; if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } @@ -341,6 +347,33 @@ class WakeWordService { this.setState('off'); } + /** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als + * maxAgeMs basiert: false-positive verwerfen, zurueck zu armed. + * Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund + * zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr + * wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc. + * Returnt true wenn verworfen wurde. */ + async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise { + if (this.state !== 'conversing') return false; + if (this.lastTriggerAt === 0) return false; + const age = Date.now() - this.lastTriggerAt; + if (age > maxAgeMs) return false; + console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age); + this.lastTriggerAt = 0; + if (this.nativeReady && OpenWakeWord) { + try { + await OpenWakeWord.start(); + ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT); + this.setState('armed'); + return true; + } catch (err) { + console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err); + } + } + this.setState('off'); + return true; + } + /** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */ async resume(): Promise { if (this.state !== 'conversing') return;