From a0dc0cf20e07db2d558e55ab7d2b6d188ad97b97 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 6 Jun 2026 20:51:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(speaker-id):=20Phase=205=20=E2=80=94=20Pas?= =?UTF-8?q?sive-Listen-Window=20nach=20jeder=20Konversation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer State 'listening' im WakeWordService. Nach endConversation faellt ARIA nicht direkt zu armed zurueck, sondern ins passive Lauschen fuer PASSIVE_LISTEN_DEFAULT_MS (Default 30s, in AsyncStorage konfigurierbar). In dem Fenster braucht Stefan kein Wake-Word mehr — er kann einfach weitersprechen, Speaker-ID-Gating in der Whisper-Bridge filtert fremde Stimmen (TV, Frau, Hintergrundgespraeche). Flow: armed → wake → conversing → TTS → resume → (Nichts gesagt) → endConversation → enterPassiveListening('listening' + Timer) → startPassiveStreamingRecording (kein User-Bubble, kein wake-ready-Sound) → Speaker-ID-Gating in Bridge → Speech detected: exitPassiveListening('speech') → 'conversing' → normaler Flow → Nichts in N Sek: Timer feuert → exitPassiveListening('timeout') → 'armed' (Wake an) Implementation: - wakeword.ts: WakeWordState += 'listening'. enterPassiveListening + exitPassiveListening + onPassiveListen-Callback + Cancel-Timer-Hooks in stop(). PASSIVE_LISTEN_DEFAULT_MS/STORAGE_KEY + load/save Helpers. - ChatScreen.tsx: state-Type um 'listening' erweitert. State-Listener schliesst Conversation-Focus auch in 'listening' (Spotify bleibt pausiert). onPassiveListen → startPassiveStreamingRecording mit noSpeechTimeoutMs=passiveMs. STT-Endpoint-Handler: bei text != '' und state=='listening' → exitPassiveListening('speech'); bei text == '' und state=='listening' → naechste passive Aufnahme. Beim Wechsel listening→armed/off: laufende streaming-Aufnahme cancellen damit OpenWakeWord beim Re-Arm das Mic kriegt. Co-Authored-By: Claude Opus 4.7 --- android/src/screens/ChatScreen.tsx | 73 ++++++++++++++++-- android/src/services/wakeword.ts | 116 ++++++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 9 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index fb4cee1..7d9f64a 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser'; import ErrorBoundary from '../components/ErrorBoundary'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; -import wakeWordService from '../services/wakeword'; +import wakeWordService, { loadPassiveListenMs } from '../services/wakeword'; import phoneCallService from '../services/phoneCall'; import { playWakeReadySound } from '../services/wakeReadySound'; import { @@ -273,7 +273,7 @@ const ChatScreen: React.FC = () => { const [gpsEnabled, setGpsEnabled] = useState(false); const [wakeWordActive, setWakeWordActive] = useState(false); // Genauer State (off/armed/conversing) fuer UI-Feedback am Button - const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off'); + const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing' | 'listening'>('off'); const [fullscreenImage, setFullscreenImage] = useState(null); const [memoryDetailId, setMemoryDetailId] = useState(null); const [inboxVisible, setInboxVisible] = useState(false); @@ -487,9 +487,16 @@ const ChatScreen: React.FC = () => { // Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im // Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber // Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach - // 'armed' oder 'off' fallen, darf Spotify wieder. - if (s === 'conversing') audioService.acquireConversationFocus(); + // 'armed' oder 'off' fallen, darf Spotify wieder. 'listening' soll + // Spotify ebenfalls leise halten (User darf jederzeit weitersprechen). + if (s === 'conversing' || s === 'listening') audioService.acquireConversationFocus(); else audioService.releaseConversationFocus(); + // Beim Verlassen von 'listening' (Timer abgelaufen) eine ggf. noch + // laufende passive Streaming-Aufnahme killen, sonst hat OpenWakeWord + // keinen Zugriff aufs Mic beim Re-Arm. + if ((s === 'armed' || s === 'off') && audioService.isStreamingRecording()) { + audioService.cancelStreamingRecording('wakeword-state-' + s); + } // Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist // (armed oder conversing), soll der App-Prozess im Hintergrund am Leben // bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen. @@ -1346,12 +1353,18 @@ const ChatScreen: React.FC = () => { // - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor. // aria-bridge bekommt das gleiche Event und triggert Brain // direkt. App muss nix mehr senden. - // - text == '' → cancelStreamingRecording (no-speech / hardcap / error). - // Konversation beenden wie frueher der "kein Speech"-Fall. + // - text == '' → cancelStreamingRecording (no-speech / hardcap / error / + // speaker_mismatch). Konversation beenden, oder bei + // passive-listening: nochmal lauschen. const unsubEndpoint = audioService.onSttEndpoint((ev) => { if (ev.text && ev.text.trim()) { console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)', ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS); + // Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang + // zu 'conversing' damit der normale Flow greift (TTS, resume, etc.) + if (wakeWordService.getState() === 'listening') { + wakeWordService.exitPassiveListening('speech').catch(() => {}); + } // Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) + // chat(sender=aria) wie im Legacy-Pfad. } else { @@ -1361,11 +1374,28 @@ const ChatScreen: React.FC = () => { if (ev.audioRequestId) { setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId)); } - wakeWordService.endConversation(); - if (!wakeWordService.isActive()) setWakeWordActive(false); + // Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv + // lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende). + // Sonst endConversation wie bisher. + if (wakeWordService.getState() === 'listening') { + console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme'); + startPassiveStreamingRecording(); + } else { + wakeWordService.endConversation(); + if (!wakeWordService.isActive()) setWakeWordActive(false); + } } }); + // Passive-Listen-Callback: Wake-Word-Service hat in den passiven Modus + // geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme + // OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der + // Whisper-Bridge filtert fremde Stimmen weg. + const unsubPassive = wakeWordService.onPassiveListen(() => { + console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme'); + startPassiveStreamingRecording(); + }); + // Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht. // Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen // (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert). @@ -1430,11 +1460,38 @@ const ChatScreen: React.FC = () => { unsubWake(); unsubEndpoint(); unsubBarge(); + unsubPassive(); unsubTtsStart(); unsubTtsEnd(); }; }, [wakeWordActive]); + // Passive-Listen-Aufnahme: ohne User-Bubble, ohne Wake-Sound, Speaker-ID- + // Gating in der Whisper-Bridge entscheidet ob Stefan spricht oder z.B. + // die Frau / TV. Bei text != '' → wakeWordService.exitPassiveListening('speech') + // schaltet auf conversing, Brain antwortet, TTS spielt, resume → endConv → + // ... und passive listening startet von vorne (mit frischem Timer). + // useCallback damit der useEffect oben die Funktion stabil capturen kann. + const startPassiveStreamingRecording = useCallback(async () => { + const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`; + const location = await getCurrentLocation(); + const passiveMs = await loadPassiveListenMs(); + const { ok } = await audioService.startStreamingRecording({ + audioRequestId, + voice: localXttsVoiceRef.current, + speed: ttsSpeedRef.current, + interrupted: false, + location: location || null, + noSpeechTimeoutMs: Math.min(passiveMs, 30000), + endpointMs: 1500, + hardCapMs: Math.max(passiveMs + 5000, 35000), + }); + if (!ok) { + console.warn('[Chat] passive streaming start failed — exit passive listening'); + wakeWordService.exitPassiveListening('manual').catch(() => {}); + } + }, []); + // Wake Word Toggle Handler const toggleWakeWord = useCallback(async () => { if (wakeWordActive) { diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 9fa345b..9248f95 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -26,8 +26,30 @@ import { acquireBackgroundAudio } from './backgroundAudio'; type WakeWordCallback = () => void; type StateCallback = (state: WakeWordState) => void; +type PassiveListenCallback = () => void; -export type WakeWordState = 'off' | 'armed' | 'conversing'; +export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening'; + +/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation — + * in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt + * fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */ +export const PASSIVE_LISTEN_DEFAULT_MS = 30_000; +export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms'; + +export async function loadPassiveListenMs(): Promise { + try { + const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY); + if (raw) { + const n = parseInt(raw, 10); + if (isFinite(n) && n >= 0 && n <= 120_000) return n; + } + } catch {} + return PASSIVE_LISTEN_DEFAULT_MS; +} + +export async function savePassiveListenMs(ms: number): Promise { + await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms)); +} export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; @@ -103,6 +125,12 @@ class WakeWordService { * Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger * waehrend ARIA noch redet, NICHT vom Guard blockieren. */ private detectionInProgress: boolean = false; + /** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech, + * beendet den listening-State und geht zurueck zu armed. */ + private passiveListenTimer: ReturnType | null = null; + /** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet + * hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */ + private passiveListenCallbacks: PassiveListenCallback[] = []; private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; @@ -225,6 +253,7 @@ class WakeWordService { /** Komplett ausschalten (Ohr abschalten) */ async stop(): Promise { console.log('[WakeWord] Ohr deaktiviert'); + this.cancelPassiveListenTimer(); if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } @@ -407,6 +436,17 @@ class WakeWordService { this.bargeListening = false; import('./logger').then(m => m.reportAppDebug('wake.end', `endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{}); + + // Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen + // fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID + // (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute + // Anrede weitersprechen. + const passiveMs = await loadPassiveListenMs(); + if (passiveMs > 0 && this.nativeReady) { + this.enterPassiveListening(passiveMs); + return; + } + if (this.nativeReady && OpenWakeWord) { // Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS): // OpenWakeWord.start() ist idempotent (Kotlin checkt running.get() @@ -435,6 +475,80 @@ class WakeWordService { this.setState('off'); } + /** Eintritt in den Passive-Listen-Modus: state='listening', Timer fuer + * Auto-Ende setzen, Callbacks feuern damit ChatScreen die passive + * Streaming-Aufnahme startet. OpenWakeWord bleibt AUS (Mic-Exklusivitaet — + * audioService braucht das Mikro fuer die passive Aufnahme). + * Speaker-ID-Gating (Phase 3) filtert fremde Stimmen auf der Bridge. */ + private enterPassiveListening(durationMs: number): void { + this.cancelPassiveListenTimer(); + this.setState('listening'); + const seconds = Math.round(durationMs / 1000); + console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds); + import('./logger').then(m => m.reportAppDebug('wake.passive', + `entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{}); + ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT); + this.passiveListenTimer = setTimeout(() => { + this.passiveListenTimer = null; + this.exitPassiveListening('timeout').catch(() => {}); + }, durationMs); + this.passiveListenCallbacks.forEach(cb => { + try { cb(); } catch (e) { console.warn('[WakeWord] passive cb err:', e); } + }); + } + + /** Verlassen des Passive-Listen-Modus. + * reason='speech' → User hat was gesagt (STT-Endpoint mit text) → uebergang + * in 'conversing' (Brain antwortet, TTS spielt, dann resume → endConversation + * → wieder passive listening, repeat). + * reason='timeout' → 30s nichts gehoert → zurueck zu armed (Wake-Word wieder an). + * reason='manual' → User hat App geschlossen / stopped → zurueck zu armed. */ + async exitPassiveListening(reason: 'timeout' | 'speech' | 'manual'): Promise { + if (this.state !== 'listening') return; + this.cancelPassiveListenTimer(); + console.log('[WakeWord] Passive-Listen Ende (reason=%s)', reason); + import('./logger').then(m => m.reportAppDebug('wake.passive', + `exit reason=${reason}`)).catch(()=>{}); + + if (reason === 'speech') { + // Wechsel zu 'conversing' damit das Standard-Conversation-Flow greift + // (Brain-Response, TTS, resume etc.). Wake-Word bleibt aus (Mic belegt). + this.setState('conversing'); + return; + } + + // timeout oder manual → Wake-Word reaktivieren, armed-State. + if (this.nativeReady && OpenWakeWord) { + try { + await OpenWakeWord.start(); + console.log('[WakeWord] zurueck zu armed nach passive-listen'); + ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); + this.setState('armed'); + return; + } catch (err) { + console.warn('[WakeWord] re-arm nach passive-listen failed:', err); + } + } + this.setState('off'); + } + + private cancelPassiveListenTimer(): void { + if (this.passiveListenTimer) { + clearTimeout(this.passiveListenTimer); + this.passiveListenTimer = null; + } + } + + /** Subscribe auf Passive-Listen-Events: feuert wenn der Service in den + * passiven Modus eintritt. ChatScreen startet hier eine streaming- + * Aufnahme OHNE User-Bubble (passiv lauschen). */ + onPassiveListen(callback: PassiveListenCallback): () => void { + this.passiveListenCallbacks.push(callback); + return () => { + this.passiveListenCallbacks = this.passiveListenCallbacks.filter(c => c !== callback); + }; + } + /** 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