feat: Conversation-Window — Gespraech endet nach Stille statt Endlos-Loop

Der Gespraechsmodus war bisher ein Endless-Loop: Mikro hat sich nach
jeder ARIA-Antwort wieder geoeffnet bis MAX_RECORDING_MS, danach Speech-
Gate verworfen und neu starten. Das Ohr blieb ewig an.

Neue Logik:
  audio.ts: startRecording(autoStop, noSpeechTimeoutMs?) — wenn der User
    innerhalb des Timeouts nicht anfaengt zu sprechen, wird Stille
    gemeldet → stopRecording → Speech-Gate verwirft → result=null.
  wakeword.ts: drei States off/armed/conversing. start() geht direkt in
    'conversing' (kein Wake-Word verfuegbar; Stub fuer spaetere Porcupine-
    Integration). endConversation() bei No-Speech.
  ChatScreen: Aufnahme bekommt das Window aus AsyncStorage durchgereicht.
    Bei null-Result → endConversation, UI-State synchron.
  Settings: neuer +/- Block "Konversations-Fenster" 3-20s (Default 8).

Mit dem Stub ist die Architektur bereit fuer Porcupine: dann geht
endConversation auf 'armed' statt 'off' und der Wake-Word-Detector
laeuft passiv weiter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 15:14:01 +02:00
parent 578ade3544
commit 1b8a51aad0
4 changed files with 166 additions and 32 deletions
+59 -23
View File
@@ -1,56 +1,92 @@
/**
* Gespraechsmodus — "Ohr-Button"
* Gespraechsmodus / Wake Word Service
*
* Wenn aktiv: Nach jeder ARIA-Antwort (TTS fertig) startet automatisch die Aufnahme.
* Wie ein Walkie-Talkie / natuerliches Gespraech:
* ARIA spricht → Aufnahme startet → User spricht → VAD stoppt → ARIA antwortet → ...
* 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).
*
* Phase 2 (geplant): Porcupine "ARIA" Wake Word fuer passives Lauschen.
* Aktuell ohne Porcupine: armed wird nur als Lifecycle-State gefuehrt; bei
* Conversation-Ende geht's direkt auf 'off' damit User klares Feedback bekommt.
*/
type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
export type WakeWordState = 'off' | 'listening' | 'detected';
export type WakeWordState = 'off' | 'armed' | 'conversing';
class WakeWordService {
private state: WakeWordState = 'off';
private wakeCallbacks: WakeWordCallback[] = [];
private stateCallbacks: StateCallback[] = [];
private wakeWordSupported: boolean = false; // wird gesetzt wenn Porcupine spaeter integriert ist
/** Gespraechsmodus starten */
/** Ohr-Button gedrueckt — startet Konversation (oder armed wenn Wake-Word verfuegbar) */
async start(): Promise<boolean> {
if (this.state === 'listening') return true;
console.log('[WakeWord] Gespraechsmodus aktiviert — starte sofort Aufnahme');
this.setState('listening');
// Sofort erste Aufnahme starten
setTimeout(() => {
if (this.state === 'listening') {
this.wakeCallbacks.forEach(cb => cb());
}
}, 500);
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);
}
return true;
}
/** Gespraechsmodus stoppen */
/** Komplett ausschalten (Ohr abschalten) */
stop(): void {
console.log('[WakeWord] Gespraechsmodus deaktiviert');
console.log('[WakeWord] Ohr deaktiviert');
this.setState('off');
}
/** Nach ARIA-Antwort (TTS fertig): Aufnahme automatisch starten */
/** 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');
}
}
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
async resume(): Promise<void> {
if (this.state !== 'listening') return;
if (this.state !== 'conversing') return;
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
await new Promise(resolve => setTimeout(resolve, 800));
if (this.state === 'listening') {
console.log('[WakeWord] TTS fertig — starte automatisch Aufnahme');
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 === 'listening';
return this.state !== 'off';
}
/** True wenn gerade aktiv aufgenommen / mit ARIA gesprochen wird. */
isConversing(): boolean {
return this.state === 'conversing';
}
// --- Callbacks ---