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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user