diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 0d214fa..d713db7 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -29,7 +29,7 @@ import updateService from '../services/updater'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; -import { RecordingResult } from '../services/audio'; +import { RecordingResult, loadConvWindowMs } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; // --- Typen --- @@ -385,10 +385,11 @@ const ChatScreen: React.FC = () => { useEffect(() => { const unsubWake = wakeWordService.onWakeWord(async () => { console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme'); - // Aufnahme mit Auto-Stop (VAD) starten - const started = await audioService.startRecording(true); + // Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus + const windowMs = await loadConvWindowMs(); + const started = await audioService.startRecording(true, windowMs); if (!started) { - // Mikrofon nicht verfuegbar, Wake Word wieder aktivieren + // Mikrofon nicht verfuegbar, naechsten Versuch wakeWordService.resume(); } }); @@ -397,7 +398,7 @@ const ChatScreen: React.FC = () => { const unsubSilence = audioService.onSilenceDetected(async () => { const result = await audioService.stopRecording(); if (result && result.durationMs > 500) { - // Sprachnachricht senden (gleiche Logik wie handleVoiceRecording) + // User hat im Fenster gesprochen → Sprachnachricht senden const location = await getCurrentLocation(); const userMsg: ChatMessage = { id: nextId(), @@ -414,9 +415,14 @@ const ChatScreen: React.FC = () => { voice: localXttsVoiceRef.current, ...(location && { location }), }); + // resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert. + } else { + // Kein Speech im Window → Konversation beenden (Ohr geht aus oder + // bleibt armed wenn Wake Word verfuegbar) + wakeWordService.endConversation(); + // UI-State synchron halten + if (!wakeWordService.isActive()) setWakeWordActive(false); } - // Wake Word wieder aktivieren - if (wakeWordActive) wakeWordService.resume(); }); return () => { diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index d550207..33cbec9 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -31,6 +31,10 @@ import { VAD_SILENCE_MIN_SEC, VAD_SILENCE_MAX_SEC, VAD_SILENCE_STORAGE_KEY, + CONV_WINDOW_DEFAULT_SEC, + CONV_WINDOW_MIN_SEC, + CONV_WINDOW_MAX_SEC, + CONV_WINDOW_STORAGE_KEY, } from '../services/audio'; import ModeSelector from '../components/ModeSelector'; import QRScanner from '../components/QRScanner'; @@ -87,6 +91,7 @@ const SettingsScreen: React.FC = () => { const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsPrerollSec, setTtsPrerollSec] = useState(TTS_PREROLL_DEFAULT_SEC); const [vadSilenceSec, setVadSilenceSec] = useState(VAD_SILENCE_DEFAULT_SEC); + const [convWindowSec, setConvWindowSec] = useState(CONV_WINDOW_DEFAULT_SEC); const [editingPath, setEditingPath] = useState(false); const [xttsVoice, setXttsVoice] = useState(''); const [loadingVoice, setLoadingVoice] = useState(null); @@ -130,6 +135,14 @@ const SettingsScreen: React.FC = () => { } } }); + AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => { + if (saved != null) { + const n = parseFloat(saved); + if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) { + setConvWindowSec(n); + } + } + }); AsyncStorage.getItem('aria_xtts_voice').then(saved => { if (saved) setXttsVoice(saved); }); @@ -603,6 +616,39 @@ const SettingsScreen: React.FC = () => { +0.5 + + Konversations-Fenster + + Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange + Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird. + Sprichst du nichts → Mikrofon zu. + Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s. + + + { + const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10); + setConvWindowSec(next); + AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next)); + }} + disabled={convWindowSec <= CONV_WINDOW_MIN_SEC} + > + −1 + + {convWindowSec.toFixed(0)} s + { + const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10); + setConvWindowSec(next); + AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next)); + }} + disabled={convWindowSec >= CONV_WINDOW_MAX_SEC} + > + +1 + + {/* === Sprachausgabe (geraetelokal) === */} diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 879dbd5..605f530 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -84,6 +84,27 @@ export const VAD_SILENCE_MIN_SEC = 1.0; export const VAD_SILENCE_MAX_SEC = 8.0; export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec'; +// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so +// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation +// beendet wird. Sprichst du im Fenster nichts → Konversation aus. +export const CONV_WINDOW_DEFAULT_SEC = 8.0; +export const CONV_WINDOW_MIN_SEC = 3.0; +export const CONV_WINDOW_MAX_SEC = 20.0; +export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec'; + +export async function loadConvWindowMs(): Promise { + try { + const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY); + if (raw != null) { + const n = parseFloat(raw); + if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) { + return Math.round(n * 1000); + } + } + } catch {} + return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000); +} + async function loadVadSilenceMs(): Promise { try { const raw = await AsyncStorage.getItem(VAD_SILENCE_STORAGE_KEY); @@ -157,6 +178,7 @@ class AudioService { private lastSpeechTime: number = 0; private vadTimer: ReturnType | null = null; private maxDurationTimer: ReturnType | null = null; + private noSpeechTimer: ReturnType | null = null; constructor() { this.recorder = new AudioRecorderPlayer(); @@ -189,8 +211,16 @@ class AudioService { // --- Aufnahme --- - /** Mikrofon-Aufnahme starten */ - async startRecording(autoStop: boolean = false): Promise { + /** Mikrofon-Aufnahme starten. + * + * @param autoStop VAD aktivieren — Auto-Stop bei Stille + * @param noSpeechTimeoutMs Wenn der User innerhalb dieser Zeit nichts sagt, + * wird Stille gemeldet (Recording wird verworfen). + * Fuer Conversation-Window: nach ARIA's Antwort + * hast du nur N Sekunden um anzufangen, sonst + * Gespraech zu Ende. + */ + async startRecording(autoStop: boolean = false, noSpeechTimeoutMs: number = 0): Promise { if (this.recordingState !== 'idle') { console.warn('[Audio] Aufnahme laeuft bereits'); return false; @@ -276,6 +306,18 @@ class AudioService { }, MAX_RECORDING_MS); } + // Conversation-Window: Wenn der User innerhalb noSpeechTimeoutMs nicht + // anfaengt zu sprechen → Aufnahme abbrechen (Speech-Gate verwirft sie), + // ChatScreen erkennt das und beendet die Konversation. + if (noSpeechTimeoutMs > 0) { + this.noSpeechTimer = setTimeout(() => { + if (!this.speechDetected && this.recordingState === 'recording') { + console.log(`[Audio] Conversation-Window ${noSpeechTimeoutMs}ms ohne Sprache — Stop`); + this.silenceListeners.forEach(cb => cb()); + } + }, noSpeechTimeoutMs); + } + console.log('[Audio] Aufnahme gestartet (autoStop: %s)', autoStop); return true; } catch (err) { @@ -302,6 +344,10 @@ class AudioService { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; } + if (this.noSpeechTimer) { + clearTimeout(this.noSpeechTimer); + this.noSpeechTimer = null; + } try { await this.recorder.stopRecorder(); diff --git a/android/src/services/wakeword.ts b/android/src/services/wakeword.ts index ea225b2..8a78d19 100644 --- a/android/src/services/wakeword.ts +++ b/android/src/services/wakeword.ts @@ -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 { - 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 { - 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 ---