/** * VoiceControls Component * Microphone button with hotkey support for voice commands * Visual feedback for listening, processing, and speaking states */ import React, { useState, useEffect, useRef } from 'react'; const VoiceControls = ({ onCommand, hotkey = ' ' }) => { const [state, setState] = useState('idle'); // idle, listening, processing, speaking const [transcript, setTranscript] = useState(''); const [error, setError] = useState(null); const [permissionGranted, setPermissionGranted] = useState(false); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const hotkeyPressedRef = useRef(false); useEffect(() => { // Check if browser supports MediaRecorder if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setError('Voice control not supported in this browser'); return; } // Note: We request permission on mount for better UX. // Alternative: Request only on first use by removing this and letting // startListening() handle the permission request requestMicrophonePermission(); // Setup hotkey listener const handleKeyDown = (e) => { if (e.key === hotkey && !hotkeyPressedRef.current && state === 'idle') { hotkeyPressedRef.current = true; startListening(); } }; const handleKeyUp = (e) => { if (e.key === hotkey && hotkeyPressedRef.current) { hotkeyPressedRef.current = false; if (state === 'listening') { stopListening(); } } }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); if (mediaRecorderRef.current && state === 'listening') { mediaRecorderRef.current.stop(); } }; }, [hotkey, state]); const requestMicrophonePermission = async () => { try { await navigator.mediaDevices.getUserMedia({ audio: true }); setPermissionGranted(true); } catch (err) { setError('Microphone permission denied'); setPermissionGranted(false); } }; const startListening = async () => { if (!permissionGranted) { await requestMicrophonePermission(); if (!permissionGranted) return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunksRef.current.push(event.data); } }; mediaRecorder.onstop = async () => { const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); await processAudio(audioBlob); // Stop all tracks stream.getTracks().forEach(track => track.stop()); }; mediaRecorder.start(); setState('listening'); setTranscript(''); setError(null); } catch (err) { console.error('Error starting recording:', err); setError('Failed to start recording: ' + err.message); } }; const stopListening = () => { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { mediaRecorderRef.current.stop(); } }; const processAudio = async (audioBlob) => { setState('processing'); try { // Send audio to backend for transcription const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); const response = await fetch('/api/voice/transcribe', { method: 'POST', body: formData }); if (!response.ok) { throw new Error('Transcription failed'); } const data = await response.json(); const transcribedText = data.text || ''; setTranscript(transcribedText); if (transcribedText) { // Parse and route the voice command await routeCommand(transcribedText); } else { setError('No speech detected'); setState('idle'); } } catch (err) { console.error('Error processing audio:', err); setError('Failed to process audio: ' + err.message); setState('idle'); } }; const routeCommand = async (text) => { try { const response = await fetch('/api/voice/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); if (!response.ok) { throw new Error('Command routing failed'); } const commandResult = await response.json(); // Call parent callback with command result if (onCommand) { onCommand(commandResult); } // Check if TTS response is available if (commandResult.speak_response) { await speakResponse(commandResult.speak_response); } else { setState('idle'); } } catch (err) { console.error('Error routing command:', err); setError('Failed to execute command: ' + err.message); setState('idle'); } }; const speakResponse = async (text) => { setState('speaking'); try { // Try to get TTS audio from backend const response = await fetch('/api/voice/speak', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); if (response.ok) { const audioBlob = await response.blob(); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); audio.onended = () => { setState('idle'); URL.revokeObjectURL(audioUrl); }; audio.play(); } else { // Fallback to browser TTS if ('speechSynthesis' in window) { const utterance = new SpeechSynthesisUtterance(text); utterance.onend = () => setState('idle'); window.speechSynthesis.speak(utterance); } else { setState('idle'); } } } catch (err) { console.error('Error speaking response:', err); setState('idle'); } }; const getStateColor = () => { switch (state) { case 'listening': return '#27AE60'; case 'processing': return '#F39C12'; case 'speaking': return '#3498DB'; default: return '#95A5A6'; } }; const getStateIcon = () => { switch (state) { case 'listening': return '🎤'; case 'processing': return '⏳'; case 'speaking': return '🔊'; default: return '🎙️'; } }; return (