diff --git a/android/src/components/MessageText.tsx b/android/src/components/MessageText.tsx index 41ec505..cba5842 100644 --- a/android/src/components/MessageText.tsx +++ b/android/src/components/MessageText.tsx @@ -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 ohne nested + * 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 = ({ text, style }) => { - const segments = React.useMemo(() => tokenize(text), [text]); return ( - - {segments.map((seg, i) => { - if (seg.kind === 'text') { - return {seg.text}; - } - return ( - onPress(seg)} - // Long-Press soll an den Parent durch fuer Selection - onLongPress={undefined} - suppressHighlighting={false} - > - {seg.text} - - ); - })} + + {text} ); }; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 8178569..233666b 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -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) => { diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index bd197c0..8bac3c1 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -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 | 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 { @@ -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.)