/** * VoiceButton - Push-to-Talk + Auto-Stop Aufnahmeknopf * * 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) * * Visuelles Feedback durch pulsierende Animation waehrend der Aufnahme. */ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, Animated, StyleSheet, Easing, TouchableOpacity, Pressable, } from 'react-native'; import audioService, { RecordingResult } from '../services/audio'; // --- Typen --- interface VoiceButtonProps { /** Wird aufgerufen wenn die Aufnahme fertig ist */ onRecordingComplete: (result: RecordingResult) => void; /** Button deaktivieren */ disabled?: boolean; /** Wake-Word-Modus aktiv (zeigt Indikator) */ wakeWordActive?: boolean; } // --- Komponente --- const VoiceButton: React.FC = ({ onRecordingComplete, 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); const isLongPress = useRef(false); // 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 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; } } }, [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) { isLongPress.current = false; setIsRecording(true); } }, [disabled, isRecording]); // Push-to-Talk: Lang druecken const handlePressIn = async () => { if (disabled || isRecording) return; isLongPress.current = true; const started = await audioService.startRecording(false); // kein autoStop if (started) { setIsRecording(true); } }; const handlePressOut = async () => { if (!isRecording || !isLongPress.current) return; isLongPress.current = false; setIsRecording(false); const result = await audioService.stopRecording(); if (result && result.durationMs > 300) { onRecordingComplete(result); } }; // Tap-to-Talk: Einmal tippen startet mit Auto-Stop. // Guard gegen Doppel-Tap während asyncer Start/Stop. const tapBusy = useRef(false); const handleTap = async () => { if (disabled || tapBusy.current) return; tapBusy.current = true; try { // Fragen WIR den Service, nicht den React-State (Closure kann 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); } } else if (svcState === 'idle') { // Aufnahme mit Auto-Stop starten const started = await audioService.startRecording(true); if (started) { isLongPress.current = false; setIsRecording(true); } } // 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. } finally { tapBusy.current = false; } }; // Expose startAutoRecording via ref fuer Wake Word React.useImperativeHandle( React.createRef(), () => ({ startAutoRecording }), [startAutoRecording], ); const formatDuration = (ms: number): string => { const seconds = Math.floor(ms / 1000); const tenths = Math.floor((ms % 1000) / 100); return `${seconds}.${tenths}s`; }; // Meter-Visualisierung (0-1 Skala) const meterLevel = Math.max(0, Math.min(1, (meterDb + 60) / 60)); return ( {wakeWordActive && !isRecording && ( )} true} onResponderGrant={handlePressIn} onResponderRelease={handlePressOut} onResponderTerminate={handlePressOut} > {isRecording ? '⏹' : '🎙'} {isRecording && ( {formatDuration(durationMs)} )} ); }; // Expose startAutoRecording fuer externe Aufrufe (Wake Word) export type VoiceButtonHandle = { startAutoRecording: () => Promise }; // --- 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;