/** * 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'; type WakeWordCallback = () => void; type StateCallback = (state: WakeWordState) => void; export type WakeWordState = 'off' | 'armed' | 'conversing'; 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 = { 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; start(): Promise; stop(): Promise; dispose(): Promise; isAvailable(): Promise; } const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule }; class WakeWordService { private state: WakeWordState = 'off'; private wakeCallbacks: WakeWordCallback[] = []; private stateCallbacks: StateCallback[] = []; private keyword: WakeKeyword = DEFAULT_KEYWORD; private nativeReady: boolean = false; private initInProgress: Promise | null = null; private eventSub: { remove: () => void } | null = null; /** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */ async loadFromStorage(): Promise { 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 { 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 { 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 { if (!OpenWakeWord) return; try { await OpenWakeWord.dispose(); } catch {} this.nativeReady = false; } /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ async start(): Promise { if (this.state !== 'off') return true; if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.start(); console.log('[WakeWord] armed — warte auf "%s"', this.keyword); 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 { console.log('[WakeWord] Ohr deaktiviert'); if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } this.setState('off'); } /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ private async onWakeDetected(): Promise { console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword); ToastAndroid.show(`Wake-Word "${KEYWORD_LABELS[this.keyword]}" erkannt — sprich jetzt`, ToastAndroid.SHORT); if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.stop(); } catch {} } this.setState('conversing'); setTimeout(() => { if (this.state === 'conversing') { this.wakeCallbacks.forEach(cb => cb()); } }, 200); } /** Konversation beenden — User hat im Window nichts gesagt. * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Ohne: zurueck zu 'off'. */ async endConversation(): Promise { if (this.state !== 'conversing') return; if (this.nativeReady && OpenWakeWord) { try { await OpenWakeWord.start(); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed'); ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); this.setState('armed'); return; } catch (err) { console.warn('[WakeWord] re-arm fehlgeschlagen:', err); } } console.log('[WakeWord] Konversation zu Ende — Ohr aus'); ToastAndroid.show('Mikro aus', ToastAndroid.SHORT); this.setState('off'); } /** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */ async resume(): Promise { if (this.state !== 'conversing') return; // Kurze Pause damit TTS-Audio nicht ins Mikrofon geht await new Promise(resolve => setTimeout(resolve, 800)); if (this.state === 'conversing') { console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window'); 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); }; } 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) { this.state = state; this.stateCallbacks.forEach(cb => cb(state)); } } } const wakeWordService = new WakeWordService(); export default wakeWordService;