UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

587 lines (584 loc) 21.9 kB
'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