fix(wake): false-positive nach langer Hintergrund-Pause verwerfen
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<RecordingResult | null> {
|
||||
if (this.recordingState !== 'recording') {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
|
||||
Reference in New Issue
Block a user