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:
2026-05-16 15:54:07 +02:00
parent 2bac9c26ca
commit 048d231b60
3 changed files with 84 additions and 5 deletions
+33
View File
@@ -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;