From 52795530f9bc7facb30fc91ab67c81ebba20131e Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 7 May 2026 07:49:02 +0200 Subject: [PATCH] fix(audio): Wake-Word-Anruf-Pause + Resume-Cooldown + Background-Mic-Order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 4 — Wake-Word laeuft bei Anruf weiter: phoneCall ruft jetzt wakeWordService.pauseForCall bei RINGING/OFFHOOK und resumeFromCall bei IDLE. Telefonie-App belegt das Mikro waehrend des Anrufs, openWakeWord muss daher pausieren. Pre-Call-State wird gemerkt — armed bleibt armed, conversing degraded zu armed (sonst landet der User nach Auflegen in einem halben Dialog). Bug 3 — App-Resume triggert faelschlich Wake-Word: Beim Wechsel von Background nach Foreground gibt's Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route), die openWakeWord als Wake- Word interpretiert. Neuer Cooldown-Mechanismus: AppState-Listener im ChatScreen ruft wakeWordService.setResumeCooldown(1500) — Detections in der Phase werden in onWakeDetected verworfen. Bug 1 — Background-Aufnahme klappt nicht: acquireBackgroundAudio('rec') wird jetzt VOR audioService.startRecorder gerufen, acquireBackgroundAudio('wake') VOR OpenWakeWord.start. Sonst greifen Androids Background-Mic-Restrictions (ab 11+) — der Service mit foregroundServiceType=microphone muss zum Zeitpunkt des AudioRecord- Starts schon aktiv sein, nicht erst per state-change-Listener asynchron danach. Bug 2 (VAD manchmal nicht): nicht in diesem Commit, vermutlich umgebungsabhaengig. Toast zeigt die kalibrierten Schwellen — wenn das nochmal auftritt, schick mir die Werte. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 15 +++++++ android/src/services/audio.ts | 8 +++- android/src/services/phoneCall.ts | 14 ++++++- android/src/services/wakeword.ts | 64 ++++++++++++++++++++++++++++++ issue.md | 3 ++ 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 0a5c748..0e1c1b9 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -19,6 +19,7 @@ import { ScrollView, Modal, ToastAndroid, + AppState, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; @@ -193,6 +194,20 @@ 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. + useEffect(() => { + let lastState: string = AppState.currentState; + const sub = AppState.addEventListener('change', (next) => { + if (lastState !== 'active' && next === 'active') { + wakeWordService.setResumeCooldown(1500); + } + lastState = next; + }); + return () => sub.remove(); + }, []); + // Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro // auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess // sonst und die Aufnahme bricht ab). diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 8c891d9..578f639 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -10,7 +10,7 @@ import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react import Sound from 'react-native-sound'; import RNFS from 'react-native-fs'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { stopBackgroundAudio } from './backgroundAudio'; +import { acquireBackgroundAudio, releaseBackgroundAudio, stopBackgroundAudio } from './backgroundAudio'; import AudioRecorderPlayer, { AudioEncoderAndroidType, AudioSourceAndroidType, @@ -368,6 +368,12 @@ class AudioService { this.recordingPath = `${RNFS.CachesDirectoryPath}/aria_recording_${Date.now()}.mp4`; + // Foreground-Service VOR dem AudioRecord starten — sonst blockt Android + // den Background-Mic-Zugriff (foregroundServiceType=microphone muss zum + // Zeitpunkt des startRecorder() schon aktiv sein, sonst greifen die + // Background-Mic-Restrictions ab Android 11+). + await acquireBackgroundAudio('rec'); + // Aufnahme mit Metering starten await this.recorder.startRecorder(this.recordingPath, { AudioEncoderAndroid: AudioEncoderAndroidType.AAC, diff --git a/android/src/services/phoneCall.ts b/android/src/services/phoneCall.ts index b5adba9..6242387 100644 --- a/android/src/services/phoneCall.ts +++ b/android/src/services/phoneCall.ts @@ -19,6 +19,7 @@ import { ToastAndroid, } from 'react-native'; import audioService from './audio'; +import wakeWordService from './wakeword'; interface PhoneCallNative { start(): Promise; @@ -91,16 +92,25 @@ class PhoneCallService { private _onStateChanged(state: PhoneState): void { if (state === this.lastState) return; - console.log('[PhoneCall] State: %s → %s', this.lastState, state); + const prev = this.lastState; + console.log('[PhoneCall] State: %s → %s', prev, state); this.lastState = state; if (state === 'ringing' || state === 'offhook') { audioService.haltAllPlayback(`Telefon-State: ${state}`); + // Wake-Word + Aufnahme pausieren: Telefonie-App belegt das Mikro + // waehrend des Anrufs, plus ARIA soll nicht im Telefonat zuhoeren. + wakeWordService.pauseForCall().catch(() => {}); ToastAndroid.show( state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert', ToastAndroid.SHORT, ); + } else if (state === 'idle' && prev !== 'idle') { + // Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war. + // TTS kommt nicht automatisch zurueck (Stream weg) — User kann + // ARIAs letzte Antwort per Play-Button nochmal hoeren. + wakeWordService.resumeFromCall().catch(() => {}); + ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT); } - // idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern } } diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 9dbb227..929bd08 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -22,6 +22,7 @@ import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { acquireBackgroundAudio } from './backgroundAudio'; type WakeWordCallback = () => void; type StateCallback = (state: WakeWordState) => void; @@ -77,6 +78,14 @@ class WakeWordService { private bargeCallbacks: WakeWordCallback[] = []; /** True solange Wake-Word parallel zu TTS aktiv ist. */ private bargeListening: boolean = false; + /** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */ + private callPaused: boolean = false; + private preCallState: WakeWordState = 'off'; + /** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections + * ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's + * oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route), + * der openWakeWord faelschlich triggern kann. */ + private cooldownUntilMs: number = 0; private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; @@ -157,6 +166,10 @@ class WakeWordService { /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ async start(): Promise { if (this.state !== 'off') return true; + // Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background- + // Lauschen funktioniert (Android braucht foregroundServiceType=microphone + // aktiv zum Zeitpunkt des AudioRecord.startRecording). + await acquireBackgroundAudio('wake'); if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.start(); @@ -200,8 +213,22 @@ class WakeWordService { this.setState('off'); } + /** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren. + * Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen + * die openWakeWord faelschlich als Trigger interpretiert. */ + setResumeCooldown(ms: number = 1500): void { + this.cooldownUntilMs = Date.now() + ms; + console.log('[WakeWord] Cooldown aktiv fuer %dms', ms); + } + /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ private async onWakeDetected(): Promise { + 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; + } console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)', this.keyword, this.state, this.bargeListening); if (this.nativeReady && OpenWakeWord) { @@ -255,6 +282,43 @@ class WakeWordService { console.log('[WakeWord] Barge-Listening aus'); } + /** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State + * merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA + * soll nicht in laufende Telefonate reinhoeren. */ + async pauseForCall(): Promise { + if (this.callPaused) return; + this.preCallState = this.state; + if (this.state === 'off') { + this.callPaused = true; // merken dass wir pausiert wurden + return; + } + this.callPaused = true; + if (this.nativeReady && OpenWakeWord) { + try { await OpenWakeWord.stop(); } catch {} + } + this.bargeListening = false; + console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState); + } + + /** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation + * geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */ + async resumeFromCall(): Promise { + if (!this.callPaused) return; + const restoreTo = this.preCallState; + this.callPaused = false; + this.preCallState = 'off'; + console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo); + if (restoreTo === 'off') return; + // Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen, + // sicher zu armed degraden. + if (restoreTo === 'conversing') this.setState('armed'); + if (this.nativeReady && OpenWakeWord) { + try { await OpenWakeWord.start(); } catch (err) { + console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err); + } + } + } + /** Konversation beenden — User hat im Window nichts gesagt. * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Ohne: zurueck zu 'off'. diff --git a/issue.md b/issue.md index 845fb88..84f57b6 100644 --- a/issue.md +++ b/issue.md @@ -30,6 +30,9 @@ - [x] VAD adaptive Baseline robuster: minimum statt avg + Cap auf -50dB bis -28dB (Stille) / -40dB bis -18dB (Speech) — keine "tote" VAD-Konfiguration mehr bei lauter Umgebung oder Wake-Word-Echo - [x] Push-to-Talk raus, nur noch Tap-to-Talk (verhinderte Touch-Race-Probleme) - [x] Manueller Mikro-Stop beendet Wake-Word-Konversation: Tap auf Mikro-Knopf waehrend conversing → audio raus + zurueck zu armed (= Wake-Word lauscht wieder, kein Auto-Mikro nach ARIAs Antwort). VAD-Auto-Stop bleibt bei Multi-Turn +- [x] **Wake-Word pausiert bei Anruf**: phoneCall ruft pauseForCall (openWakeWord.stop) bei RINGING/OFFHOOK, resumeFromCall bei IDLE. Pre-Call-State wird gemerkt — armed bleibt armed, conversing degraded zu armed (User soll nicht in halbem Dialog landen) +- [x] **App-Resume-Cooldown**: Wechsel von Background → Foreground triggert keinen falschen Wake-Word-Trigger mehr. AppState-Listener setzt 1.5s Cooldown in dem onWakeDetected-Events ignoriert werden (Audio-Pegel-Spike beim AudioFocus-Switch sonst als Wake-Word interpretiert) +- [x] Background-Mikro robust: acquireBackgroundAudio('rec'/'wake') wird jetzt VOR AudioRecord.startRecording gerufen — Foreground-Service mit foregroundServiceType=microphone muss aktiv sein bevor das Mikro greift, sonst blockiert Android ab 11+ den Background-Zugriff ### App Features