feat: Porcupine Wake-Word Integration (Built-In Keywords, "Jarvis" default)

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) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-24 15:23:51 +02:00
parent 1b8a51aad0
commit 22fa4b3ccf
4 changed files with 289 additions and 40 deletions

View File

@ -24,7 +24,9 @@
"react-native-camera-kit": "^13.0.0", "react-native-camera-kit": "^13.0.0",
"@react-native-async-storage/async-storage": "^1.21.0", "@react-native-async-storage/async-storage": "^1.21.0",
"react-native-fs": "^2.20.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": { "devDependencies": {
"typescript": "^5.3.3", "typescript": "^5.3.3",

View File

@ -139,6 +139,11 @@ const ChatScreen: React.FC = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {});
}, []);
const toggleMute = useCallback(() => { const toggleMute = useCallback(() => {
setTtsMuted(prev => { setTtsMuted(prev => {
const next = !prev; const next = !prev;

View File

@ -36,6 +36,12 @@ import {
CONV_WINDOW_MAX_SEC, CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY, CONV_WINDOW_STORAGE_KEY,
} from '../services/audio'; } 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 ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
import VoiceCloneModal from '../components/VoiceCloneModal'; import VoiceCloneModal from '../components/VoiceCloneModal';
@ -92,6 +98,10 @@ const SettingsScreen: React.FC = () => {
const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC); const [ttsPrerollSec, setTtsPrerollSec] = useState<number>(TTS_PREROLL_DEFAULT_SEC);
const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState<number>(VAD_SILENCE_DEFAULT_SEC);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [wakeAccessKey, setWakeAccessKey] = useState<string>('');
const [wakeAccessKeyVisible, setWakeAccessKeyVisible] = useState(false);
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(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 => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
if (saved) setXttsVoice(saved); if (saved) setXttsVoice(saved);
}); });
@ -651,6 +667,84 @@ const SettingsScreen: React.FC = () => {
</View> </View>
</View> </View>
{/* === Wake-Word (geraetelokal) === */}
<Text style={styles.sectionTitle}>Wake-Word</Text>
<View style={styles.card}>
<Text style={styles.toggleHint}>
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).
</Text>
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Picovoice Access Key</Text>
<View style={{flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6}}>
<TextInput
style={[styles.input, {flex: 1}]}
value={wakeAccessKey}
onChangeText={setWakeAccessKey}
placeholder="kostenlos auf console.picovoice.ai"
placeholderTextColor="#666680"
secureTextEntry={!wakeAccessKeyVisible}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
onPress={() => setWakeAccessKeyVisible(v => !v)}
style={{padding: 8}}
>
<Text style={{fontSize: 18}}>{wakeAccessKeyVisible ? '🙈' : '👁'}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 16}]}>Wake-Word</Text>
<Text style={styles.toggleHint}>
Built-In: sofort verwendbar. "ARIA" als Custom-Keyword kommt spaeter
ueber Diagnostic-Upload.
</Text>
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8}}>
{BUILTIN_KEYWORDS.map(kw => (
<TouchableOpacity
key={kw}
style={[
styles.keywordChip,
wakeKeyword === kw && styles.keywordChipActive,
]}
onPress={() => setWakeKeyword(kw)}
>
<Text style={[
styles.keywordChipText,
wakeKeyword === kw && styles.keywordChipTextActive,
]}>
{kw}
</Text>
</TouchableOpacity>
))}
</View>
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
<TouchableOpacity
style={[styles.connectButton, {flex: 1}]}
onPress={async () => {
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);
}}
>
<Text style={styles.connectButtonText}>Speichern + Aktivieren</Text>
</TouchableOpacity>
</View>
{!!wakeStatus && (
<Text style={{marginTop: 8, fontSize: 12, color: '#8888AA'}}>{wakeStatus}</Text>
)}
</View>
{/* === Sprachausgabe (geraetelokal) === */} {/* === Sprachausgabe (geraetelokal) === */}
<Text style={styles.sectionTitle}>Sprachausgabe</Text> <Text style={styles.sectionTitle}>Sprachausgabe</Text>
<View style={styles.card}> <View style={styles.card}>
@ -1331,6 +1425,28 @@ const styles = StyleSheet.create({
minWidth: 80, minWidth: 80,
textAlign: 'center', 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; export default SettingsScreen;

View File

@ -3,69 +3,188 @@
* *
* Drei Zustaende: * Drei Zustaende:
* off Ohr aus, nichts laeuft * off Ohr aus, nichts laeuft
* armed Ohr aktiv, wartet auf Wake Word ("ARIA"). Mikro IST AUS. * armed Ohr aktiv, Porcupine hoert passiv auf das Wake-Word.
* (Sobald Porcupine integriert ist, hoert hier der Wake-Word- * Das Mikro ist von Porcupine belegt; AudioRecorder ist aus.
* Detektor passiv mit. Aktuell ist das gleichbedeutend mit "off" * conversing Wake-Word getriggert (oder Ohr-Tap ohne Wake-Word):
* bis der User wieder tippt Stub fuer spaeter.) * aktive Konversation. Porcupine pausiert (gibt Mikro frei),
* conversing Wake Word getriggert / Ohr-Tap ohne Wake Word: aktive Konvers- * AudioRecorder uebernimmt fuer die Aufnahme.
* ation mit ARIA. Mikro oeffnet nach jeder ARIA-Antwort fuer X * Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
* Sekunden (Conversation-Window). Spricht der User nichts in dem * (Conversation-Window). Stille im Fenster zurueck zu armed.
* Fenster zurueck auf armed (kein erneuter Tap noetig sobald
* Porcupine drin ist).
* *
* Aktuell ohne Porcupine: armed wird nur als Lifecycle-State gefuehrt; bei * Wake-Word fallback: ist kein Picovoice-Access-Key gesetzt, geht 'start'
* Conversation-Ende geht's direkt auf 'off' damit User klares Feedback bekommt. * 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 WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void; type StateCallback = (state: WakeWordState) => void;
export type WakeWordState = 'off' | 'armed' | 'conversing'; 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 { class WakeWordService {
private state: WakeWordState = 'off'; private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = []; private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = []; 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<boolean> | null = null;
/** Beim App-Start aufrufen — laedt Settings, baut Porcupine wenn Key da ist. */
async loadFromStorage(): Promise<void> {
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<boolean> {
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<boolean> {
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<boolean> { async start(): Promise<boolean> {
if (this.state !== 'off') return true; if (this.state !== 'off') return true;
if (this.wakeWordSupported) { if (this.porcupine) {
// Spaeter: Porcupine starten und auf "ARIA" warten // Passives Lauschen via Porcupine
console.log('[WakeWord] armed — warte auf Wake Word'); try {
this.setState('armed'); await this.porcupine.start();
} else { console.log('[WakeWord] armed — warte auf Wake Word "%s"', this.keyword);
// Heute: direkt in die Konversation this.setState('armed');
console.log('[WakeWord] Konversation startet sofort (kein Wake-Word)'); return true;
this.setState('conversing'); } catch (err) {
setTimeout(() => { console.warn('[WakeWord] Porcupine start fehlgeschlagen — Fallback Direkt-Konversation:', err);
if (this.state === 'conversing') { }
this.wakeCallbacks.forEach(cb => cb());
}
}, 500);
} }
// 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; return true;
} }
/** Komplett ausschalten (Ohr abschalten) */ /** Komplett ausschalten (Ohr abschalten) */
stop(): void { async stop(): Promise<void> {
console.log('[WakeWord] Ohr deaktiviert'); console.log('[WakeWord] Ohr deaktiviert');
if (this.porcupine) {
try { await this.porcupine.stop(); } catch {}
}
this.setState('off'); this.setState('off');
} }
/** Konversation beenden User hat im Window nichts gesagt. /** Wake-Word getriggert: Porcupine pausieren, Konversation starten. */
* Mit Porcupine: zurueck zu 'armed'. Ohne: zurueck zu 'off'. private async onWakeDetected(): Promise<void> {
*/ console.log('[WakeWord] Wake-Word "%s" erkannt!', this.keyword);
endConversation(): void { if (this.porcupine) {
if (this.state !== 'conversing') return; try { await this.porcupine.stop(); } catch {}
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');
} }
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<void> {
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 */ /** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
@ -84,11 +203,18 @@ class WakeWordService {
return this.state !== 'off'; return this.state !== 'off';
} }
/** True wenn gerade aktiv aufgenommen / mit ARIA gesprochen wird. */
isConversing(): boolean { isConversing(): boolean {
return this.state === 'conversing'; return this.state === 'conversing';
} }
hasWakeWord(): boolean {
return !!this.porcupine;
}
getKeyword(): string {
return this.keyword;
}
// --- Callbacks --- // --- Callbacks ---
onWakeWord(callback: WakeWordCallback): () => void { onWakeWord(callback: WakeWordCallback): () => void {