/** * VoiceButton - Floating microphone button for voice interaction. * * Renders as a fixed FAB positioned above the mobile bottom navigation. * Orchestrates the voice interaction lifecycle: * 1. Click mic to start / stop quick listening (one-shot commands) * 2. Display real-time transcript while listening * 3. Parse final transcript into a command intent * 4. Execute navigation or show the help modal * 5. Speak the response via TTS * * Also provides: * - Help button to open VoiceCommands modal * - Chat button to open the full VoiceChat panel * - First-time VoiceTutorial on initial interaction */ import { useState, useEffect, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { MicrophoneIcon } from "@heroicons/react/24/solid"; import { QuestionMarkCircleIcon, ExclamationTriangleIcon, XMarkIcon, ChatBubbleLeftRightIcon, } from "@heroicons/react/24/outline"; import PropTypes from "prop-types"; import useVoice from "@hooks/useVoice"; import useApp from "@hooks/useApp"; import useLocation from "@hooks/useLocation"; import { parseCommand, speak, cancelSpeech, checkCompatibility, requestMicrophonePermission, isTutorialShown, getVoiceSettings, INTENTS, } from "@services/voiceService"; import { sendVoiceMessage } from "@services/voiceAssistantApi"; import VoiceTranscript from "./VoiceTranscript"; import VoiceCommands from "./VoiceCommands"; import VoiceChat from "./VoiceChat"; import VoiceTutorial from "./VoiceTutorial"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PERMISSION_GRANTED = "granted"; const PERMISSION_DENIED = "denied"; /** Duration (ms) to keep the command result visible before auto-dismissing. */ const RESULT_DISPLAY_DURATION_MS = 4000; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** Intents that benefit from Groq-powered backend responses. */ const DATA_INTENTS = new Set([ INTENTS.QUERY_WEATHER_TIME, INTENTS.NAVIGATE_WEATHER, INTENTS.QUERY_CROP_PRICE, INTENTS.NAVIGATE_APMC, INTENTS.QUERY_DISEASE_TREATMENT, INTENTS.NAVIGATE_DISEASE, INTENTS.COMPARE_PRICES, INTENTS.READ_ALERTS, INTENTS.BEST_APMC, ]); /** * Build route state to pass entity data to target pages. * This enables auto-filling search fields and triggering data fetches. */ function _buildRouteState(parsedResult, aiResult, userLocation) { const state = { fromVoice: true }; const entities = parsedResult?.entities || {}; // Pass commodity for APMC page if (entities.crops && entities.crops.length > 0) { state.commodity = entities.crops[0]; } // Pass location for weather page if (userLocation && userLocation.taluka) { state.location = userLocation; } // Also extract commodity from backend response if available if (aiResult?.data?.commodity) { state.commodity = aiResult.data.commodity; } return state; } function VoiceButton({ className = "" }) { const navigate = useNavigate(); const { language } = useApp(); const locationCtx = useLocation(); const { isSupported, isListening, transcript, interimTranscript, error: voiceError, startListening, stopListening, clearError, } = useVoice(); const [showTranscript, setShowTranscript] = useState(false); const [showCommands, setShowCommands] = useState(false); const [showChat, setShowChat] = useState(false); const [showTutorial, setShowTutorial] = useState(false); const [commandResult, setCommandResult] = useState(null); const [micPermission, setMicPermission] = useState(null); const [localError, setLocalError] = useState(null); const lastProcessedTranscript = useRef(""); const compatibility = useRef(checkCompatibility()); const hasInteracted = useRef(false); // --------------------------------------------------------------------------- // Process final transcript into a command (one-shot mode) // --------------------------------------------------------------------------- useEffect(() => { if (showChat) return; // Chat handles its own transcript processing if (!transcript || transcript === lastProcessedTranscript.current) return; lastProcessedTranscript.current = transcript; const result = parseCommand(transcript, language); setCommandResult(result); // Navigation-only commands (home, help) if (result.intent !== INTENTS.UNKNOWN && result.confidence >= 0.5) { if (result.intent === INTENTS.SHOW_HELP) { setShowCommands(true); const settings = getVoiceSettings(); if (settings.autoSpeak) { speak(result.response, language, settings).catch(() => {}); } return; } } // For data-driven intents, call the Groq-powered backend if (DATA_INTENTS.has(result.intent) && result.confidence >= 0.5) { const location = locationCtx.hasLocation ? { state: locationCtx.state, district: locationCtx.district, taluka: locationCtx.taluka } : null; // Show loading state setCommandResult({ ...result, response: language === "hi" ? "जानकारी ला रहा हूँ..." : "Fetching information...", }); sendVoiceMessage({ message: transcript, language, location }) .then((aiResult) => { setCommandResult({ intent: result.intent, confidence: result.confidence, route: aiResult.navigate_to || result.route, response: aiResult.response, }); const settings = getVoiceSettings(); if (settings.autoSpeak) { speak(aiResult.response, language, settings).catch(() => {}); } // Navigate with entity data as route state const targetRoute = aiResult.navigate_to || result.route; if (targetRoute) { const routeState = _buildRouteState(result, aiResult, location); navigate(targetRoute, { state: routeState }); } }) .catch(() => { // Fallback: navigate and speak the basic response setCommandResult(result); if (result.route) { const routeState = _buildRouteState(result, null, location); navigate(result.route, { state: routeState }); } const settings = getVoiceSettings(); if (settings.autoSpeak) { speak(result.response, language, settings).catch(() => {}); } }); return; } // Non-data intents: navigate and speak directly if (result.intent !== INTENTS.UNKNOWN && result.confidence >= 0.5 && result.route) { navigate(result.route); } const settings = getVoiceSettings(); if (settings.autoSpeak) { speak(result.response, language, settings).catch(() => {}); } }, [transcript, language, navigate, showChat, locationCtx]); // --------------------------------------------------------------------------- // Show / hide transcript panel alongside listening state // --------------------------------------------------------------------------- useEffect(() => { if (isListening && !showChat) { setShowTranscript(true); setCommandResult(null); } }, [isListening, showChat]); // --------------------------------------------------------------------------- // Auto-dismiss command result after a short delay // --------------------------------------------------------------------------- useEffect(() => { if (!commandResult) return; const timer = setTimeout(() => { setCommandResult(null); setShowTranscript(false); }, RESULT_DISPLAY_DURATION_MS); return () => clearTimeout(timer); }, [commandResult]); // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- const ensureMicPermission = useCallback(async () => { if (!compatibility.current.isFullySupported) { setLocalError( "Voice recognition is not supported in this browser. Please use Chrome or Edge.", ); return false; } if (micPermission !== PERMISSION_GRANTED) { const state = await requestMicrophonePermission(); setMicPermission(state); if (state === PERMISSION_DENIED) { setLocalError( "Microphone access was denied. Please allow microphone permissions in your browser settings.", ); return false; } } return true; }, [micPermission]); const maybeShowTutorial = useCallback(() => { if (!hasInteracted.current && !isTutorialShown()) { hasInteracted.current = true; setShowTutorial(true); return true; } hasInteracted.current = true; return false; }, []); const handleToggleListening = useCallback(async () => { if (isListening) { stopListening(); return; } // Show tutorial on first interaction if (maybeShowTutorial()) return; const permitted = await ensureMicPermission(); if (!permitted) return; clearError(); setLocalError(null); setCommandResult(null); lastProcessedTranscript.current = ""; startListening(); }, [ isListening, maybeShowTutorial, ensureMicPermission, clearError, startListening, stopListening, ]); const handleOpenChat = useCallback(() => { // Show tutorial on first interaction if (maybeShowTutorial()) return; if (isListening) { stopListening(); } setShowTranscript(false); cancelSpeech(); setShowChat(true); }, [maybeShowTutorial, isListening, stopListening]); const handleCloseChat = useCallback(() => { setShowChat(false); }, []); const handleDismissError = useCallback(() => { setLocalError(null); clearError(); }, [clearError]); const handleCloseTranscript = useCallback(() => { setShowTranscript(false); if (isListening) { stopListening(); } cancelSpeech(); }, [isListening, stopListening]); const handleCloseTutorial = useCallback(() => { setShowTutorial(false); }, []); // --------------------------------------------------------------------------- // Render guards // --------------------------------------------------------------------------- if (!isSupported && !compatibility.current.speechRecognition) { return null; } const displayError = localError || voiceError; // When chat is open, only render the chat panel (hide FAB) if (showChat) { return ( <> ); } return ( <>
{/* Error notification */} {displayError && ( )} {/* Transcript panel (one-shot mode) */} {showTranscript && ( )} {/* Button cluster */}
{/* Help button */} setShowCommands(true)} className={[ "h-10 w-10 rounded-full bg-white border border-neutral-200", "text-neutral-500 shadow-card flex items-center justify-center", "hover:bg-neutral-50 hover:text-neutral-700 transition-colors", "focus:outline-none focus:ring-2 focus:ring-primary-500", ].join(" ")} aria-label="Show voice commands" whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > {/* Chat button */} {/* Microphone FAB */}
{/* Pulse rings animation */} {isListening && ( <> )}
{/* Voice commands help modal */} setShowCommands(false)} language={language} /> {/* First-time tutorial */} ); } VoiceButton.propTypes = { className: PropTypes.string, }; export default VoiceButton;