FarmerHelp / frontend /src /components /voice /VoiceButton.jsx
Gankit12's picture
AAASSs
fb6ccf7
Raw
History Blame Contribute Delete
16.8 kB
/**
* 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 (
<>
<VoiceChat
isOpen={showChat}
onClose={handleCloseChat}
language={language}
/>
<VoiceTutorial
isOpen={showTutorial}
onClose={handleCloseTutorial}
language={language}
/>
</>
);
}
return (
<>
<div
className={`fixed bottom-20 right-4 md:bottom-6 md:right-6 z-40 flex flex-col items-end gap-3 ${className}`}
>
{/* Error notification */}
<AnimatePresence>
{displayError && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="flex items-start gap-2 max-w-xs bg-danger-50 border border-danger-200 text-danger-800 rounded-lg px-3 py-2 text-sm shadow-card"
>
<ExclamationTriangleIcon
className="h-5 w-5 shrink-0 text-danger-500 mt-0.5"
aria-hidden="true"
/>
<span className="flex-1">{displayError}</span>
<button
type="button"
onClick={handleDismissError}
className="shrink-0 p-0.5 rounded hover:bg-danger-100 transition-colors"
aria-label="Dismiss error"
>
<XMarkIcon className="h-4 w-4" />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Transcript panel (one-shot mode) */}
<AnimatePresence>
{showTranscript && (
<VoiceTranscript
transcript={transcript}
interimTranscript={interimTranscript}
isListening={isListening}
commandResult={commandResult}
language={language}
onClose={handleCloseTranscript}
/>
)}
</AnimatePresence>
{/* Button cluster */}
<div className="flex items-center gap-2">
{/* Help button */}
<motion.button
type="button"
onClick={() => 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 }}
>
<QuestionMarkCircleIcon className="h-5 w-5" />
</motion.button>
{/* Chat button */}
<motion.button
type="button"
onClick={handleOpenChat}
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="Open voice chat"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ChatBubbleLeftRightIcon className="h-5 w-5" />
</motion.button>
{/* Microphone FAB */}
<div className="relative">
{/* Pulse rings animation */}
<AnimatePresence>
{isListening && (
<>
<motion.div
className="absolute inset-0 rounded-full bg-primary-400"
initial={{ scale: 1, opacity: 0.4 }}
animate={{ scale: 2, opacity: 0 }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeOut",
}}
/>
<motion.div
className="absolute inset-0 rounded-full bg-primary-400"
initial={{ scale: 1, opacity: 0.3 }}
animate={{ scale: 1.8, opacity: 0 }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeOut",
delay: 0.5,
}}
/>
</>
)}
</AnimatePresence>
<motion.button
type="button"
onClick={handleToggleListening}
className={[
"relative z-10 h-14 w-14 rounded-full shadow-lg",
"flex items-center justify-center",
"transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500",
isListening
? "bg-danger-600 text-white hover:bg-danger-700"
: "bg-primary-600 text-white hover:bg-primary-700",
].join(" ")}
aria-label={
isListening ? "Stop listening" : "Start voice command"
}
aria-pressed={isListening}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.9 }}
>
<MicrophoneIcon className="h-6 w-6" />
</motion.button>
</div>
</div>
</div>
{/* Voice commands help modal */}
<VoiceCommands
isOpen={showCommands}
onClose={() => setShowCommands(false)}
language={language}
/>
{/* First-time tutorial */}
<VoiceTutorial
isOpen={showTutorial}
onClose={handleCloseTutorial}
language={language}
/>
</>
);
}
VoiceButton.propTypes = {
className: PropTypes.string,
};
export default VoiceButton;