diff --git a/client/src/components/VoiceControl.css b/client/src/components/VoiceControl.css new file mode 100644 index 0000000..30002c0 --- /dev/null +++ b/client/src/components/VoiceControl.css @@ -0,0 +1,209 @@ +.voice-control { + padding: 1rem; + background: #1e293b; + border-radius: 0.5rem; + border: 1px solid #334155; +} + +.voice-control.unsupported { + text-align: center; + padding: 2rem; + color: #ef4444; +} + +.voice-control.unsupported small { + color: #9ca3af; + display: block; + margin-top: 0.5rem; +} + +.voice-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.voice-header h3 { + font-size: 1rem; + font-weight: 600; + color: #f9fafb; + margin: 0; +} + +.selected-turtle { + font-size: 0.875rem; + color: #10b981; + font-weight: 600; +} + +.no-selection { + font-size: 0.875rem; + color: #9ca3af; +} + +.voice-button { + width: 100%; + padding: 1.5rem; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + border: none; + border-radius: 0.75rem; + color: white; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + transition: all 0.3s; + position: relative; + overflow: hidden; +} + +.voice-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4); +} + +.voice-button:disabled { + background: #374151; + cursor: not-allowed; + opacity: 0.5; +} + +.voice-button.listening { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + animation: pulse-glow 2s infinite; +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 20px rgba(239, 68, 68, 0.5); + } + 50% { + box-shadow: 0 0 40px rgba(239, 68, 68, 0.8); + } +} + +.voice-button .icon { + font-size: 2rem; +} + +.pulse-ring { + position: absolute; + width: 80px; + height: 80px; + border: 3px solid rgba(255, 255, 255, 0.6); + border-radius: 50%; + animation: pulse-ring 1.5s infinite; +} + +@keyframes pulse-ring { + 0% { + transform: scale(0.5); + opacity: 1; + } + 100% { + transform: scale(1.5); + opacity: 0; + } +} + +.voice-feedback { + margin-top: 1rem; + padding: 1rem; + background: #0f172a; + border-radius: 0.5rem; + border: 1px solid #1e293b; +} + +.transcript { + color: #e5e7eb; + margin-bottom: 0.5rem; + font-size: 0.875rem; +} + +.transcript strong { + color: #60a5fa; +} + +.last-command { + color: #10b981; + font-size: 0.875rem; + font-family: 'Courier New', monospace; +} + +.last-command strong { + color: #34d399; +} + +.voice-commands-help { + margin-top: 1rem; +} + +.voice-commands-help details { + background: #0f172a; + border-radius: 0.5rem; + border: 1px solid #1e293b; +} + +.voice-commands-help summary { + padding: 0.75rem; + cursor: pointer; + font-weight: 600; + color: #94a3b8; + -webkit-user-select: none; + user-select: none; + transition: all 0.2s; +} + +.voice-commands-help summary:hover { + color: #e5e7eb; +} + +.commands-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.command-category h4 { + font-size: 0.75rem; + color: #60a5fa; + text-transform: uppercase; + margin: 0 0 0.5rem 0; + font-weight: 700; + letter-spacing: 0.05em; +} + +.command-category ul { + list-style: none; + padding: 0; + margin: 0; +} + +.command-category li { + font-size: 0.75rem; + color: #9ca3af; + padding: 0.25rem 0; + font-family: 'Courier New', monospace; +} + +.command-category li::before { + content: "▸ "; + color: #3b82f6; + font-weight: bold; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .voice-button { + padding: 1.25rem; + } + + .commands-grid { + grid-template-columns: 1fr; + } +} diff --git a/client/src/components/VoiceControl.jsx b/client/src/components/VoiceControl.jsx new file mode 100644 index 0000000..75bb62d --- /dev/null +++ b/client/src/components/VoiceControl.jsx @@ -0,0 +1,220 @@ +import React, { useState, useEffect } from 'react'; +import { useTurtleStore } from '../store/turtleStore'; +import './VoiceControl.css'; + +export default function VoiceControl() { + const [isListening, setIsListening] = useState(false); + const [transcript, setTranscript] = useState(''); + const [lastCommand, setLastCommand] = useState(''); + const [supported, setSupported] = useState(false); + const [recognition, setRecognition] = useState(null); + + const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle()); + const sendCommand = useTurtleStore((state) => state.sendCommand); + + useEffect(() => { + // Check if speech recognition is supported + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + + if (SpeechRecognition) { + setSupported(true); + const recognitionInstance = new SpeechRecognition(); + recognitionInstance.continuous = false; + recognitionInstance.interimResults = false; + recognitionInstance.lang = 'en-US'; + + recognitionInstance.onresult = (event) => { + const speechResult = event.results[0][0].transcript.toLowerCase(); + setTranscript(speechResult); + processVoiceCommand(speechResult); + }; + + recognitionInstance.onerror = (event) => { + console.error('Speech recognition error:', event.error); + setIsListening(false); + }; + + recognitionInstance.onend = () => { + setIsListening(false); + }; + + setRecognition(recognitionInstance); + } + }, []); + + const processVoiceCommand = (command) => { + if (!selectedTurtle) { + speak('Please select a turtle first'); + return; + } + + const turtleId = selectedTurtle.turtleID; + let action = null; + let param = null; + + // Parse voice commands + if (command.includes('forward') || command.includes('go ahead')) { + action = 'forward'; + } else if (command.includes('back') || command.includes('backward')) { + action = 'back'; + } else if (command.includes('turn left') || command.includes('left')) { + action = 'turnLeft'; + } else if (command.includes('turn right') || command.includes('right')) { + action = 'turnRight'; + } else if (command.includes('go up') || command.includes('move up')) { + action = 'up'; + } else if (command.includes('go down') || command.includes('move down')) { + action = 'down'; + } else if (command.includes('dig')) { + if (command.includes('up')) { + action = 'digUp'; + } else if (command.includes('down')) { + action = 'digDown'; + } else { + action = 'dig'; + } + } else if (command.includes('place') || command.includes('build')) { + action = 'place'; + } else if (command.includes('explore') || command.includes('start exploring')) { + action = 'explore'; + } else if (command.includes('mine') || command.includes('start mining')) { + action = 'mine'; + } else if (command.includes('return home') || command.includes('go home') || command.includes('come back')) { + action = 'returnHome'; + } else if (command.includes('stop')) { + action = 'stop'; + } else if (command.includes('set home') || command.includes('mark home')) { + action = 'setHome'; + } else if (command.includes('refuel')) { + action = 'refuel'; + } else if (command.includes('status') || command.includes('report')) { + action = 'status'; + } + + if (action) { + sendCommand(turtleId, action, param); + setLastCommand(`${action}${param ? ` ${param}` : ''}`); + speak(`Sending ${action.replace(/([A-Z])/g, ' $1').toLowerCase()} command`); + } else { + speak('Command not recognized'); + } + }; + + const speak = (text) => { + if ('speechSynthesis' in window) { + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 1.1; + utterance.pitch = 1.0; + window.speechSynthesis.speak(utterance); + } + }; + + const startListening = () => { + if (recognition && !isListening) { + setTranscript(''); + setIsListening(true); + recognition.start(); + } + }; + + const stopListening = () => { + if (recognition && isListening) { + recognition.stop(); + setIsListening(false); + } + }; + + if (!supported) { + return ( +
+

⚠️ Voice commands not supported in this browser

+ Try Chrome, Edge, or Safari +
+ ); + } + + return ( +
+
+

🎤 Voice Control

+ {selectedTurtle ? ( + Turtle {selectedTurtle.turtleID} + ) : ( + Select a turtle first + )} +
+ + + + {transcript && ( +
+
+ You said: "{transcript}" +
+ {lastCommand && ( +
+ Command: {lastCommand} +
+ )} +
+ )} + +
+
+ Available Commands +
+
+

Movement

+
    +
  • "forward" / "go ahead"
  • +
  • "back" / "backward"
  • +
  • "turn left" / "left"
  • +
  • "turn right" / "right"
  • +
  • "go up" / "move up"
  • +
  • "go down" / "move down"
  • +
+
+
+

Actions

+
    +
  • "dig"
  • +
  • "dig up"
  • +
  • "dig down"
  • +
  • "place" / "build"
  • +
  • "refuel"
  • +
+
+
+

Autonomous

+
    +
  • "explore" / "start exploring"
  • +
  • "mine" / "start mining"
  • +
  • "return home" / "go home"
  • +
  • "stop"
  • +
  • "set home"
  • +
  • "status" / "report"
  • +
+
+
+
+
+
+ ); +}