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:
parent
1b8a51aad0
commit
22fa4b3ccf
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue