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(() => {}); };
|
return () => { phoneCallService.stop().catch(() => {}); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
|
// App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger
|
||||||
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
|
// beim Wechsel Background→Foreground:
|
||||||
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
|
// (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(() => {
|
useEffect(() => {
|
||||||
let lastState: string = AppState.currentState;
|
let lastState: string = AppState.currentState;
|
||||||
|
let lastBackgroundAt = 0;
|
||||||
const sub = AppState.addEventListener('change', (next) => {
|
const sub = AppState.addEventListener('change', (next) => {
|
||||||
if (lastState !== 'active' && next === 'active') {
|
if (next === 'background' || next === 'inactive') {
|
||||||
wakeWordService.setResumeCooldown(1500);
|
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;
|
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 */
|
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
||||||
async stopRecording(): Promise<RecordingResult | null> {
|
async stopRecording(): Promise<RecordingResult | null> {
|
||||||
if (this.recordingState !== 'recording') {
|
if (this.recordingState !== 'recording') {
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ class WakeWordService {
|
|||||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||||
* der openWakeWord faelschlich triggern kann. */
|
* der openWakeWord faelschlich triggern kann. */
|
||||||
private cooldownUntilMs: number = 0;
|
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 keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||||
private nativeReady: boolean = false;
|
private nativeReady: boolean = false;
|
||||||
@@ -231,6 +236,7 @@ class WakeWordService {
|
|||||||
}
|
}
|
||||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||||
this.keyword, this.state, this.bargeListening);
|
this.keyword, this.state, this.bargeListening);
|
||||||
|
this.lastTriggerAt = now;
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try { await OpenWakeWord.stop(); } catch {}
|
try { await OpenWakeWord.stop(); } catch {}
|
||||||
}
|
}
|
||||||
@@ -341,6 +347,33 @@ class WakeWordService {
|
|||||||
this.setState('off');
|
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 */
|
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
if (this.state !== 'conversing') return;
|
if (this.state !== 'conversing') return;
|
||||||
|
|||||||
Reference in New Issue
Block a user