268 lines
9.1 KiB
TypeScript
268 lines
9.1 KiB
TypeScript
/**
|
|
* 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<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 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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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 */
|
|
async resume(): Promise<void> {
|
|
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;
|