feat: Add Voice Control component with speech recognition and command processing
This commit is contained in:
209
client/src/components/VoiceControl.css
Normal file
209
client/src/components/VoiceControl.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
220
client/src/components/VoiceControl.jsx
Normal file
220
client/src/components/VoiceControl.jsx
Normal file
@@ -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 (
|
||||
<div className="voice-control unsupported">
|
||||
<p>⚠️ Voice commands not supported in this browser</p>
|
||||
<small>Try Chrome, Edge, or Safari</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-control">
|
||||
<div className="voice-header">
|
||||
<h3>🎤 Voice Control</h3>
|
||||
{selectedTurtle ? (
|
||||
<span className="selected-turtle">Turtle {selectedTurtle.turtleID}</span>
|
||||
) : (
|
||||
<span className="no-selection">Select a turtle first</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`voice-button ${isListening ? 'listening' : ''}`}
|
||||
onClick={isListening ? stopListening : startListening}
|
||||
disabled={!selectedTurtle}
|
||||
>
|
||||
{isListening ? (
|
||||
<>
|
||||
<span className="pulse-ring"></span>
|
||||
<span className="icon">🎙️</span>
|
||||
<span>Listening...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="icon">🎤</span>
|
||||
<span>Press to Speak</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{transcript && (
|
||||
<div className="voice-feedback">
|
||||
<div className="transcript">
|
||||
<strong>You said:</strong> "{transcript}"
|
||||
</div>
|
||||
{lastCommand && (
|
||||
<div className="last-command">
|
||||
<strong>Command:</strong> {lastCommand}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="voice-commands-help">
|
||||
<details>
|
||||
<summary>Available Commands</summary>
|
||||
<div className="commands-grid">
|
||||
<div className="command-category">
|
||||
<h4>Movement</h4>
|
||||
<ul>
|
||||
<li>"forward" / "go ahead"</li>
|
||||
<li>"back" / "backward"</li>
|
||||
<li>"turn left" / "left"</li>
|
||||
<li>"turn right" / "right"</li>
|
||||
<li>"go up" / "move up"</li>
|
||||
<li>"go down" / "move down"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="command-category">
|
||||
<h4>Actions</h4>
|
||||
<ul>
|
||||
<li>"dig"</li>
|
||||
<li>"dig up"</li>
|
||||
<li>"dig down"</li>
|
||||
<li>"place" / "build"</li>
|
||||
<li>"refuel"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="command-category">
|
||||
<h4>Autonomous</h4>
|
||||
<ul>
|
||||
<li>"explore" / "start exploring"</li>
|
||||
<li>"mine" / "start mining"</li>
|
||||
<li>"return home" / "go home"</li>
|
||||
<li>"stop"</li>
|
||||
<li>"set home"</li>
|
||||
<li>"status" / "report"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user