| import { useState, useEffect, useRef } from 'react'; |
| import { Mic, MicOff, Loader } from 'lucide-react'; |
| import api from '../api/axios'; |
|
|
| const VoiceInput = ({ onTransactionAdded }) => { |
| const [isListening, setIsListening] = useState(false); |
| const [isProcessing, setIsProcessing] = useState(false); |
| const [error, setError] = useState(null); |
| const recognitionRef = useRef(null); |
| const silenceTimerRef = useRef(null); |
|
|
| const stopSilenceTimer = () => { |
| if (silenceTimerRef.current) { |
| clearTimeout(silenceTimerRef.current); |
| silenceTimerRef.current = null; |
| } |
| }; |
|
|
| const startSilenceTimer = () => { |
| stopSilenceTimer(); |
| silenceTimerRef.current = setTimeout(() => { |
| console.log("Silence timeout reached. Stopping recognition."); |
| if (recognitionRef.current) recognitionRef.current.stop(); |
| }, 6000); |
| }; |
|
|
| useEffect(() => { |
| if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; |
| recognitionRef.current = new SpeechRecognition(); |
| recognitionRef.current.continuous = true; |
| recognitionRef.current.interimResults = true; |
| recognitionRef.current.lang = 'en-US'; |
|
|
| recognitionRef.current.onstart = () => { |
| setIsListening(true); |
| setError(null); |
| startSilenceTimer(); |
| }; |
|
|
| recognitionRef.current.onend = () => { |
| setIsListening(false); |
| stopSilenceTimer(); |
| }; |
|
|
| recognitionRef.current.onerror = (event) => { |
| console.error("Speech recognition error", event.error); |
| if (event.error !== 'no-speech') { |
| setError(`Voice error: ${event.error}`); |
| } |
| setIsListening(false); |
| stopSilenceTimer(); |
| }; |
|
|
| recognitionRef.current.onresult = (event) => { |
| startSilenceTimer(); |
|
|
| let finalTranscript = ''; |
| for (let i = event.resultIndex; i < event.results.length; ++i) { |
| if (event.results[i].isFinal) { |
| finalTranscript += event.results[i][0].transcript; |
| } |
| } |
|
|
| if (finalTranscript) { |
| console.log("Heard Final:", finalTranscript); |
| sendTextCheck(finalTranscript); |
| recognitionRef.current.stop(); |
| } |
| }; |
| } else { |
| setError("Browser does not support Web Speech API."); |
| } |
|
|
| return () => { |
| if (recognitionRef.current) recognitionRef.current.stop(); |
| stopSilenceTimer(); |
| }; |
| }, []); |
|
|
| const toggleListening = () => { |
| if (isListening) { |
| recognitionRef.current.stop(); |
| } else { |
| recognitionRef.current.start(); |
| } |
| }; |
|
|
| const sendTextCheck = async (text) => { |
| setIsProcessing(true); |
| try { |
| const response = await api.post('finance/voice/command/', { text }); |
|
|
| console.log("Voice Response:", response.data); |
|
|
| if (response.data.transaction) { |
| if (onTransactionAdded) onTransactionAdded(); |
| alert(`Added: ${response.data.transaction.title} - ${response.data.parsed.amount}`); |
| } else { |
| alert(`Heard: "${response.data.text}". Could not verify details.`); |
| } |
|
|
| } catch (err) { |
| console.error("Voice Command Error:", err); |
| setError(err.response?.data?.error || "Failed to process voice command"); |
| } finally { |
| setIsProcessing(false); |
| } |
| }; |
|
|
| return ( |
| <div style={{ position: 'fixed', bottom: '2rem', right: '2rem', zIndex: 1000 }}> |
| {error && ( |
| <div style={{ |
| position: 'absolute', bottom: '100%', right: 0, marginBottom: '0.5rem', |
| background: 'rgba(239, 68, 68, 0.9)', color: 'white', padding: '0.5rem', |
| borderRadius: '0.5rem', fontSize: '0.8rem', whiteSpace: 'nowrap' |
| }}> |
| {error} |
| </div> |
| )} |
| |
| <button |
| onClick={toggleListening} |
| disabled={isProcessing || !!error} |
| style={{ |
| width: '60px', height: '60px', |
| borderRadius: '50%', |
| border: 'none', |
| background: isListening ? '#ef4444' : '#6366f1', |
| color: 'white', |
| display: 'flex', justifyContent: 'center', alignItems: 'center', |
| boxShadow: '0 4px 15px rgba(0,0,0,0.3)', |
| cursor: isProcessing ? 'wait' : 'pointer', |
| transition: 'all 0.3s transform', |
| transform: isListening ? 'scale(1.1)' : 'scale(1)', |
| animation: isListening ? 'pulse 1.5s infinite' : 'none', |
| opacity: error ? 0.5 : 1 |
| }} |
| title={error || "Speak to add transaction"} |
| > |
| {isProcessing ? ( |
| <Loader className="animate-spin" size={24} /> |
| ) : isListening ? ( |
| <MicOff size={24} /> |
| ) : ( |
| <Mic size={24} /> |
| )} |
| </button> |
| |
| {/* Voice Tip */} |
| <div style={{ |
| position: 'absolute', |
| top: '100%', |
| right: '50%', |
| transform: 'translateX(50%)', |
| marginTop: '8px', |
| background: 'rgba(0,0,0,0.7)', |
| color: 'white', |
| padding: '4px 8px', |
| borderRadius: '4px', |
| fontSize: '10px', |
| whiteSpace: 'nowrap', |
| pointerEvents: 'none' |
| }}> |
| Add Expense/Income for Amount |
| </div> |
| |
| <style>{` |
| @keyframes pulse { |
| 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); } |
| 70% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); } |
| 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } |
| } |
| `}</style> |
| </div> |
| ); |
| }; |
|
|
| export default VoiceInput; |
|
|