From e7da9cf9c42a7f37708bde268861bb36c599a41f Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 15 Aug 2026 12:57:24 +0200 Subject: [PATCH] feat(voice): Halb-Duplex-Default + Barge-in-Schalter + echter Cancel (E2) Behebt den Konversations-Mischmasch: (1) Barge-in-Einstellung (Default AUS = Halb-Duplex) gated das Mikro-Lauschen waehrend TTS -> ARIA spricht ungestoert zu Ende. (2) interruptAriaIfBusy bricht die Brain-Antwort WIRKLICH ab (cancel_request) statt nur TTS zu muten -> sie antwortet nicht mehr weiter waehrend man redet. (3) Stop-Button stoppt vorhersehbar alles (TTS + Brain-Cancel). Musik: nativer Focus ist schon GAIN_TRANSIENT (pausiert) -> der saubere Flow behebt das Focus-Geflacker. Co-Authored-By: Claude Opus 4.8 --- android/src/screens/ChatScreen.tsx | 30 ++++++++++++++++++-------- android/src/screens/SettingsScreen.tsx | 24 ++++++++++++++++++++- android/src/services/audio.ts | 19 ++++++++++++++++ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index d46222b..3bc6f6c 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; import MessageText from '../components/MessageText'; -import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs } from '../services/audio'; +import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; // --- Typen --- @@ -384,6 +384,8 @@ const ChatScreen: React.FC = () => { // stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest // es. Default true (Konversation). false = Einzelaktion/Skill-Antwort. const converseRef = useRef(true); + // Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro). + const bargeInEnabledRef = useRef(false); const flatListRef = useRef(null); const messageIdCounter = useRef(0); @@ -662,6 +664,7 @@ const ChatScreen: React.FC = () => { const voice = await AsyncStorage.getItem('aria_xtts_voice'); localXttsVoiceRef.current = voice || ''; ttsSpeedRef.current = await loadTtsSpeed(); + bargeInEnabledRef.current = await loadBargeInEnabled(); const gps = await AsyncStorage.getItem('aria_gps_enabled'); setGpsEnabled(gps === 'true'); const hints = await AsyncStorage.getItem('aria_show_hints'); @@ -1810,7 +1813,9 @@ const ChatScreen: React.FC = () => { // Prozess nicht killt wenn die App im Hintergrund ist. const unsubTtsStart = audioService.onPlaybackStarted(() => { acquireBackgroundAudio('tts').catch(() => {}); - if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) { + // Barge-Listening (Mikro waehrend TTS) NUR im Barge-in-Modus. Default aus = + // Halb-Duplex: ARIA spricht ungestoert zu Ende, dann erst geht das Mikro auf. + if (bargeInEnabledRef.current && wakeWordService.isConversing() && wakeWordService.hasWakeWord()) { wakeWordService.startBargeListening().catch(() => {}); } }); @@ -2231,15 +2236,16 @@ const ChatScreen: React.FC = () => { advanceQueue(pid); }, [advanceQueue]); - // Queue-Modus (β€žimmer anstellen"): eine neue Sprachnachricht bricht ARIAs - // laufende Arbeit NICHT mehr ab. Sie wird β€” wie Text β€” angestellt und laeuft - // serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf). - // Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme - // nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button - // (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr. + // Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt: + // TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst + // produziert das Brain weiter TTS, die ins offene Mikro laeuft β†’ genau der + // "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes + // Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (πŸ“£). const interruptAriaIfBusy = useCallback(() => { if (audioService.isPlayingAudio()) { - audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)'); + audioService.haltAllPlayback('user startet Aufnahme β€” Interrupt'); + rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' }); + return true; } return false; }, []); @@ -2293,6 +2299,12 @@ const ChatScreen: React.FC = () => { // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich // endConversation: User hat explizit gestoppt β†’ kein Multi-Turn-Resume. const handleVoiceButtonStop = useCallback(async (): Promise => { + // Stop = ALLES beenden, vorhersehbar. Spricht ARIA gerade, hart stoppen + + // laufende Brain-Antwort abbrechen (sonst "sagt sie ihren letzten Satz"). + if (audioService.isPlayingAudio()) { + audioService.haltAllPlayback('user stop'); + rvs.send('cancel_request' as any, { hard: true, source: 'voice-stop' }); + } // Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden // (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten. // exitPassiveListening cancelt den Stream selbst (via _freeMic) β†’ es feuert diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 04813bc..7bd9e76 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -75,6 +75,8 @@ import { MAX_RECORDING_MIN_SEC, MAX_RECORDING_MAX_SEC, MAX_RECORDING_STORAGE_KEY, + loadBargeInEnabled, + saveBargeInEnabled, VAD_SILENCE_DB_DEFAULT, VAD_SILENCE_DB_MIN, VAD_SILENCE_DB_MAX, @@ -204,6 +206,8 @@ const SettingsScreen: React.FC = () => { const [sttEndpointSec, setSttEndpointSec] = useState(STT_ENDPOINT_DEFAULT_MS / 1000); const [convWindowSec, setConvWindowSec] = useState(CONV_WINDOW_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState(MAX_RECORDING_DEFAULT_SEC); + // Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex). + const [bargeIn, setBargeIn] = useState(false); // null = automatisch (adaptive Baseline), sonst manueller dB-Override const [vadSilenceDb, setVadSilenceDb] = useState(null); const [showVadInfo, setShowVadInfo] = useState(false); @@ -329,6 +333,7 @@ const SettingsScreen: React.FC = () => { } } }); + loadBargeInEnabled().then(setBargeIn).catch(() => {}); AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => { if (saved != null && saved !== '') { const n = parseFloat(saved); @@ -1666,7 +1671,24 @@ const SettingsScreen: React.FC = () => { {currentSection === 'voice_input' && (<> Spracheingabe - Stille-Toleranz + + + Barge-in (unterbrechen) + + AUS (empfohlen): ARIA spricht ihre Antwort ZU ENDE, dann geht das + Mikro auf β€” sauber, kein Selbst-Echo, du hoerst sie ganz. AN: du + kannst sie waehrend des Sprechens per Wake-Wort unterbrechen. + + + { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }} + trackColor={{ false: '#2A2A3E', true: '#0096FF' }} + thumbColor={bargeIn ? '#FFFFFF' : '#666680'} + /> + + + Stille-Toleranz Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 08a45f2..9f0fb08 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -160,6 +160,25 @@ export const STT_ENDPOINT_MIN_MS = 1000; export const STT_ENDPOINT_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms'; +// Barge-in-Modus: darf man ARIA waehrend ihrer TTS-Antwort unterbrechen (reden)? +// Default AUS = sauberes Halb-Duplex (ARIA spricht aus, DANN oeffnet das Mikro β€” +// kein Selbst-Echo, kein Mischmasch). AN = waehrend TTS auf Wake-Wort lauschen. +export const BARGE_IN_STORAGE_KEY = 'aria_barge_in_enabled'; + +export async function loadBargeInEnabled(): Promise { + try { + return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true'; + } catch { + return false; + } +} + +export async function saveBargeInEnabled(enabled: boolean): Promise { + try { + await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled)); + } catch {} +} + export async function loadSttEndpointMs(): Promise { try { const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);