fix: Textauswahl, adaptive VAD-Schwelle + Barge-In bei Sprachaufnahme
Bug 1 — Textauswahl in Bubbles ging nicht mehr:
MessageText hatte verschachtelte <Text onPress={...}> fuer Custom-Link-
Styling. Das fing die Long-Press-Geste ab, daher kein Markieren+Kopieren
mehr. Jetzt nur noch ein einzelnes <Text selectable dataDetectorType="all">,
Android macht URLs/Telefonnummern/Emails per System-Detection klickbar.
Bug 2 — VAD erkannte Stille nicht zuverlaessig (Aufnahme lief endlos):
Festwerte (-45dB Stille / -28dB Sprache) passten nicht zu jeder Umgebung.
In lauteren Raeumen lag der Hintergrundpegel ueber der Stille-Schwelle,
lastSpeechTime wurde dauerhaft aktualisiert → VAD feuerte nie, Aufnahme
lief bis 120s Max-Duration.
Jetzt adaptiv: erste 5 Mic-Samples (~500ms) bilden die Baseline; Stille-
Schwelle = baseline+6dB, Sprache-Schwelle = baseline+12dB. Toast zeigt
die kalibrierten Werte beim Aufnahmestart. Fallback auf -38dB/-22dB falls
das Mikro keine Metering-Updates liefert.
Bug 3 — Barge-In ("ach vergiss es"):
Wenn waehrend ARIAs Antwort eine neue Sprachnachricht aufgenommen wird,
wird ARIAs aktuelle Aktivitaet (TTS + thinking/tool) sofort abgebrochen
bevor die neue Message gesendet wird — wie in einem echten Gespraech wo
man den anderen unterbrechen darf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,68 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* MessageText — rendert Chat-Text mit Auto-Linkifizierung:
|
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
|
||||||
* - http(s)://... → tippbar, oeffnet im Browser
|
|
||||||
* - mailto: oder plain E-Mail → tippbar, oeffnet Mail-App
|
|
||||||
* - Telefonnummern → tippbar, oeffnet Android-Dialer
|
|
||||||
*
|
*
|
||||||
* Text ist durchgaengig markierbar/kopierbar (selectable).
|
* Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
|
||||||
|
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
|
||||||
|
* <Text> mit eigenem onPress. Nested Text mit onPress fingen die Long-Press-
|
||||||
|
* Geste ab, damit war Markieren+Kopieren defekt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text, Linking, TextStyle, StyleProp } from 'react-native';
|
import { Text, TextStyle, StyleProp } from 'react-native';
|
||||||
|
|
||||||
// Regex kombiniert URL | Email | Telefonnummer.
|
|
||||||
// Gruppenreihenfolge ist wichtig fuer die Erkennung unten.
|
|
||||||
//
|
|
||||||
// URL: http://... oder https://... bis zum ersten Whitespace / Anfuehrungszeichen.
|
|
||||||
// Email: simpler Standard-Match (kein RFC-kompatibel aber gut genug).
|
|
||||||
// Telefon: internationale Form (+49..., 0049..., 0176...), darf Leerzeichen
|
|
||||||
// / Bindestriche / Schraegstriche / Klammern enthalten, mindestens 7
|
|
||||||
// Ziffern insgesamt. Vermeidet banale Zahlen (Uhrzeiten, Datum).
|
|
||||||
const LINK_REGEX = new RegExp(
|
|
||||||
'(https?:\\/\\/[^\\s<>"]+)' + // 1: URL
|
|
||||||
'|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})' + // 2: Email
|
|
||||||
'|((?:\\+|00)\\d[\\d\\s()\\-\\/]{6,}\\d|0\\d{2,4}[\\s\\/\\-]?[\\d\\s\\-\\/]{5,}\\d)', // 3: Telefon
|
|
||||||
'g',
|
|
||||||
);
|
|
||||||
|
|
||||||
const LINK_STYLE = { color: '#0096FF', textDecorationLine: 'underline' } as TextStyle;
|
|
||||||
|
|
||||||
interface Segment {
|
|
||||||
text: string;
|
|
||||||
kind: 'text' | 'url' | 'email' | 'phone';
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(raw: string): Segment[] {
|
|
||||||
const out: Segment[] = [];
|
|
||||||
let lastEnd = 0;
|
|
||||||
LINK_REGEX.lastIndex = 0;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = LINK_REGEX.exec(raw)) !== null) {
|
|
||||||
if (m.index > lastEnd) {
|
|
||||||
out.push({ text: raw.slice(lastEnd, m.index), kind: 'text' });
|
|
||||||
}
|
|
||||||
if (m[1]) out.push({ text: m[1], kind: 'url' });
|
|
||||||
else if (m[2]) out.push({ text: m[2], kind: 'email' });
|
|
||||||
else if (m[3]) out.push({ text: m[3], kind: 'phone' });
|
|
||||||
lastEnd = LINK_REGEX.lastIndex;
|
|
||||||
}
|
|
||||||
if (lastEnd < raw.length) out.push({ text: raw.slice(lastEnd), kind: 'text' });
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPress(seg: Segment) {
|
|
||||||
try {
|
|
||||||
if (seg.kind === 'url') {
|
|
||||||
Linking.openURL(seg.text);
|
|
||||||
} else if (seg.kind === 'email') {
|
|
||||||
Linking.openURL(`mailto:${seg.text}`);
|
|
||||||
} else if (seg.kind === 'phone') {
|
|
||||||
// Android-Dialer erwartet tel:-Schema ohne Leerzeichen/Bindestriche
|
|
||||||
const clean = seg.text.replace(/[\s\-\/()]/g, '');
|
|
||||||
Linking.openURL(`tel:${clean}`);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -70,34 +16,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageText: React.FC<Props> = ({ text, style }) => {
|
const MessageText: React.FC<Props> = ({ text, style }) => {
|
||||||
const segments = React.useMemo(() => tokenize(text), [text]);
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text style={style} selectable dataDetectorType="all">
|
||||||
style={style}
|
{text}
|
||||||
selectable
|
|
||||||
// dataDetectorType ist Android-only und macht Phone/URL/Email zusaetzlich
|
|
||||||
// ueber System-Detection klickbar — als Fallback falls unsere Regex-
|
|
||||||
// Tokens nicht passen.
|
|
||||||
dataDetectorType="all"
|
|
||||||
>
|
|
||||||
{segments.map((seg, i) => {
|
|
||||||
if (seg.kind === 'text') {
|
|
||||||
return <Text key={i} selectable>{seg.text}</Text>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
key={i}
|
|
||||||
selectable
|
|
||||||
style={LINK_STYLE}
|
|
||||||
onPress={() => onPress(seg)}
|
|
||||||
// Long-Press soll an den Parent durch fuer Selection
|
|
||||||
onLongPress={undefined}
|
|
||||||
suppressHighlighting={false}
|
|
||||||
>
|
|
||||||
{seg.text}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
const result = await audioService.stopRecording();
|
const result = await audioService.stopRecording();
|
||||||
if (result && result.durationMs > 500) {
|
if (result && result.durationMs > 500) {
|
||||||
// User hat im Fenster gesprochen → Sprachnachricht senden
|
// User hat im Fenster gesprochen → Sprachnachricht senden
|
||||||
|
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
|
||||||
|
interruptAriaIfBusy();
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
@@ -648,8 +650,28 @@ const ChatScreen: React.FC = () => {
|
|||||||
rvs.send('cancel_request' as any, {});
|
rvs.send('cancel_request' as any, {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
|
||||||
|
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
|
||||||
|
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
|
||||||
|
// mach lieber X" sagen wie in einem echten Gespraech.
|
||||||
|
const interruptAriaIfBusy = useCallback(() => {
|
||||||
|
const speaking = audioService.isPlayingAudio();
|
||||||
|
const thinking = agentActivity.activity !== 'idle';
|
||||||
|
if (!speaking && !thinking) return false;
|
||||||
|
console.log('[Chat] Barge-In: speaking=%s thinking=%s — interrupting ARIA',
|
||||||
|
speaking, thinking);
|
||||||
|
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
|
||||||
|
if (thinking) {
|
||||||
|
setAgentActivity({ activity: 'idle', tool: '' });
|
||||||
|
rvs.send('cancel_request' as any, {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [agentActivity]);
|
||||||
|
|
||||||
// Sprachaufnahme abgeschlossen
|
// Sprachaufnahme abgeschlossen
|
||||||
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
||||||
|
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
|
||||||
|
interruptAriaIfBusy();
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
@@ -668,7 +690,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
...(location && { location }),
|
...(location && { location }),
|
||||||
});
|
});
|
||||||
}, [getCurrentLocation]);
|
}, [getCurrentLocation, interruptAriaIfBusy]);
|
||||||
|
|
||||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
|
* Nutzt react-native-audio-recorder-player fuer Aufnahme.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
|
import { Platform, PermissionsAndroid, NativeModules, ToastAndroid } from 'react-native';
|
||||||
import Sound from 'react-native-sound';
|
import Sound from 'react-native-sound';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
@@ -72,9 +72,16 @@ const AUDIO_SAMPLE_RATE = 16000;
|
|||||||
const AUDIO_CHANNELS = 1;
|
const AUDIO_CHANNELS = 1;
|
||||||
const AUDIO_ENCODING = 'audio/wav';
|
const AUDIO_ENCODING = 'audio/wav';
|
||||||
|
|
||||||
// VAD (Voice Activity Detection) — Stille-Erkennung
|
// VAD (Voice Activity Detection) — Stille-Erkennung.
|
||||||
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
|
// Fallback-Werte falls die adaptive Baseline-Messung fehlschlaegt (z.B. weil
|
||||||
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
|
// das Mikro keine metering-Updates liefert). Adaptive Werte werden zur
|
||||||
|
// Laufzeit aus den ersten BASELINE_SAMPLES gemessen und auf baseline+offset
|
||||||
|
// gesetzt — funktioniert in lauten wie leisen Umgebungen.
|
||||||
|
const VAD_SILENCE_FALLBACK_DB = -38; // Fallback Stille-Schwelle
|
||||||
|
const VAD_SPEECH_FALLBACK_DB = -22; // Fallback Sprach-Schwelle
|
||||||
|
const VAD_SILENCE_OFFSET_DB = 6; // Sprache = Baseline + 6dB
|
||||||
|
const VAD_SPEECH_OFFSET_DB = 12; // sicheres Speech = Baseline + 12dB
|
||||||
|
const VAD_BASELINE_SAMPLES = 5; // 5 × 100ms = 500ms Baseline
|
||||||
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
|
const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — laenger = keine Huestler/Klopfer mehr
|
||||||
|
|
||||||
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
|
// VAD-Stille (in Sekunden) — wie lange Sprechpause toleriert wird, bevor
|
||||||
@@ -212,6 +219,14 @@ class AudioService {
|
|||||||
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
|
// Latch damit der Silence-Callback pro Aufnahme genau einmal feuert
|
||||||
private silenceFired: boolean = false;
|
private silenceFired: boolean = false;
|
||||||
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
private noSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Adaptive Schwellen — werden in den ersten 500ms aus dem Mikro-Pegel
|
||||||
|
// gemessen. baseline = avg dB der ersten 5 Samples, dann:
|
||||||
|
// silence = baseline + VAD_SILENCE_OFFSET_DB (6dB ueber ambient)
|
||||||
|
// speech = baseline + VAD_SPEECH_OFFSET_DB (12dB ueber ambient = klares Reden)
|
||||||
|
// Funktioniert sowohl im stillen Buero als auch im lauten Cafe.
|
||||||
|
private vadBaselineSamples: number[] = [];
|
||||||
|
private vadAdaptiveSilenceDb: number = VAD_SILENCE_FALLBACK_DB;
|
||||||
|
private vadAdaptiveSpeechDb: number = VAD_SPEECH_FALLBACK_DB;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.recorder = new AudioRecorderPlayer();
|
this.recorder = new AudioRecorderPlayer();
|
||||||
@@ -270,6 +285,14 @@ class AudioService {
|
|||||||
this.stopPlayback();
|
this.stopPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True wenn ARIA gerade was abspielt — egal ob WAV-Queue oder PCM-Stream.
|
||||||
|
* Nuetzlich fuer "Barge-In": wenn der User spricht waehrend ARIA spricht,
|
||||||
|
* soll die ARIA-Wiedergabe abgebrochen + die neue User-Message verarbeitet
|
||||||
|
* werden ("ach vergiss es, mach lieber X"). */
|
||||||
|
isPlayingAudio(): boolean {
|
||||||
|
return this.isPlaying || this.pcmStreamActive;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Berechtigungen ---
|
// --- Berechtigungen ---
|
||||||
|
|
||||||
async requestMicrophonePermission(): Promise<boolean> {
|
async requestMicrophonePermission(): Promise<boolean> {
|
||||||
@@ -341,8 +364,25 @@ class AudioService {
|
|||||||
const db = e.currentMetering ?? -160;
|
const db = e.currentMetering ?? -160;
|
||||||
this.meterListeners.forEach(cb => cb(db));
|
this.meterListeners.forEach(cb => cb(db));
|
||||||
|
|
||||||
|
// Adaptive Baseline: erste 5 Samples (~500ms) sammeln, dann Schwellen
|
||||||
|
// anpassen. -160 (kein Metering) ignorieren — sonst wird die Baseline
|
||||||
|
// sinnlos niedrig.
|
||||||
|
if (this.vadBaselineSamples.length < VAD_BASELINE_SAMPLES) {
|
||||||
|
if (db > -100) {
|
||||||
|
this.vadBaselineSamples.push(db);
|
||||||
|
if (this.vadBaselineSamples.length === VAD_BASELINE_SAMPLES) {
|
||||||
|
const avg = this.vadBaselineSamples.reduce((a, b) => a + b, 0) / VAD_BASELINE_SAMPLES;
|
||||||
|
this.vadAdaptiveSilenceDb = avg + VAD_SILENCE_OFFSET_DB;
|
||||||
|
this.vadAdaptiveSpeechDb = avg + VAD_SPEECH_OFFSET_DB;
|
||||||
|
const msg = `VAD: ambient=${avg.toFixed(0)}dB stille>${this.vadAdaptiveSilenceDb.toFixed(0)}dB`;
|
||||||
|
console.log('[Audio] %s speech>%s', msg, this.vadAdaptiveSpeechDb.toFixed(1));
|
||||||
|
try { ToastAndroid.show(msg, ToastAndroid.SHORT); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
|
// Sprach-Gate: Erkennen ob tatsaechlich gesprochen wird
|
||||||
if (db > VAD_SPEECH_THRESHOLD_DB) {
|
if (db > this.vadAdaptiveSpeechDb) {
|
||||||
if (!this.speechDetected && this.speechStartTime === 0) {
|
if (!this.speechDetected && this.speechStartTime === 0) {
|
||||||
this.speechStartTime = Date.now();
|
this.speechStartTime = Date.now();
|
||||||
}
|
}
|
||||||
@@ -357,7 +397,7 @@ class AudioService {
|
|||||||
|
|
||||||
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
|
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
|
||||||
if (this.vadEnabled) {
|
if (this.vadEnabled) {
|
||||||
if (db > VAD_SILENCE_THRESHOLD_DB) {
|
if (db > this.vadAdaptiveSilenceDb) {
|
||||||
this.lastSpeechTime = Date.now();
|
this.lastSpeechTime = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,6 +407,12 @@ class AudioService {
|
|||||||
this.lastSpeechTime = Date.now();
|
this.lastSpeechTime = Date.now();
|
||||||
this.speechDetected = false;
|
this.speechDetected = false;
|
||||||
this.speechStartTime = 0;
|
this.speechStartTime = 0;
|
||||||
|
// VAD-Adaptive zurueckgesetzt: Baseline wird in den ersten 500ms neu
|
||||||
|
// gemessen. Bis dahin gelten die Fallback-Schwellen — die sind etwas
|
||||||
|
// empfindlicher als die alten Werte (-38 statt -45 fuer Stille).
|
||||||
|
this.vadBaselineSamples = [];
|
||||||
|
this.vadAdaptiveSilenceDb = VAD_SILENCE_FALLBACK_DB;
|
||||||
|
this.vadAdaptiveSpeechDb = VAD_SPEECH_FALLBACK_DB;
|
||||||
this.setState('recording');
|
this.setState('recording');
|
||||||
|
|
||||||
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
|
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
|
||||||
|
|||||||
Reference in New Issue
Block a user