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:
|
||||
* - http(s)://... → tippbar, oeffnet im Browser
|
||||
* - mailto: oder plain E-Mail → tippbar, oeffnet Mail-App
|
||||
* - Telefonnummern → tippbar, oeffnet Android-Dialer
|
||||
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung.
|
||||
*
|
||||
* 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 { Text, Linking, 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 {}
|
||||
}
|
||||
import { Text, TextStyle, StyleProp } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
@@ -70,34 +16,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const MessageText: React.FC<Props> = ({ text, style }) => {
|
||||
const segments = React.useMemo(() => tokenize(text), [text]);
|
||||
return (
|
||||
<Text
|
||||
style={style}
|
||||
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 style={style} selectable dataDetectorType="all">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -504,6 +504,8 @@ const ChatScreen: React.FC = () => {
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 500) {
|
||||
// User hat im Fenster gesprochen → Sprachnachricht senden
|
||||
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
|
||||
interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
@@ -648,8 +650,28 @@ const ChatScreen: React.FC = () => {
|
||||
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
|
||||
const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
|
||||
// Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
|
||||
interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
@@ -668,7 +690,7 @@ const ChatScreen: React.FC = () => {
|
||||
speed: ttsSpeedRef.current,
|
||||
...(location && { location }),
|
||||
});
|
||||
}, [getCurrentLocation]);
|
||||
}, [getCurrentLocation, interruptAriaIfBusy]);
|
||||
|
||||
// Datei auswaehlen → zur Pending-Liste hinzufuegen
|
||||
const handleFileSelected = useCallback(async (file: FileData) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 RNFS from 'react-native-fs';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
@@ -72,9 +72,16 @@ const AUDIO_SAMPLE_RATE = 16000;
|
||||
const AUDIO_CHANNELS = 1;
|
||||
const AUDIO_ENCODING = 'audio/wav';
|
||||
|
||||
// VAD (Voice Activity Detection) — Stille-Erkennung
|
||||
const VAD_SILENCE_THRESHOLD_DB = -45; // dB unter dem als "Stille" gilt
|
||||
const VAD_SPEECH_THRESHOLD_DB = -28; // dB ueber dem als "Sprache" gilt (Sprach-Gate) — hoeher = weniger Umgebungsgeraeusche
|
||||
// VAD (Voice Activity Detection) — Stille-Erkennung.
|
||||
// Fallback-Werte falls die adaptive Baseline-Messung fehlschlaegt (z.B. weil
|
||||
// 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
|
||||
|
||||
// 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
|
||||
private silenceFired: boolean = false;
|
||||
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() {
|
||||
this.recorder = new AudioRecorderPlayer();
|
||||
@@ -270,6 +285,14 @@ class AudioService {
|
||||
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 ---
|
||||
|
||||
async requestMicrophonePermission(): Promise<boolean> {
|
||||
@@ -341,8 +364,25 @@ class AudioService {
|
||||
const db = e.currentMetering ?? -160;
|
||||
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
|
||||
if (db > VAD_SPEECH_THRESHOLD_DB) {
|
||||
if (db > this.vadAdaptiveSpeechDb) {
|
||||
if (!this.speechDetected && this.speechStartTime === 0) {
|
||||
this.speechStartTime = Date.now();
|
||||
}
|
||||
@@ -357,7 +397,7 @@ class AudioService {
|
||||
|
||||
// VAD: Stille erkennen (nur wenn Sprache erkannt wurde)
|
||||
if (this.vadEnabled) {
|
||||
if (db > VAD_SILENCE_THRESHOLD_DB) {
|
||||
if (db > this.vadAdaptiveSilenceDb) {
|
||||
this.lastSpeechTime = Date.now();
|
||||
}
|
||||
}
|
||||
@@ -367,6 +407,12 @@ class AudioService {
|
||||
this.lastSpeechTime = Date.now();
|
||||
this.speechDetected = false;
|
||||
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');
|
||||
|
||||
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
|
||||
|
||||
Reference in New Issue
Block a user