a0dc0cf20e
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 <noreply@anthropic.com>
675 lines
28 KiB
TypeScript
675 lines
28 KiB
TypeScript
/**
|
|
* Gespraechsmodus / Wake Word Service
|
|
*
|
|
* Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord),
|
|
* komplett on-device via ONNX Runtime in Native-Kotlin (siehe
|
|
* OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud-
|
|
* Roundtrip, kein Cent Lizenzgebuehren.
|
|
*
|
|
* Drei Zustaende:
|
|
* off — Ohr aus, nichts laeuft
|
|
* armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
|
|
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
|
|
* conversing — Wake-Word getriggert (oder Ohr-Tap manuell):
|
|
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
|
|
* AudioRecorder uebernimmt fuer die Aufnahme.
|
|
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
|
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
|
*
|
|
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
|
|
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
|
|
*/
|
|
|
|
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;
|
|
type PassiveListenCallback = () => void;
|
|
|
|
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<number> {
|
|
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<void> {
|
|
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
|
|
}
|
|
|
|
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
|
|
|
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
|
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
|
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
|
* werden — Diagnostic-Upload ist Phase 2. */
|
|
export const WAKE_KEYWORDS = [
|
|
'hey_jarvis',
|
|
'computer',
|
|
'alexa',
|
|
'hey_mycroft',
|
|
'hey_rhasspy',
|
|
] as const;
|
|
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
|
|
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
|
|
|
|
/** Hilfs-Mapping fuer die Anzeige im UI. */
|
|
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
|
hey_jarvis: 'Hey Jarvis',
|
|
computer: 'Computer',
|
|
alexa: 'Alexa',
|
|
hey_mycroft: 'Hey Mycroft',
|
|
hey_rhasspy: 'Hey Rhasspy',
|
|
};
|
|
|
|
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
|
|
const DEFAULT_THRESHOLD = 0.5;
|
|
const DEFAULT_PATIENCE = 2;
|
|
const DEFAULT_DEBOUNCE_MS = 1500;
|
|
|
|
interface OpenWakeWordModule {
|
|
init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
|
|
start(): Promise<boolean>;
|
|
stop(): Promise<boolean>;
|
|
dispose(): Promise<boolean>;
|
|
isAvailable(): Promise<boolean>;
|
|
}
|
|
|
|
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
|
|
|
|
class WakeWordService {
|
|
private state: WakeWordState = 'off';
|
|
private wakeCallbacks: WakeWordCallback[] = [];
|
|
private stateCallbacks: StateCallback[] = [];
|
|
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
|
|
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
|
|
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;
|
|
/** 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;
|
|
/** App liegt im Hintergrund — alle Detections sperren. Wird vom
|
|
* AppState-Listener im ChatScreen via setBackground/setForeground gesetzt.
|
|
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
|
|
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
|
|
private inBackground: boolean = false;
|
|
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
|
|
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
|
|
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
|
|
* Event sofort verworfen. Reset beim Verlassen von 'conversing'.
|
|
* 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<typeof setTimeout> | 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;
|
|
private initInProgress: Promise<boolean> | null = null;
|
|
private eventSub: { remove: () => void } | null = null;
|
|
|
|
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
|
|
async loadFromStorage(): Promise<void> {
|
|
try {
|
|
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
|
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
|
|
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
|
|
await this.initNative();
|
|
} catch (err) {
|
|
console.warn('[WakeWord] loadFromStorage', err);
|
|
}
|
|
}
|
|
|
|
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
|
|
async configure(keyword: string): Promise<boolean> {
|
|
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
|
|
? (keyword as WakeKeyword)
|
|
: DEFAULT_KEYWORD;
|
|
this.keyword = next;
|
|
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
|
|
|
|
// Laufende Instanz stoppen + neu initialisieren
|
|
await this.disposeNative();
|
|
const ok = await this.initNative();
|
|
if (!ok) {
|
|
ToastAndroid.show(
|
|
`Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`,
|
|
ToastAndroid.LONG,
|
|
);
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
private async initNative(): Promise<boolean> {
|
|
if (!OpenWakeWord) {
|
|
console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv');
|
|
this.nativeReady = false;
|
|
return false;
|
|
}
|
|
if (this.initInProgress) return this.initInProgress;
|
|
this.initInProgress = (async () => {
|
|
try {
|
|
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
|
// Subscribe nur einmal
|
|
if (!this.eventSub) {
|
|
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
|
this.eventSub = emitter.addListener('WakeWordDetected', () => {
|
|
console.log('[WakeWord] Native Detection-Event empfangen');
|
|
this.onWakeDetected().catch(err =>
|
|
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
|
});
|
|
}
|
|
this.nativeReady = true;
|
|
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
|
|
return true;
|
|
} catch (err: any) {
|
|
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
|
|
this.nativeReady = false;
|
|
return false;
|
|
} finally {
|
|
this.initInProgress = null;
|
|
}
|
|
})();
|
|
return this.initInProgress;
|
|
}
|
|
|
|
private async disposeNative(): Promise<void> {
|
|
if (!OpenWakeWord) return;
|
|
try { await OpenWakeWord.dispose(); } catch {}
|
|
this.nativeReady = false;
|
|
}
|
|
|
|
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
|
async start(): Promise<boolean> {
|
|
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();
|
|
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
|
|
// Debug-Log via RVS damit wir auch ohne ADB sehen wann es greift
|
|
import('./logger').then(m => m.reportAppDebug('wake.start', `armed, keyword=${this.keyword}`)).catch(()=>{});
|
|
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
|
this.setState('armed');
|
|
return true;
|
|
} catch (err: any) {
|
|
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
|
|
err?.message || err);
|
|
ToastAndroid.show(
|
|
`Wake-Word-Start failed: ${err?.message || err}`,
|
|
ToastAndroid.LONG,
|
|
);
|
|
}
|
|
} else {
|
|
console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
|
|
ToastAndroid.show(
|
|
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
|
|
ToastAndroid.LONG,
|
|
);
|
|
}
|
|
// Fallback: direkt in Konversation
|
|
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
|
|
this.setState('conversing');
|
|
setTimeout(() => {
|
|
if (this.state === 'conversing') {
|
|
this.wakeCallbacks.forEach(cb => cb());
|
|
}
|
|
}, 500);
|
|
return true;
|
|
}
|
|
|
|
/** Komplett ausschalten (Ohr abschalten) */
|
|
async stop(): Promise<void> {
|
|
console.log('[WakeWord] Ohr deaktiviert');
|
|
this.cancelPassiveListenTimer();
|
|
if (this.nativeReady && OpenWakeWord) {
|
|
try { await OpenWakeWord.stop(); } catch {}
|
|
}
|
|
this.bargeListening = false;
|
|
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);
|
|
}
|
|
|
|
/** App in den Hintergrund: alle Wake-Word-Detections sperren.
|
|
* Im Hintergrund will Stefan praktisch nie einen neuen Dialog starten —
|
|
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
|
|
setBackground(): void {
|
|
this.inBackground = true;
|
|
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
|
|
}
|
|
|
|
/** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
|
|
* als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
|
|
* dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
|
|
setForeground(): void {
|
|
this.inBackground = false;
|
|
this.cooldownUntilMs = Date.now() + 3000;
|
|
console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
|
|
}
|
|
|
|
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
|
private async onWakeDetected(): Promise<void> {
|
|
if (this.inBackground) {
|
|
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
|
|
return;
|
|
}
|
|
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
|
|
// Ausnahme: Barge-In waehrend ARIA-TTS ist ein legitimer neuer Trigger.
|
|
if (this.detectionInProgress && !this.bargeListening) {
|
|
console.log('[WakeWord] Trigger ignoriert (Detection-Zyklus laeuft schon — Native-Doppel-Event-Race)');
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: detectionInProgress')).catch(()=>{});
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
this.detectionInProgress = true;
|
|
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
|
this.keyword, this.state, this.bargeListening);
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
|
`keyword=${this.keyword} state=${this.state} barge=${this.bargeListening}`)).catch(()=>{});
|
|
this.lastTriggerAt = now;
|
|
if (this.nativeReady && OpenWakeWord) {
|
|
try {
|
|
await OpenWakeWord.stop();
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'native stop ok')).catch(()=>{});
|
|
} catch (e: any) {
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect', `native stop FAIL ${e?.message}`)).catch(()=>{});
|
|
}
|
|
}
|
|
this.bargeListening = false;
|
|
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
|
|
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
|
|
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
|
|
if (this.state === 'conversing') {
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
|
`barge path: cbs=${this.bargeCallbacks.length}`)).catch(()=>{});
|
|
this.bargeCallbacks.forEach(cb => {
|
|
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
|
|
});
|
|
// Kein erneutes setState — wir bleiben in 'conversing'.
|
|
return;
|
|
}
|
|
this.setState('conversing');
|
|
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
|
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
|
// nicht zuendekommen, weil Hermes auf einen Native-Wake-Event wartet.
|
|
// OpenWakeWord.stop() oben ist awaited → Mikro ist schon frei, kein
|
|
// 200ms-Sicherheitsabstand noetig.
|
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
|
`state→conversing, firing ${this.wakeCallbacks.length} callback(s) directly`)).catch(()=>{});
|
|
this.wakeCallbacks.forEach(cb => {
|
|
try { cb(); } catch (e) { console.warn('[WakeWord] wake cb err:', e); }
|
|
});
|
|
}
|
|
|
|
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
|
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
|
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
|
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
|
|
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
|
|
async startBargeListening(): Promise<void> {
|
|
if (!this.nativeReady || !OpenWakeWord) return;
|
|
if (this.state !== 'conversing') return;
|
|
if (this.bargeListening) return;
|
|
try {
|
|
await OpenWakeWord.start();
|
|
this.bargeListening = true;
|
|
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
|
|
} catch (err) {
|
|
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
|
|
}
|
|
}
|
|
|
|
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
|
|
* naechste Aufnahme das Mikro braucht. */
|
|
async stopBargeListening(): Promise<void> {
|
|
if (!this.bargeListening) return;
|
|
if (this.nativeReady && OpenWakeWord) {
|
|
try { await OpenWakeWord.stop(); } catch {}
|
|
}
|
|
this.bargeListening = false;
|
|
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<void> {
|
|
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<void> {
|
|
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'.
|
|
*
|
|
* WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
|
|
* Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
|
|
* feuert direkt danach ein zweiter Listener (stopBargeListening) — der
|
|
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
|
|
* ist, und unseren frisch re-armierten Listener killen.
|
|
*/
|
|
async endConversation(): Promise<void> {
|
|
if (this.state !== 'conversing') {
|
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
|
`endConversation called but state=${this.state} → noop`)).catch(()=>{});
|
|
return;
|
|
}
|
|
const wasBarge = this.bargeListening;
|
|
// Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
|
|
// stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
|
|
// OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
|
|
// den Listener stoppen.
|
|
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()
|
|
// und resolved sofort). Wir koennen es trotzdem rufen — billiger
|
|
// als state extra zu fragen, garantiert dass nach diesem Pfad
|
|
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
|
|
try {
|
|
await OpenWakeWord.start();
|
|
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
|
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
|
`OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
|
|
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
|
this.setState('armed');
|
|
return;
|
|
} catch (err: any) {
|
|
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
|
`OpenWakeWord.start() FAIL: ${err?.message || err} → state=off`,
|
|
)).catch(()=>{});
|
|
}
|
|
}
|
|
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
|
import('./logger').then(m => m.reportAppDebug('wake.end',
|
|
`fallback: nativeReady=${this.nativeReady} → state=off`)).catch(()=>{});
|
|
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
|
|
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<void> {
|
|
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
|
|
* 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.
|
|
*
|
|
* WICHTIG: setTimeout(800ms) kann im Hintergrund (Display aus) verspaetet
|
|
* feuern — JS-Thread ist geparkt. Wenn der Timer >2s ueberfaellig ist,
|
|
* hat der User offensichtlich die App verlassen und kommt erst spaeter
|
|
* wieder — wir oeffnen das Mikro dann NICHT, sondern beenden die
|
|
* Konversation. Sonst sieht der User nach dem App-Resume "Mikro plus-
|
|
* aufnahme laeuft" obwohl er gar nichts gesagt hat → wirkt wie Phantom-
|
|
* Wake-Word. Klassische Doze-Throttling-Falle wie bei wake.detect frueher. */
|
|
async resume(): Promise<void> {
|
|
if (this.state !== 'conversing') return;
|
|
const scheduledAt = Date.now();
|
|
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
|
await new Promise(resolve => setTimeout(resolve, 800));
|
|
if (this.state !== 'conversing') return;
|
|
const delay = Date.now() - scheduledAt;
|
|
if (delay > 2800) {
|
|
// Timer war stark verspaetet — JS-Thread war im Hintergrund geparkt.
|
|
// Conversation als beendet behandeln statt das Mikro zu oeffnen.
|
|
console.log('[WakeWord] resume(): %dms statt ~800ms — App war im Background. endConversation statt mic-open', delay);
|
|
import('./logger').then(m => m.reportAppDebug('wake.resume',
|
|
`delayed ${delay}ms (>2800) — endConversation statt mic-open`)).catch(()=>{});
|
|
// Asynchroner Aufruf — endConversation ist async, kein await damit wir
|
|
// hier nicht in einem Promise-Chain haengen.
|
|
this.endConversation().catch(() => {});
|
|
return;
|
|
}
|
|
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window (delay=%dms)', delay);
|
|
this.wakeCallbacks.forEach(cb => cb());
|
|
}
|
|
|
|
/** True solange das Ohr aktiv ist (armed ODER conversing). */
|
|
isActive(): boolean {
|
|
return this.state !== 'off';
|
|
}
|
|
|
|
isConversing(): boolean {
|
|
return this.state === 'conversing';
|
|
}
|
|
|
|
hasWakeWord(): boolean {
|
|
return this.nativeReady;
|
|
}
|
|
|
|
getKeyword(): WakeKeyword {
|
|
return this.keyword;
|
|
}
|
|
|
|
// --- Callbacks ---
|
|
|
|
onWakeWord(callback: WakeWordCallback): () => void {
|
|
this.wakeCallbacks.push(callback);
|
|
return () => {
|
|
this.wakeCallbacks = this.wakeCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
|
|
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
|
|
onBargeIn(callback: WakeWordCallback): () => void {
|
|
this.bargeCallbacks.push(callback);
|
|
return () => {
|
|
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
isBargeListening(): boolean {
|
|
return this.bargeListening;
|
|
}
|
|
|
|
onStateChange(callback: StateCallback): () => void {
|
|
this.stateCallbacks.push(callback);
|
|
return () => {
|
|
this.stateCallbacks = this.stateCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
getState(): WakeWordState {
|
|
return this.state;
|
|
}
|
|
|
|
private setState(state: WakeWordState): void {
|
|
if (this.state !== state) {
|
|
const wasConversing = this.state === 'conversing';
|
|
this.state = state;
|
|
// Re-Entry-Guard freigeben sobald wir 'conversing' verlassen — Zyklus ist durch
|
|
if (wasConversing && state !== 'conversing') {
|
|
this.detectionInProgress = false;
|
|
}
|
|
this.stateCallbacks.forEach(cb => cb(state));
|
|
}
|
|
}
|
|
}
|
|
|
|
const wakeWordService = new WakeWordService();
|
|
export default wakeWordService;
|