import React, { useCallback, useEffect, useRef, useState } from "react"; import { Animated, Pressable, Text, View } from "react-native"; import * as Haptics from "expo-haptics"; import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent, } from "expo-speech-recognition"; interface VoiceButtonProps { onTranscript: (text: string) => void; } const VOICE_BUTTON_SIZE = 72; /** * Tap-to-toggle voice button using on-device speech recognition. * - Tap once: start listening * - Tap again: stop and send transcript * - Long-press while listening: cancel (discard) */ export function VoiceButton({ onTranscript }: VoiceButtonProps) { const [isListening, setIsListening] = useState(false); const [transcript, setTranscript] = useState(""); const pulseAnim = useRef(new Animated.Value(1)).current; const glowAnim = useRef(new Animated.Value(0)).current; const pulseLoop = useRef(null); const cancelledRef = useRef(false); // Speech recognition events useSpeechRecognitionEvent("start", () => { setIsListening(true); }); useSpeechRecognitionEvent("end", () => { setIsListening(false); stopPulse(); // Send transcript if we have one and weren't cancelled if (!cancelledRef.current && transcript.trim()) { onTranscript(transcript.trim()); } setTranscript(""); cancelledRef.current = false; }); useSpeechRecognitionEvent("result", (event) => { const text = event.results[0]?.transcript ?? ""; setTranscript(text); }); useSpeechRecognitionEvent("error", (event) => { console.error("Speech recognition error:", event.error, event.message); setIsListening(false); stopPulse(); setTranscript(""); }); const startPulse = useCallback(() => { pulseLoop.current = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.15, duration: 700, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, duration: 700, useNativeDriver: true, }), ]) ); pulseLoop.current.start(); Animated.timing(glowAnim, { toValue: 1, duration: 300, useNativeDriver: true, }).start(); }, [pulseAnim, glowAnim]); const stopPulse = useCallback(() => { pulseLoop.current?.stop(); pulseAnim.setValue(1); Animated.timing(glowAnim, { toValue: 0, duration: 200, useNativeDriver: true, }).start(); }, [pulseAnim, glowAnim]); const startListening = useCallback(async () => { const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync(); if (!result.granted) return; cancelledRef.current = false; setTranscript(""); startPulse(); ExpoSpeechRecognitionModule.start({ lang: "en-US", interimResults: true, continuous: true, }); }, [startPulse]); const stopAndSend = useCallback(() => { stopPulse(); cancelledRef.current = false; ExpoSpeechRecognitionModule.stop(); }, [stopPulse]); const cancelListening = useCallback(() => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); stopPulse(); cancelledRef.current = true; setTranscript(""); ExpoSpeechRecognitionModule.abort(); }, [stopPulse]); const handleTap = useCallback(async () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); if (isListening) { stopAndSend(); } else { await startListening(); } }, [isListening, stopAndSend, startListening]); const handleLongPress = useCallback(() => { if (isListening) { cancelListening(); } }, [isListening, cancelListening]); return ( {/* Outer pulse ring */} {/* Button */} {isListening ? "⏹" : "🎤"} {/* Label / transcript preview */} {isListening ? transcript || "Listening..." : "Tap to talk"} ); }