From 3d001a1d0302ff1fa61b67005895ffb101504e51 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 22:31:26 +0200 Subject: [PATCH] feat(app): manueller Aufnahme-Knopf nutzt jetzt auch Streaming-STT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceButton rewrite — dB/VAD-Pfad endgueltig raus. Knopf ist jetzt nur noch UI-Trigger: - onTapStart (ChatScreen baut Bubble + startStreamingRecording) - onTapStop (ChatScreen ruft stopStreamingRecording) - audioService.onStateChange treibt die Animation (statt internem isRecording-Flag) - onSilenceDetected-Subscription weg ChatScreen: - handleVoiceRecording (Legacy) → handleVoiceButtonStart + handleVoiceButtonStop - Bubble wird beim Tap SOFORT gebaut (vorher: erst nach Stop), Text landet via audioRequestId-Match im chat-Handler-Update-Pfad - noSpeechTimeoutMs=0 (manueller Modus, User kontrolliert via Tap), hardCapMs=300_000 (5 Minuten Notbremse) - Wake-Word-conversing + manueller Stop = endConversation (User will nicht in Multi-Turn-Modus) - RecordingResult-Import entfaellt (nicht mehr genutzt) Damit ist die komplette App-seitige Aufnahme auf Streaming + ML- Endpointer. Der ganze dB/VAD-Apparat (vadEnabled, vadBaselineSamples, loadVadSilenceDbOverride, vadTimer, noSpeechTimer, etc.) ist jetzt nur noch Dead-Code — wird in einem Folge-Commit gemeinsam mit dem zugehoerigen Settings-Slider abgeraeumt. --- android/src/components/VoiceButton.tsx | 123 ++++++++++--------------- android/src/screens/ChatScreen.tsx | 59 +++++++----- 2 files changed, 86 insertions(+), 96 deletions(-) diff --git a/android/src/components/VoiceButton.tsx b/android/src/components/VoiceButton.tsx index 3839167..e62db91 100644 --- a/android/src/components/VoiceButton.tsx +++ b/android/src/components/VoiceButton.tsx @@ -1,12 +1,19 @@ /** - * VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf + * VoiceButton — Tap-to-Talk-Aufnahmeknopf (Streaming-Variante). * - * Zwei Modi: - * 1. Push-to-Talk: gedrueckt halten zum Aufnehmen, loslassen zum Senden - * 2. Tap-to-Talk: einmal tippen startet Aufnahme, VAD stoppt automatisch bei Stille - * (auch genutzt fuer Wake-Word-getriggerte Aufnahme) + * Push-to-Talk gibt's nicht mehr. Tap startet Streaming-Aufnahme an die + * Whisper-Bridge. Tap nochmal sendet stt_stream_end → Whisper liefert den + * finalen Text → aria-bridge forwardet direkt an Brain. Keine dB/VAD- + * Stille-Erkennung mehr — Whisper hoert auf semantische Stille (kein + * neuer Text mehr). * - * Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme. + * Diese Komponente ist absichtlich "dumm": sie kapselt nur den + * Tap-Lifecycle + die Animation. Recording-Optionen (voice/speed/ + * location/interrupted) baut ChatScreen, die User-Bubble ebenfalls. + * + * Visuelles Feedback: pulsierende Animation + Dauer + dB-Pegel via + * audioService.onMeterUpdate (das macht audio.ts noch fuer alte Records; + * neu kommt der Pegel via NativeEventEmitter (PcmStreamMeter) — folgt). */ import React, { useState, useRef, useEffect, useCallback } from 'react'; @@ -17,25 +24,28 @@ import { StyleSheet, Easing, TouchableOpacity, - Pressable, } from 'react-native'; -import audioService, { RecordingResult } from '../services/audio'; +import audioService, { RecordingState } from '../services/audio'; // --- Typen --- interface VoiceButtonProps { - /** Wird aufgerufen wenn die Aufnahme fertig ist */ - onRecordingComplete: (result: RecordingResult) => void; + /** User hat getippt — ChatScreen soll Bubble bauen + startStreamingRecording. + * Returns true wenn die Aufnahme tatsaechlich gestartet ist. */ + onTapStart: () => Promise; + /** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */ + onTapStop: () => Promise; /** Button deaktivieren */ disabled?: boolean; - /** Wake-Word-Modus aktiv (zeigt Indikator) */ + /** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */ wakeWordActive?: boolean; } // --- Komponente --- const VoiceButton: React.FC = ({ - onRecordingComplete, + onTapStart, + onTapStop, disabled = false, wakeWordActive = false, }) => { @@ -45,6 +55,21 @@ const VoiceButton: React.FC = ({ const pulseAnim = useRef(new Animated.Value(1)).current; const durationTimer = useRef | null>(null); + // State via audioService.onStateChange spiegeln — der Service ist die + // Quelle der Wahrheit (Streaming-Session, Wake-Word-Multi-Turn, etc. + // koennen den Recording-State von extern aendern). isStreamingRecording + // ist auch true wenn die Wake-Word-Konversation gerade aufzeichnet — + // dann zeigt der Button "stop"-Symbol, und Tap stoppt die laufende + // Aufnahme (egal ob via Wake-Word oder Knopf gestartet). + useEffect(() => { + const unsub = audioService.onStateChange((next: RecordingState) => { + setIsRecording(next === 'recording'); + }); + // Initial-State synchronisieren + setIsRecording(audioService.getRecordingState() === 'recording'); + return unsub; + }, []); + // Puls-Animation starten/stoppen useEffect(() => { if (isRecording) { @@ -71,14 +96,13 @@ const VoiceButton: React.FC = ({ } }, [isRecording, pulseAnim]); - // Aufnahmedauer zaehlen + Metering + // Aufnahmedauer zaehlen + Metering (Pegel-Bar) useEffect(() => { if (isRecording) { setDurationMs(0); durationTimer.current = setInterval(() => { setDurationMs(prev => prev + 100); }, 100); - const unsubMeter = audioService.onMeterUpdate(setMeterDb); return () => { unsubMeter(); @@ -89,74 +113,28 @@ const VoiceButton: React.FC = ({ clearInterval(durationTimer.current); durationTimer.current = null; } + setMeterDb(-160); } }, [isRecording]); - // VAD Silence Callback — Auto-Stop. - // WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen - // audioService selber fragen. Empty deps → Listener wird EINMAL registriert. - // audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal - // feuert (silenceFired-Latch). - const onCompleteRef = useRef(onRecordingComplete); - useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]); - useEffect(() => { - const unsubSilence = audioService.onSilenceDetected(async () => { - if (audioService.getRecordingState() !== 'recording') return; - const result = await audioService.stopRecording(); - setIsRecording(false); - if (result && result.durationMs > 500) { - onCompleteRef.current(result); - } - }); - return unsubSilence; - }, []); - - // Auto-Start fuer Wake Word (extern getriggert) - const startAutoRecording = useCallback(async () => { - if (disabled || isRecording) return; - const started = await audioService.startRecording(true); // autoStop = true - if (started) { - setIsRecording(true); - } - }, [disabled, isRecording]); - - // Tap-to-Talk: Einmal tippen startet mit Auto-Stop. - // Guard gegen Doppel-Tap während asyncer Start/Stop. + // Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop. const tapBusy = useRef(false); - const handleTap = async () => { + const handleTap = useCallback(async () => { if (disabled || tapBusy.current) return; tapBusy.current = true; try { - // Fragen WIR den Service, nicht den React-State (Closure kann stale sein) + // Service-State fragen statt React-State (Closure koennte stale sein) const svcState = audioService.getRecordingState(); if (svcState === 'recording') { - // Aufnahme manuell stoppen - const result = await audioService.stopRecording(); - setIsRecording(false); - if (result && result.durationMs > 300) { - onRecordingComplete(result); - } + await onTapStop(); } else if (svcState === 'idle') { - // Aufnahme mit Auto-Stop starten - const started = await audioService.startRecording(true); - if (started) { - setIsRecording(true); - } + await onTapStart(); } - // svcState === 'processing': Stopp in progress — nichts tun, User - // muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy - // kurz damit der User's UI-Feedback synchron bleibt. + // 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen } finally { tapBusy.current = false; } - }; - - // Expose startAutoRecording via ref fuer Wake Word - React.useImperativeHandle( - React.createRef(), - () => ({ startAutoRecording }), - [startAutoRecording], - ); + }, [disabled, onTapStart, onTapStop]); const formatDuration = (ms: number): string => { const seconds = Math.floor(ms / 1000); @@ -164,7 +142,11 @@ const VoiceButton: React.FC = ({ return `${seconds}.${tenths}s`; }; - // Meter-Visualisierung (0-1 Skala) + // Meter-Visualisierung (-60..0 dB → 0..1). Bei Streaming-Mode liefert + // audio.ts (noch) keinen Pegel, also bleibt der Balken leer — wird in + // einem Folge-Commit nachgerueckt (PcmStreamRecorder-Module muss dafuer + // einen RMS-Wert mit-emitten). Tut der Streaming-Funktion keinen Abbruch, + // ist reines UI-Beiwerk. const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60)); return ( @@ -198,9 +180,6 @@ const VoiceButton: React.FC = ({ ); }; -// Expose startAutoRecording fuer externe Aufrufe (Wake Word) -export type VoiceButtonHandle = { startAutoRecording: () => Promise }; - // --- Styles --- const styles = StyleSheet.create({ diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 8846018..7781445 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -47,7 +47,7 @@ import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; import MessageText from '../components/MessageText'; -import { RecordingResult, loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio'; +import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; // --- Typen --- @@ -1761,49 +1761,59 @@ const ChatScreen: React.FC = () => { return true; }, [agentActivity]); - // Sprachaufnahme abgeschlossen - const handleVoiceRecording = useCallback(async (result: RecordingResult) => { - // Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv. + // Manueller Aufnahme-Knopf (VoiceButton) — Start. + // Streaming-Variante: PcmStreamRecorder + Whisper-ML-Endpointer ersetzen + // die alte dB-VAD-Schleife. Knopf-1.-Tap startet, Knopf-2.-Tap stoppt. + // Bubble bauen wir SOFORT damit der User sofort Feedback hat — Text wird + // ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert. + const handleVoiceButtonStart = useCallback(async (): Promise => { + const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const wasInterrupted = interruptAriaIfBusy(); const location = await getCurrentLocation(); - const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`; - const cmid = nextClientMsgId(); const userMsg: ChatMessage = { id: nextId(), sender: 'user', text: '🎙 Spracheingabe wird verarbeitet...', timestamp: Date.now(), + attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], audioRequestId, - clientMsgId: cmid, - deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', - sendAttempts: 1, }; setMessages(prev => capMessages([...prev, userMsg])); - dispatchWithAck(cmid, 'audio', { - base64: result.base64, - durationMs: result.durationMs, - mimeType: result.mimeType, + const { ok } = await audioService.startStreamingRecording({ + audioRequestId, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, interrupted: wasInterrupted, - audioRequestId, - ...(location && { location }), + location: location || null, + // Manueller Knopf: kein no-speech-Watchdog (User kontrolliert via Tap-zum- + // Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper + // die Session auch app-seitig haben wir +2s Toleranz. + noSpeechTimeoutMs: 0, + endpointMs: 1500, + hardCapMs: 300000, }); - scheduleStaleAudioCleanup(audioRequestId, result.durationMs); + if (!ok) { + // Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg. + setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId)); + return false; + } + scheduleStaleAudioCleanup(audioRequestId, 60000); + return true; + }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]); - // Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit - // den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn- - // Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen. - // Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback - // und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"- - // Knopf. + // Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die + // dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge + // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich + // endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume. + const handleVoiceButtonStop = useCallback(async (): Promise => { + await audioService.stopStreamingRecording('user'); if (wakeWordService.isConversing()) { console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed'); await wakeWordService.endConversation(); } - }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]); + }, []); // Datei auswaehlen → zur Pending-Liste hinzufuegen const handleFileSelected = useCallback(async (file: FileData) => { @@ -2572,7 +2582,8 @@ const ChatScreen: React.FC = () => { ) : ( <>