6651f5937d
Du kannst jetzt "Computer" sagen waehrend ARIA noch redet — TTS verstummt, neue Aufnahme startet. Vorher musste man warten oder manuell den Voice-Button tappen. Native (OpenWakeWordModule.kt): - AudioRecord-Source von MIC auf VOICE_COMMUNICATION (aktiviert auf den meisten Geraeten Echo-Cancellation + Noise-Suppression) - Zusaetzlich AcousticEchoCanceler/NoiseSuppressor/AutomaticGainControl explizit aktiviert wenn vorhanden — robuster auf Geraeten wo die VOICE_COMMUNICATION-Source die Effects nicht automatisch mitbringt - releaseAudioEffects() im stop/dispose JS (wakeword.ts): - Neue API: startBargeListening / stopBargeListening — Wake-Word parallel aktivieren, ohne den State 'conversing' zu verlassen - onWakeDetected unterscheidet jetzt: in 'conversing' → barge-in- Callback (nicht der normale wake-callback). Sonst Standard-Pfad. - onBargeIn-Subscriber-API + isBargeListening-Getter Lifecycle-Wiring (audio.ts + ChatScreen): - audioService.onPlaybackStarted callback (neu) - ChatScreen: Bei TTS-Start → wakeWord.startBargeListening - ChatScreen: Bei TTS-Ende → wakeWord.stopBargeListening (sonst kein AudioRecord fuer die naechste Aufnahme) - ChatScreen: Bei BargeIn → haltAllPlayback + cancel_request + 150ms-Pause + neue Aufnahme starten issue.md + README aktualisiert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
/**
|
|
* 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<WakeKeyword, string> = {
|
|
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<boolean>;
|
|
start(): Promise<boolean>;
|
|
stop(): Promise<boolean>;
|
|
dispose(): Promise<boolean>;
|
|
isAvailable(): Promise<boolean>;
|
|
}
|
|
|
|
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
|
|
|
|
class WakeWordService {
|
|
private state: WakeWordState = 'off';
|
|
private wakeCallbacks: WakeWordCallback[] = [];
|
|
private stateCallbacks: StateCallback[] = [];
|
|
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
|
|
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
|
|
private bargeCallbacks: WakeWordCallback[] = [];
|
|
/** True solange Wake-Word parallel zu TTS aktiv ist. */
|
|
private bargeListening: boolean = false;
|
|
|
|
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
|
private nativeReady: boolean = false;
|
|
private initInProgress: Promise<boolean> | null = null;
|
|
private eventSub: { remove: () => void } | null = null;
|
|
|
|
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
|
|
async loadFromStorage(): Promise<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
if (!OpenWakeWord) return;
|
|
try { await OpenWakeWord.dispose(); } catch {}
|
|
this.nativeReady = false;
|
|
}
|
|
|
|
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
|
async start(): Promise<boolean> {
|
|
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<void> {
|
|
console.log('[WakeWord] Ohr deaktiviert');
|
|
if (this.nativeReady && OpenWakeWord) {
|
|
try { await OpenWakeWord.stop(); } catch {}
|
|
}
|
|
this.bargeListening = false;
|
|
this.setState('off');
|
|
}
|
|
|
|
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
|
private async onWakeDetected(): Promise<void> {
|
|
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
|
this.keyword, this.state, this.bargeListening);
|
|
if (this.nativeReady && OpenWakeWord) {
|
|
try { await OpenWakeWord.stop(); } catch {}
|
|
}
|
|
this.bargeListening = false;
|
|
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
|
|
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
|
|
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
|
|
if (this.state === 'conversing') {
|
|
this.bargeCallbacks.forEach(cb => {
|
|
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
|
|
});
|
|
// Kein erneutes setState — wir bleiben in 'conversing'.
|
|
return;
|
|
}
|
|
this.setState('conversing');
|
|
setTimeout(() => {
|
|
if (this.state === 'conversing') {
|
|
this.wakeCallbacks.forEach(cb => cb());
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
|
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
|
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
|
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
|
|
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
|
|
async startBargeListening(): Promise<void> {
|
|
if (!this.nativeReady || !OpenWakeWord) return;
|
|
if (this.state !== 'conversing') return;
|
|
if (this.bargeListening) return;
|
|
try {
|
|
await OpenWakeWord.start();
|
|
this.bargeListening = true;
|
|
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
|
|
} catch (err) {
|
|
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
|
|
}
|
|
}
|
|
|
|
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
|
|
* naechste Aufnahme das Mikro braucht. */
|
|
async stopBargeListening(): Promise<void> {
|
|
if (!this.bargeListening) return;
|
|
if (this.nativeReady && OpenWakeWord) {
|
|
try { await OpenWakeWord.stop(); } catch {}
|
|
}
|
|
this.bargeListening = false;
|
|
console.log('[WakeWord] Barge-Listening aus');
|
|
}
|
|
|
|
/** Konversation beenden — User hat im Window nichts gesagt.
|
|
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
|
|
* Ohne: zurueck zu 'off'.
|
|
*/
|
|
async endConversation(): Promise<void> {
|
|
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<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.nativeReady;
|
|
}
|
|
|
|
getKeyword(): WakeKeyword {
|
|
return this.keyword;
|
|
}
|
|
|
|
// --- Callbacks ---
|
|
|
|
onWakeWord(callback: WakeWordCallback): () => void {
|
|
this.wakeCallbacks.push(callback);
|
|
return () => {
|
|
this.wakeCallbacks = this.wakeCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
|
|
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
|
|
onBargeIn(callback: WakeWordCallback): () => void {
|
|
this.bargeCallbacks.push(callback);
|
|
return () => {
|
|
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
isBargeListening(): boolean {
|
|
return this.bargeListening;
|
|
}
|
|
|
|
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;
|