From 22fa4b3ccf2d62f220cf632e46b764e61163bcd9 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 24 Apr 2026 15:23:51 +0200 Subject: [PATCH] feat: Porcupine Wake-Word Integration (Built-In Keywords, "Jarvis" default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WakeWordService wrappt jetzt Picovoice Porcupine: - loadFromStorage(): Access Key + Keyword aus AsyncStorage, init Porcupine - configure(key, keyword): Settings-Wechsel, Re-Init - start(): wenn Porcupine bereit → 'armed' (passives Lauschen), sonst Fallback auf direktes 'conversing' (klassischer Modus) - onWakeDetected: Porcupine pausieren → 'conversing' → wakeCallback - endConversation: Porcupine wieder starten → 'armed' (Wake-Word weiter aktiv im Hintergrund, kein erneuter Tap noetig) - Pro Geraet eigene Wahl: jeder User kann sein eigenes Wake-Word haben Settings: neuer Bereich "Wake-Word" - Picovoice Access Key Input (mit Eye-Toggle), kostenlos auf console.picovoice.ai - Built-In Keyword Chips: jarvis, computer, picovoice, porcupine, bumblebee, terminator, alexa, hey google, ok google, hey siri - "Speichern + Aktivieren" Button mit Status-Feedback - Hinweis dass "ARIA" Custom-Keyword spaeter via Diagnostic kommt ChatScreen: ruft wakeWordService.loadFromStorage() beim Mount. package.json: @picovoice/porcupine-react-native + react-native-voice-processor hinzugefuegt — npm install + native rebuild noetig. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/package.json | 4 +- android/src/screens/ChatScreen.tsx | 5 + android/src/screens/SettingsScreen.tsx | 116 ++++++++++++++ android/src/services/wakeword.ts | 204 ++++++++++++++++++++----- 4 files changed, 289 insertions(+), 40 deletions(-) diff --git a/android/package.json b/android/package.json index d68a4fa..a27567f 100644 --- a/android/package.json +++ b/android/package.json @@ -24,7 +24,9 @@ "react-native-camera-kit": "^13.0.0", "@react-native-async-storage/async-storage": "^1.21.0", "react-native-fs": "^2.20.0", - "react-native-audio-recorder-player": "^3.6.7" + "react-native-audio-recorder-player": "^3.6.7", + "@picovoice/porcupine-react-native": "^3.0.6", + "@picovoice/react-native-voice-processor": "^1.2.3" }, "devDependencies": { "typescript": "^5.3.3", diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index d713db7..1cbab16 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -139,6 +139,11 @@ const ChatScreen: React.FC = () => { return () => clearInterval(interval); }, []); + // Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt) + useEffect(() => { + wakeWordService.loadFromStorage().catch(() => {}); + }, []); + const toggleMute = useCallback(() => { setTtsMuted(prev => { const next = !prev; diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 33cbec9..c661448 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -36,6 +36,12 @@ import { CONV_WINDOW_MAX_SEC, CONV_WINDOW_STORAGE_KEY, } from '../services/audio'; +import wakeWordService, { + BUILTIN_KEYWORDS, + DEFAULT_KEYWORD, + WAKE_ACCESS_KEY_STORAGE, + WAKE_KEYWORD_STORAGE, +} from '../services/wakeword'; import ModeSelector from '../components/ModeSelector'; import QRScanner from '../components/QRScanner'; import VoiceCloneModal from '../components/VoiceCloneModal'; @@ -92,6 +98,10 @@ const SettingsScreen: React.FC = () => { const [ttsPrerollSec, setTtsPrerollSec] = useState(TTS_PREROLL_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState(VAD_SILENCE_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState(CONV_WINDOW_DEFAULT_SEC); + const [wakeAccessKey, setWakeAccessKey] = useState(''); + const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false); + const [wakeKeyword, setWakeKeyword] = useState(DEFAULT_KEYWORD); + const [wakeStatus, setWakeStatus] = useState(''); const [editingPath, setEditingPath] = useState(false); const [xttsVoice, setXttsVoice] = useState(''); const [loadingVoice, setLoadingVoice] = useState(null); @@ -143,6 +153,12 @@ const SettingsScreen: React.FC = () => { } } }); + AsyncStorage.getItem(WAKE_ACCESS_KEY_STORAGE).then(saved => { + if (saved) setWakeAccessKey(saved); + }); + AsyncStorage.getItem(WAKE_KEYWORD_STORAGE).then(saved => { + if (saved) setWakeKeyword(saved); + }); AsyncStorage.getItem('aria_xtts_voice').then(saved => { if (saved) setXttsVoice(saved); }); @@ -651,6 +667,84 @@ const SettingsScreen: React.FC = () => { + {/* === Wake-Word (geraetelokal) === */} + Wake-Word + + + Wenn ein Picovoice-Access-Key eingetragen ist, hoert die App passiv + auf das gewaehlte Wake-Word — du kannst dich mit anderen unterhalten, + Musik laufen lassen und mit "{wakeKeyword}" eine Konversation mit + ARIA starten. Ohne Key oder bei Fehlschlag startet das Ohr direkt + eine Konversation (klassischer Modus). + + + Picovoice Access Key + + + setWakeAccessKeyVisible(v => !v)} + style={{padding: 8}} + > + {wakeAccessKeyVisible ? '🙈' : '👁'} + + + + Wake-Word + + Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter + ueber Diagnostic-Upload. + + + {BUILTIN_KEYWORDS.map(kw => ( + setWakeKeyword(kw)} + > + + {kw} + + + ))} + + + + { + setWakeStatus('Initialisiere...'); + try { + const ok = await wakeWordService.configure(wakeAccessKey, wakeKeyword); + setWakeStatus(ok ? `✅ "${wakeKeyword}" bereit` : '❌ Fehlgeschlagen — Access Key pruefen'); + } catch (err: any) { + setWakeStatus('❌ ' + String(err?.message || err).slice(0, 80)); + } + setTimeout(() => setWakeStatus(''), 5000); + }} + > + Speichern + Aktivieren + + + {!!wakeStatus && ( + {wakeStatus} + )} + + {/* === Sprachausgabe (geraetelokal) === */} Sprachausgabe @@ -1331,6 +1425,28 @@ const styles = StyleSheet.create({ minWidth: 80, textAlign: 'center', }, + + keywordChip: { + backgroundColor: '#1E1E2E', + borderWidth: 1, + borderColor: '#2A2A3E', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 14, + }, + keywordChipActive: { + backgroundColor: '#0096FF', + borderColor: '#0096FF', + }, + keywordChipText: { + color: '#8888AA', + fontSize: 13, + fontWeight: '500', + }, + keywordChipTextActive: { + color: '#FFFFFF', + fontWeight: '700', + }, }); export default SettingsScreen; diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index 8a78d19..886a1fe 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -3,69 +3,188 @@ * * Drei Zustaende: * off — Ohr aus, nichts laeuft - * armed — Ohr aktiv, wartet auf Wake Word ("ARIA"). Mikro IST AUS. - * (Sobald Porcupine integriert ist, hoert hier der Wake-Word- - * Detektor passiv mit. Aktuell ist das gleichbedeutend mit "off" - * bis der User wieder tippt — Stub fuer spaeter.) - * conversing — Wake Word getriggert / Ohr-Tap ohne Wake Word: aktive Konvers- - * ation mit ARIA. Mikro oeffnet nach jeder ARIA-Antwort fuer X - * Sekunden (Conversation-Window). Spricht der User nichts in dem - * Fenster → zurueck auf armed (kein erneuter Tap noetig sobald - * Porcupine drin ist). + * 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. * - * Aktuell ohne Porcupine: armed wird nur als Lifecycle-State gefuehrt; bei - * Conversation-Ende geht's direkt auf 'off' damit User klares Feedback bekommt. + * 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[] = []; - private wakeWordSupported: boolean = false; // wird gesetzt wenn Porcupine spaeter integriert ist - /** Ohr-Button gedrueckt — startet Konversation (oder armed wenn Wake-Word verfuegbar) */ + // 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 { PorcupineManager } = require('@picovoice/porcupine-react-native'); + // Built-In Keyword-Identifier sind lower-case strings im SDK + this.porcupine = await PorcupineManager.fromBuiltInKeywords( + this.accessKey, + [this.keyword], + (_keywordIndex: number) => this.onWakeDetected(), + ); + 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.wakeWordSupported) { - // Spaeter: Porcupine starten und auf "ARIA" warten - console.log('[WakeWord] armed — warte auf Wake Word'); - this.setState('armed'); - } else { - // Heute: 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); + 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) */ - stop(): void { + async stop(): Promise { console.log('[WakeWord] Ohr deaktiviert'); + if (this.porcupine) { + try { await this.porcupine.stop(); } catch {} + } this.setState('off'); } - /** Konversation beenden — User hat im Window nichts gesagt. - * Mit Porcupine: zurueck zu 'armed'. Ohne: zurueck zu 'off'. - */ - endConversation(): void { - if (this.state !== 'conversing') return; - if (this.wakeWordSupported) { - console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (warte auf Wake Word)'); - this.setState('armed'); - } else { - console.log('[WakeWord] Konversation zu Ende — Ohr aus (kein Wake Word verfuegbar)'); - 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 */ @@ -84,11 +203,18 @@ class WakeWordService { return this.state !== 'off'; } - /** True wenn gerade aktiv aufgenommen / mit ARIA gesprochen wird. */ isConversing(): boolean { return this.state === 'conversing'; } + hasWakeWord(): boolean { + return !!this.porcupine; + } + + getKeyword(): string { + return this.keyword; + } + // --- Callbacks --- onWakeWord(callback: WakeWordCallback): () => void {