/** * VoiceButton — Tap-to-Talk-Aufnahmeknopf (Streaming-Variante). * * 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). * * 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'; import { View, Text, Animated, StyleSheet, Easing, TouchableOpacity, } from 'react-native'; import audioService, { RecordingState } from '../services/audio'; // --- Typen --- interface VoiceButtonProps { /** 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 gruenen Indikator-Dot) */ wakeWordActive?: boolean; } // --- Komponente --- const VoiceButton: React.FC = ({ onTapStart, onTapStop, disabled = false, wakeWordActive = false, }) => { const [isRecording, setIsRecording] = useState(false); const [durationMs, setDurationMs] = useState(0); const [meterDb, setMeterDb] = useState(-160); 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) { const pulse = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.2, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ]), ); pulse.start(); return () => pulse.stop(); } else { pulseAnim.setValue(1); } }, [isRecording, pulseAnim]); // 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(); if (durationTimer.current) clearInterval(durationTimer.current); }; } else { if (durationTimer.current) { clearInterval(durationTimer.current); durationTimer.current = null; } setMeterDb(-160); } }, [isRecording]); // Tap-Handler. Guard gegen Doppel-Tap waehrend asyncer Start/Stop. const tapBusy = useRef(false); const handleTap = useCallback(async () => { if (disabled || tapBusy.current) return; tapBusy.current = true; try { // Service-State fragen statt React-State (Closure koennte stale sein) const svcState = audioService.getRecordingState(); if (svcState === 'recording') { await onTapStop(); } else if (svcState === 'idle') { await onTapStart(); } // 'processing': Stop laeuft gerade — nichts tun, User muss nochmal tippen } finally { tapBusy.current = false; } }, [disabled, onTapStart, onTapStop]); const formatDuration = (ms: number): string => { const seconds = Math.floor(ms / 1000); const tenths = Math.floor((ms % 1000) / 100); return `${seconds}.${tenths}s`; }; // 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 ( {wakeWordActive && !isRecording && ( )} {isRecording ? '⏹' : '🎙'} {isRecording && ( {formatDuration(durationMs)} )} ); }; // --- Styles --- const styles = StyleSheet.create({ container: { alignItems: 'center', justifyContent: 'center', }, wakeWordDot: { position: 'absolute', top: -4, right: -4, width: 10, height: 10, borderRadius: 5, backgroundColor: '#34C759', zIndex: 10, }, buttonOuter: { width: 64, height: 64, borderRadius: 32, backgroundColor: 'rgba(0, 150, 255, 0.2)', alignItems: 'center', justifyContent: 'center', }, buttonOuterRecording: { backgroundColor: 'rgba(255, 59, 48, 0.3)', }, buttonInner: { width: 52, height: 52, borderRadius: 26, backgroundColor: '#0096FF', alignItems: 'center', justifyContent: 'center', elevation: 4, shadowColor: '#0096FF', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.4, shadowRadius: 4, }, buttonInnerRecording: { backgroundColor: '#FF3B30', }, buttonIcon: { fontSize: 24, }, infoRow: { alignItems: 'center', marginTop: 4, width: 80, }, meterBar: { height: 3, backgroundColor: '#FF3B30', borderRadius: 2, marginBottom: 2, }, durationText: { color: '#FF3B30', fontSize: 12, fontVariant: ['tabular-nums'], }, }); export default VoiceButton;