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 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 12:57:24 +02:00
co-authored by Claude Opus 4.8
parent 353fd98d3f
commit e7da9cf9c4
3 changed files with 63 additions and 10 deletions
+21 -9
View File
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText'; 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'; import Geolocation from '@react-native-community/geolocation';
// --- Typen --- // --- Typen ---
@@ -384,6 +384,8 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest // stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort. // es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true); const converseRef = useRef<boolean>(true);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -662,6 +664,7 @@ const ChatScreen: React.FC = () => {
const voice = await AsyncStorage.getItem('aria_xtts_voice'); const voice = await AsyncStorage.getItem('aria_xtts_voice');
localXttsVoiceRef.current = voice || ''; localXttsVoiceRef.current = voice || '';
ttsSpeedRef.current = await loadTtsSpeed(); ttsSpeedRef.current = await loadTtsSpeed();
bargeInEnabledRef.current = await loadBargeInEnabled();
const gps = await AsyncStorage.getItem('aria_gps_enabled'); const gps = await AsyncStorage.getItem('aria_gps_enabled');
setGpsEnabled(gps === 'true'); setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints'); 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. // Prozess nicht killt wenn die App im Hintergrund ist.
const unsubTtsStart = audioService.onPlaybackStarted(() => { const unsubTtsStart = audioService.onPlaybackStarted(() => {
acquireBackgroundAudio('tts').catch(() => {}); 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(() => {}); wakeWordService.startBargeListening().catch(() => {});
} }
}); });
@@ -2231,15 +2236,16 @@ const ChatScreen: React.FC = () => {
advanceQueue(pid); advanceQueue(pid);
}, [advanceQueue]); }, [advanceQueue]);
// Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs // Nimmt der User das Mikro waehrend ARIA SPRICHT, ist das ein echter Interrupt:
// laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft // TTS stoppen UND die laufende Brain-Antwort abbrechen (cancel_request). Sonst
// serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf). // produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
// Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme // "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
// nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button // Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
// (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr.
const interruptAriaIfBusy = useCallback(() => { const interruptAriaIfBusy = useCallback(() => {
if (audioService.isPlayingAudio()) { 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; return false;
}, []); }, []);
@@ -2293,6 +2299,12 @@ const ChatScreen: React.FC = () => {
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume. // endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => { const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// 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 // Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten. // (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert // exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
+23 -1
View File
@@ -75,6 +75,8 @@ import {
MAX_RECORDING_MIN_SEC, MAX_RECORDING_MIN_SEC,
MAX_RECORDING_MAX_SEC, MAX_RECORDING_MAX_SEC,
MAX_RECORDING_STORAGE_KEY, MAX_RECORDING_STORAGE_KEY,
loadBargeInEnabled,
saveBargeInEnabled,
VAD_SILENCE_DB_DEFAULT, VAD_SILENCE_DB_DEFAULT,
VAD_SILENCE_DB_MIN, VAD_SILENCE_DB_MIN,
VAD_SILENCE_DB_MAX, VAD_SILENCE_DB_MAX,
@@ -204,6 +206,8 @@ const SettingsScreen: React.FC = () => {
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000); const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC); const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC); const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
const [bargeIn, setBargeIn] = useState<boolean>(false);
// null = automatisch (adaptive Baseline), sonst manueller dB-Override // null = automatisch (adaptive Baseline), sonst manueller dB-Override
const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null); const [vadSilenceDb, setVadSilenceDb] = useState<number | null>(null);
const [showVadInfo, setShowVadInfo] = useState(false); 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 => { AsyncStorage.getItem(VAD_SILENCE_DB_OVERRIDE_KEY).then(saved => {
if (saved != null && saved !== '') { if (saved != null && saved !== '') {
const n = parseFloat(saved); const n = parseFloat(saved);
@@ -1666,7 +1671,24 @@ const SettingsScreen: React.FC = () => {
{currentSection === 'voice_input' && (<> {currentSection === 'voice_input' && (<>
<Text style={styles.sectionTitle}>Spracheingabe</Text> <Text style={styles.sectionTitle}>Spracheingabe</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.toggleLabel}>Stille-Toleranz</Text> <View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Barge-in (unterbrechen)</Text>
<Text style={styles.toggleHint}>
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.
</Text>
</View>
<Switch
value={bargeIn}
onValueChange={(v) => { setBargeIn(v); saveBargeInEnabled(v).catch(() => {}); }}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bargeIn ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Stille-Toleranz</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme Wie lange du eine Sprechpause machen darfst, bevor die Aufnahme
automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum automatisch beendet und gesendet wird. Hoeher = mehr Zeit zum
+19
View File
@@ -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_MAX_MS = 8000; // bis 8s: genug Zeit zum Ueberlegen
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms'; 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<boolean> {
try {
return (await AsyncStorage.getItem(BARGE_IN_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BARGE_IN_STORAGE_KEY, String(enabled));
} catch {}
}
export async function loadSttEndpointMs(): Promise<number> { export async function loadSttEndpointMs(): Promise<number> {
try { try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY); const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);