aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
587 lines (584 loc) • 21.9 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { forwardRef, useRef, useState, useEffect, useCallback } from 'react';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import { OptimizedGlassCore } from '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { cn } from '../../lib/utilsComprehensive.js';
import { useA11yId } from '../../utils/a11y.js';
import { useMotionPreferenceContext } from '../../contexts/MotionPreferenceContext.js';
import { useGlassSound } from '../../utils/soundDesign.js';
const GlassVoiceInput = /*#__PURE__*/forwardRef(({
active = false,
language = "en-US",
continuous = true,
interimResults = true,
maxAlternatives = 1,
commands = [],
wakeWord,
confidenceThreshold = 0.7,
showVisualizer = true,
visualizerStyle = "waveform",
showTranscript = true,
maxTranscriptLength = 500,
timeout = 30000,
noiseSuppression = true,
echoCancellation = true,
autoGainControl = true,
onVoiceStart,
onVoiceEnd,
onResult,
onCommand,
onError,
onAudioLevel,
onTranscriptChange,
showControls = true,
respectMotionPreference = true,
className,
...props
}, ref) => {
const {
prefersReducedMotion,
isMotionSafe
} = useMotionPreferenceContext();
const {
play
} = useGlassSound();
const canvasRef = useRef(null);
const animationRef = useRef();
const recognitionRef = useRef(null);
const audioContextRef = useRef(null);
const analyserRef = useRef(null);
const micStreamRef = useRef(null);
const voiceInputId = useA11yId("glass-voice-input");
const [isListening, setIsListening] = useState(false);
useState(false);
const [transcript, setTranscript] = useState("");
const [interimTranscript, setInterimTranscript] = useState("");
const [audioLevel, setAudioLevel] = useState(0);
const [frequency, setFrequency] = useState(0);
const [recognizedCommands, setRecognizedCommands] = useState([]);
const [audioData, setAudioData] = useState(new Float32Array(256));
const [isSupported, setIsSupported] = useState(false);
const [permissionStatus, setPermissionStatus] = useState("prompt");
// Check browser support
useEffect(() => {
const supported = "webkitSpeechRecognition" in window || "SpeechRecognition" in window;
setIsSupported(supported);
if (!supported) {
onError?.("Speech recognition is not supported in this browser");
}
}, [onError]);
// Initialize speech recognition
const initializeSpeechRecognition = useCallback(() => {
if (!isSupported) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = continuous;
recognition.interimResults = interimResults;
recognition.lang = language;
recognition.maxAlternatives = maxAlternatives;
recognition.onstart = () => {
setIsListening(true);
onVoiceStart?.();
play("tap");
};
recognition.onend = () => {
setIsListening(false);
onVoiceEnd?.();
play("success");
};
recognition.onresult = event => {
let finalTranscript = "";
let interimText = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const resultText = result[0].transcript;
const confidence = result[0].confidence;
if (result.isFinal) {
finalTranscript += resultText;
onResult?.(resultText, confidence);
// Check for commands
checkCommands(resultText, confidence);
} else {
interimText += resultText;
}
}
if (finalTranscript) {
setTranscript(prev => {
const newTranscript = (prev + finalTranscript).slice(-maxTranscriptLength);
onTranscriptChange?.(newTranscript);
return newTranscript;
});
}
setInterimTranscript(interimText);
};
recognition.onerror = event => {
console.error("Speech recognition error:", event.error);
onError?.(event.error);
setIsListening(false);
play("error");
};
recognitionRef.current = recognition;
}, [isSupported, continuous, interimResults, language, maxAlternatives, maxTranscriptLength, onVoiceStart, onVoiceEnd, onResult, onTranscriptChange, onError, play]);
// Initialize audio context for visualization
const initializeAudioContext = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation,
noiseSuppression,
autoGainControl
}
});
micStreamRef.current = stream;
setPermissionStatus("granted");
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream);
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.8;
microphone.connect(analyser);
audioContextRef.current = audioContext;
analyserRef.current = analyser;
// Start audio analysis
if (showVisualizer) {
startAudioAnalysis();
}
} catch (error) {
console.error("Microphone access denied:", error);
setPermissionStatus("denied");
onError?.("Microphone access denied");
}
}, [echoCancellation, noiseSuppression, autoGainControl, showVisualizer, onError]);
// Start audio analysis
const startAudioAnalysis = useCallback(() => {
if (!analyserRef.current) return;
const analyser = analyserRef.current;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Float32Array(bufferLength);
const analyze = () => {
analyser.getFloatFrequencyData(dataArray);
// Calculate audio level
const sum = dataArray.reduce((acc, val) => acc + Math.abs(val), 0);
const level = Math.min(1, sum / bufferLength / 50);
setAudioLevel(level);
onAudioLevel?.(level);
// Calculate dominant frequency
let maxIndex = 0;
let maxValue = -Infinity;
for (let i = 0; i < bufferLength; i++) {
if (dataArray[i] > maxValue) {
maxValue = dataArray[i];
maxIndex = i;
}
}
const freq = maxIndex * audioContextRef.current.sampleRate / (analyser.fftSize * 2);
setFrequency(freq);
// Update audio data for visualization
setAudioData(new Float32Array(dataArray));
if (showVisualizer) {
animationRef.current = requestAnimationFrame(analyze);
}
};
analyze();
}, [showVisualizer, onAudioLevel]);
// Check for voice commands
const checkCommands = useCallback((text, confidence) => {
if (confidence < confidenceThreshold) return;
const normalizedText = text.toLowerCase().trim();
commands.forEach(cmd => {
const normalizedPhrase = cmd.phrase.toLowerCase();
let match = false;
if (cmd.fuzzy) {
// Simple fuzzy matching - check if most words match
const textWords = normalizedText.split(" ");
const phraseWords = normalizedPhrase.split(" ");
const matchCount = phraseWords.filter(word => textWords.some(textWord => textWord.includes(word) || word.includes(textWord))).length;
match = matchCount / phraseWords.length >= 0.7;
} else {
match = normalizedText.includes(normalizedPhrase);
}
if (match) {
const command = {
phrase: cmd.phrase,
action: cmd.action,
confidence,
timestamp: Date.now(),
id: `command-${Date.now()}`
};
setRecognizedCommands(prev => [...prev.slice(-9), command]);
onCommand?.(command);
play("success");
}
});
// Check for wake word
if (wakeWord && normalizedText.includes(wakeWord.toLowerCase())) {
if (!isListening) {
startListening();
}
}
}, [confidenceThreshold, commands, wakeWord, isListening, onCommand, play]);
// Initialize components
useEffect(() => {
initializeSpeechRecognition();
}, [initializeSpeechRecognition]);
// Start/stop listening
const startListening = useCallback(() => {
if (!recognitionRef.current || isListening) return;
if (permissionStatus !== "granted") {
initializeAudioContext();
}
try {
recognitionRef.current.start();
} catch (error) {
console.error("Failed to start recognition:", error);
onError?.("Failed to start voice recognition");
}
}, [isListening, permissionStatus, initializeAudioContext, onError]);
const stopListening = useCallback(() => {
if (!recognitionRef.current || !isListening) return;
try {
recognitionRef.current.stop();
} catch (error) {
console.error("Failed to stop recognition:", error);
}
}, [isListening]);
// Toggle listening
const toggleListening = useCallback(() => {
if (isListening) {
stopListening();
} else {
startListening();
}
}, [isListening, startListening, stopListening]);
// Auto-start/stop based on active prop
useEffect(() => {
if (active && !isListening) {
startListening();
} else if (!active && isListening) {
stopListening();
}
}, [active, isListening, startListening, stopListening]);
// Cleanup
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
if (micStreamRef.current) {
micStreamRef.current.getTracks().forEach(track => track.stop());
}
if (audioContextRef.current) {
audioContextRef.current.close();
}
if (recognitionRef.current) {
recognitionRef.current.abort();
}
};
}, []);
// Render visualizer
const renderVisualizer = useCallback(() => {
if (!showVisualizer || prefersReducedMotion) return null;
const canvas = canvasRef.current;
if (!canvas) return null;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const {
width,
height
} = canvas;
ctx.clearRect(0, 0, width, height);
if (!audioData || audioLevel === 0) return null;
// Set up gradient
const gradient = ctx.createLinearGradient(0, height, 0, 0);
gradient.addColorStop(0, "rgba(100, 150, 255, 0.8)");
gradient.addColorStop(0.5, "rgba(150, 200, 255, 0.6)");
gradient.addColorStop(1, "rgba(200, 220, 255, 0.4)");
switch (visualizerStyle) {
case "waveform":
ctx.strokeStyle = gradient;
ctx.lineWidth = 2;
ctx.beginPath();
const sliceWidth = width / audioData.length;
let x = 0;
for (let i = 0; i < audioData.length; i++) {
const v = (audioData[i] + 100) / 100;
const y = v * height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.stroke();
break;
case "bars":
const barWidth = width / audioData.length * 2;
for (let i = 0; i < audioData.length; i++) {
const barHeight = (audioData[i] + 100) / 100 * height;
const x = i * barWidth;
ctx.fillStyle = gradient;
ctx.fillRect(x, height - barHeight, barWidth - 1, barHeight);
}
break;
case "circular":
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(centerX, centerY) * 0.6;
ctx.lineWidth = 3;
for (let i = 0; i < audioData.length; i++) {
const angle = i / audioData.length * Math.PI * 2;
const amplitude = (audioData[i] + 100) / 100 * 50;
const x1 = centerX + Math.cos(angle) * radius;
const y1 = centerY + Math.sin(angle) * radius;
const x2 = centerX + Math.cos(angle) * (radius + amplitude);
const y2 = centerY + Math.sin(angle) * (radius + amplitude);
ctx.strokeStyle = gradient;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
break;
case "particle":
const particleCount = Math.min(50, Math.floor(audioLevel * 100));
for (let i = 0; i < particleCount; i++) {
const x = Math.random() * width;
const y = Math.random() * height;
const size = Math.random() * 3 + 1;
const alpha = Math.random() * 0.8 + 0.2;
ctx.fillStyle = `rgba(100, 150, 255, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
}
break;
}
}, [showVisualizer, prefersReducedMotion, audioData, audioLevel, visualizerStyle]);
// Render visualizer continuously
useEffect(() => {
if (showVisualizer && !prefersReducedMotion) {
const animate = () => {
renderVisualizer();
if (showVisualizer) {
requestAnimationFrame(animate);
}
};
animate();
}
}, [showVisualizer, prefersReducedMotion, renderVisualizer]);
// Canvas setup
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = 400;
canvas.height = 100;
}, []);
// Clear transcript
const clearTranscript = useCallback(() => {
setTranscript("");
setInterimTranscript("");
onTranscriptChange?.("");
}, [onTranscriptChange]);
// Render controls
const renderControls = () => {
if (!showControls) return null;
return jsxs(OptimizedGlassCore, {
elevation: "level2",
intensity: "medium",
depth: 1,
tint: "neutral",
border: "subtle",
className: "glass-voice-controls glass-flex glass-flex-wrap glass-items-center glass-gap-4 glass-p-4 glass-radius-lg glass-glass-glass-backdrop-blur-md glass-contrast-guard glass-border glass-border-glass-border/20 glass-contrast-guard",
children: [jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("button", {
onClick: toggleListening,
disabled: !isSupported || permissionStatus === "denied",
className: cn("glass-px-4 glass-py-2 glass-radius-md font-medium transition-all", "glass-focus glass-touch-target glass-contrast-guard", isListening ? "bg-red-500/20 hover:bg-red-500/30 text-red-400" : "bg-green-500/20 hover:bg-green-500/30 text-green-400", (!isSupported || permissionStatus === "denied") && "opacity-50 cursor-not-allowed"),
children: isListening ? "Stop Listening" : "Start Listening"
}), jsx("button", {
onClick: clearTranscript,
className: 'glass-px-3 glass-py-2 glass-radius-md bg-secondary/20 hover:bg-secondary/30 glass-focus glass-touch-target glass-contrast-guard',
children: "Clear"
})]
}), jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("span", {
className: "glass-text-sm",
children: "Language:"
}), jsxs("select", {
value: language,
onChange: e => {},
className: "glass-px-2 glass-py-1 glass-radius-md glass-surface-overlay glass-border glass-border-glass-border/20 glass-focus glass-touch-target glass-contrast-guard",
children: [jsx("option", {
value: "en-US",
children: "English (US)"
}), jsx("option", {
value: "en-GB",
children: "English (UK)"
}), jsx("option", {
value: "es-ES",
children: "Spanish"
}), jsx("option", {
value: "fr-FR",
children: "French"
}), jsx("option", {
value: "de-DE",
children: "German"
}), jsx("option", {
value: "it-IT",
children: "Italian"
}), jsx("option", {
value: "ja-JP",
children: "Japanese"
}), jsx("option", {
value: "ko-KR",
children: "Korean"
}), jsx("option", {
value: "zh-CN",
children: "Chinese (Mandarin)"
})]
})]
}), jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("span", {
className: "glass-text-sm",
children: "Style:"
}), jsxs("select", {
value: visualizerStyle,
onChange: e => {},
className: "glass-px-2 glass-py-1 glass-radius-md glass-surface-overlay glass-border glass-border-glass-border/20 glass-focus glass-touch-target glass-contrast-guard",
children: [jsx("option", {
value: "waveform",
children: "Waveform"
}), jsx("option", {
value: "bars",
children: "Bars"
}), jsx("option", {
value: "circular",
children: "Circular"
}), jsx("option", {
value: "particle",
children: "Particle"
})]
})]
}), jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsxs("label", {
className: "glass-text-sm",
children: [jsx("input", {
type: "checkbox",
checked: continuous,
onChange: e => {},
className: "glass-mr-1 glass-focus glass-touch-target glass-contrast-guard"
}), "Continuous"]
}), jsxs("label", {
className: "glass-text-sm",
children: [jsx("input", {
type: "checkbox",
checked: showVisualizer,
onChange: e => {},
className: "glass-mr-1 glass-focus glass-touch-target glass-contrast-guard"
}), "Visualizer"]
})]
}), jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("span", {
className: "glass-text-sm",
children: "Audio Level:"
}), jsx("div", {
className: 'w-20 h-2 glass-surface-overlay glass-radius-full overflow-hidden',
children: jsx("div", {
className: 'glass-h-full glass-surface-green/60 transition-all duration-100',
style: {
width: `${audioLevel * 100}%`
}
})
})]
})]
});
};
return jsx(OptimizedGlassCore, {
ref: ref,
id: voiceInputId,
elevation: "level1",
intensity: "subtle",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-voice-input relative glass-radius-lg glass-glass-backdrop-blur-md glass-contrast-guard border border-border/20", className),
...props,
children: jsxs(MotionFramer, {
preset: isMotionSafe && respectMotionPreference ? "fadeIn" : "none",
className: "glass-flex glass-flex-col glass-gap-4 glass-p-4",
children: [renderControls(), jsxs("div", {
className: "glass-flex glass-items-center glass-justify-between glass-p-3 glass-surface-overlay glass-radius-md",
children: [jsxs("div", {
className: "glass-flex glass-items-center glass-gap-3",
children: [jsx("div", {
className: cn("w-3 h-3 glass-radius-full", isListening ? "bg-green-500 animate-pulse" : "bg-red-500")
}), jsx("span", {
className: 'glass-text-sm font-medium',
children: isListening ? "Listening..." : "Ready"
}), frequency > 0 && jsxs("span", {
className: "glass-text-xs glass-text-secondary",
children: [Math.round(frequency), "Hz"]
})]
}), jsxs("div", {
className: "glass-text-xs glass-text-secondary",
children: [permissionStatus === "denied" && "Microphone access denied", !isSupported && "Speech recognition not supported", recognizedCommands.length > 0 && `${recognizedCommands.length} commands recognized`]
})]
}), showVisualizer && jsx("div", {
className: "glass-p-4 glass-surface-overlay glass-radius-md",
children: jsx("canvas", {
ref: canvasRef,
className: "glass-w-full",
style: {
height: "100px"
}
})
}), showTranscript && (transcript || interimTranscript) && jsxs("div", {
className: "glass-p-4 glass-surface-overlay glass-radius-md",
children: [jsx("div", {
className: 'glass-text-sm font-medium mb-2',
children: "Transcript:"
}), jsxs("div", {
className: "glass-text-sm",
children: [jsx("span", {
children: transcript
}), interimTranscript && jsxs("span", {
className: 'glass-text-secondary italic',
children: [" ", interimTranscript]
})]
})]
}), recognizedCommands.length > 0 && jsxs("div", {
className: "glass-p-4 glass-surface-overlay glass-radius-md",
children: [jsx("div", {
className: 'glass-text-sm font-medium mb-2',
children: "Recent Commands:"
}), jsx("div", {
className: 'space-y-1',
children: recognizedCommands.slice(-5).map(command => jsxs("div", {
className: "glass-text-xs glass-p-2 glass-surface-primary/10 glass-radius-sm",
children: [jsx("span", {
className: 'font-medium',
children: command.phrase
}), jsxs("span", {
className: "glass-text-secondary glass-ml-2",
children: ["(", Math.round(command.confidence * 100), "% confidence)"]
})]
}, command.id))
})]
})]
})
});
});
GlassVoiceInput.displayName = "GlassVoiceInput";
export { GlassVoiceInput, GlassVoiceInput as default };
//# sourceMappingURL=GlassVoiceInput.js.map