/** * Gespraechsmodus / Wake Word Service * * Drei Zustaende: * off — Ohr aus, nichts laeuft * armed — Ohr aktiv, Porcupine hoert passiv auf das Wake-Word. * Das Mikro ist von Porcupine belegt; AudioRecorder ist aus. * conversing — Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word): * aktive Konversation. Porcupine 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. * * Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start' * direkt in 'conversing' (klassischer Gespraechsmodus). 'endConversation' * geht dann nach 'off' statt 'armed'. */ 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_ACCESS_KEY_STORAGE = 'aria_wake_access_key'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; /** Built-In Keywords von Picovoice — pre-trained, sofort einsetzbar. * Custom Keywords (z.B. "ARIA") brauchen ein .ppn File aus der Picovoice * Console — wird spaeter ueber Diagnostic uploadbar. */ export const BUILTIN_KEYWORDS = [ 'jarvis', 'computer', 'picovoice', 'porcupine', 'bumblebee', 'terminator', 'alexa', 'hey google', 'ok google', 'hey siri', ] as const; export type BuiltinKeyword = typeof BUILTIN_KEYWORDS[number]; export const DEFAULT_KEYWORD: BuiltinKeyword = 'jarvis'; class WakeWordService { private state: WakeWordState = 'off'; private wakeCallbacks: WakeWordCallback[] = []; private stateCallbacks: StateCallback[] = []; // Picovoice Manager (lazy, da Native Module nicht in jedem Build verfuegbar ist) private porcupine: any = null; private accessKey: string = ''; private keyword: string = DEFAULT_KEYWORD; private initInProgress: Promise | null = null; /** Beim App-Start aufrufen — laedt Settings, baut Porcupine wenn Key da ist. */ async loadFromStorage(): Promise { try { const k = await AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE); const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE); this.accessKey = (k || '').trim(); this.keyword = (w || DEFAULT_KEYWORD).trim(); if (this.accessKey) { // Vorinitialisieren — wirft sich nicht durch wenn etwas fehlt await this.initPorcupine(); } } catch (err) { console.warn('[WakeWord] loadFromStorage', err); } } /** Settings-Wechsel — neuer Key oder Keyword. Re-Init Porcupine. */ async configure(accessKey: string, keyword: string): Promise { this.accessKey = (accessKey || '').trim(); this.keyword = (keyword || DEFAULT_KEYWORD).trim(); await AsyncStorage.setItem(WAKE_ACCESS_KEY_STORAGE, this.accessKey); await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, this.keyword); // Laufende Instanz stoppen await this.disposePorcupine(); if (!this.accessKey) return false; // Neu initialisieren return this.initPorcupine(); } private async initPorcupine(): Promise { if (this.initInProgress) return this.initInProgress; this.initInProgress = (async () => { try { const porcupineRN = require('@picovoice/porcupine-react-native'); const { PorcupineManager, BuiltInKeywords } = porcupineRN; // Manche Porcupine-Versionen wollen das BuiltInKeywords-Enum (Objekt // mit keys wie JARVIS, COMPUTER, HEY_GOOGLE), andere akzeptieren // den String direkt. Mappen mit Fallback auf String: const enumKey = this.keyword.toUpperCase().replace(/\s+/g, '_'); const kw = (BuiltInKeywords && BuiltInKeywords[enumKey]) || this.keyword; console.log('[WakeWord] Porcupine init: keyword=%s (resolved=%s)', this.keyword, typeof kw === 'string' ? kw : '[enum]'); this.porcupine = await PorcupineManager.fromBuiltInKeywords( this.accessKey, [kw], (keywordIndex: number) => { console.log('[WakeWord] Porcupine callback fired (index=%d)', keywordIndex); this.onWakeDetected().catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err)); }, // Error handler (wenn Porcupine im Background-Thread crashed, // z.B. beim Audio-Engine-Konflikt mit audio-recorder-player) (error: any) => { console.warn('[WakeWord] Porcupine runtime error:', error?.message || error); // Nicht in Loop crashen — state zurueck auf off damit der User // mit dem Aufnahme-Button wieder normal arbeiten kann this.setState('off'); this.disposePorcupine().catch(() => {}); }, ); console.log('[WakeWord] Porcupine init OK (keyword=%s)', this.keyword); return true; } catch (err) { console.warn('[WakeWord] Porcupine init fehlgeschlagen:', err); this.porcupine = null; return false; } finally { this.initInProgress = null; } })(); return this.initInProgress; } private async disposePorcupine() { if (this.porcupine) { try { await this.porcupine.stop(); } catch {} try { await this.porcupine.delete(); } catch {} this.porcupine = null; } } /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */ async start(): Promise { if (this.state !== 'off') return true; if (this.porcupine) { // Passives Lauschen via Porcupine try { await this.porcupine.start(); console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword); this.setState('armed'); return true; } catch (err) { console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', err); } } // Fallback: direkt in die Konversation console.log('[WakeWord] Konversation startet sofort (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.porcupine) { try { await this.porcupine.stop(); } catch {} } this.setState('off'); } /** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */ private async onWakeDetected(): Promise { console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword); if (this.porcupine) { try { await this.porcupine.stop(); } catch {} } this.setState('conversing'); // kurz warten damit Mikrofon frei ist 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' (Porcupine wieder an). * Ohne: zurueck zu 'off'. */ async endConversation(): Promise { if (this.state !== 'conversing') return; if (this.porcupine && this.accessKey) { try { await this.porcupine.start(); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed'); this.setState('armed'); return; } catch (err) { console.warn('[WakeWord] re-arm fehlgeschlagen:', err); } } console.log('[WakeWord] Konversation zu Ende — Ohr aus'); 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.porcupine; } getKeyword(): string { 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;