feat: Add Voice Control component with speech recognition and command processing

This commit is contained in:
MayaTheShy
2026-02-19 22:47:18 -05:00
parent dd58093c40
commit 57ef89f52c
2 changed files with 429 additions and 0 deletions

View 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;
}
}

View 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>
);
}