{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "live-waveform", "files": [ { "path": "components/ui/live-waveform.tsx", "content": "\"use client\"\n\nimport { useEffect, useRef, type HTMLAttributes } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type LiveWaveformProps = HTMLAttributes & {\n active?: boolean\n processing?: boolean\n deviceId?: string\n barWidth?: number\n barHeight?: number\n barGap?: number\n barRadius?: number\n barColor?: string\n fadeEdges?: boolean\n fadeWidth?: number\n height?: string | number\n sensitivity?: number\n smoothingTimeConstant?: number\n fftSize?: number\n historySize?: number\n updateRate?: number\n mode?: \"scrolling\" | \"static\"\n onError?: (error: Error) => void\n onStreamReady?: (stream: MediaStream) => void\n onStreamEnd?: () => void\n}\n\nexport const LiveWaveform = ({\n active = false,\n processing = false,\n deviceId,\n barWidth = 3,\n barGap = 1,\n barRadius = 1.5,\n barColor,\n fadeEdges = true,\n fadeWidth = 24,\n barHeight: baseBarHeight = 4,\n height = 64,\n sensitivity = 1,\n smoothingTimeConstant = 0.8,\n fftSize = 256,\n historySize = 60,\n updateRate = 30,\n mode = \"static\",\n onError,\n onStreamReady,\n onStreamEnd,\n className,\n ...props\n}: LiveWaveformProps) => {\n const canvasRef = useRef(null)\n const containerRef = useRef(null)\n const historyRef = useRef([])\n const analyserRef = useRef(null)\n const audioContextRef = useRef(null)\n const streamRef = useRef(null)\n const animationRef = useRef(0)\n const lastUpdateRef = useRef(0)\n const processingAnimationRef = useRef(null)\n const lastActiveDataRef = useRef([])\n const transitionProgressRef = useRef(0)\n const staticBarsRef = useRef([])\n const needsRedrawRef = useRef(true)\n const gradientCacheRef = useRef(null)\n const lastWidthRef = useRef(0)\n\n const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n // Handle canvas resizing\n useEffect(() => {\n const canvas = canvasRef.current\n const container = containerRef.current\n if (!canvas || !container) return\n\n const resizeObserver = new ResizeObserver(() => {\n const rect = container.getBoundingClientRect()\n const dpr = window.devicePixelRatio || 1\n\n canvas.width = rect.width * dpr\n canvas.height = rect.height * dpr\n canvas.style.width = `${rect.width}px`\n canvas.style.height = `${rect.height}px`\n\n const ctx = canvas.getContext(\"2d\")\n if (ctx) {\n ctx.scale(dpr, dpr)\n }\n\n gradientCacheRef.current = null\n lastWidthRef.current = rect.width\n needsRedrawRef.current = true\n })\n\n resizeObserver.observe(container)\n return () => resizeObserver.disconnect()\n }, [])\n\n useEffect(() => {\n if (processing && !active) {\n let time = 0\n transitionProgressRef.current = 0\n\n const animateProcessing = () => {\n time += 0.03\n transitionProgressRef.current = Math.min(\n 1,\n transitionProgressRef.current + 0.02\n )\n\n const processingData = []\n const barCount = Math.floor(\n (containerRef.current?.getBoundingClientRect().width || 200) /\n (barWidth + barGap)\n )\n\n if (mode === \"static\") {\n const halfCount = Math.floor(barCount / 2)\n\n for (let i = 0; i < barCount; i++) {\n const normalizedPosition = (i - halfCount) / halfCount\n const centerWeight = 1 - Math.abs(normalizedPosition) * 0.4\n\n const wave1 = Math.sin(time * 1.5 + normalizedPosition * 3) * 0.25\n const wave2 = Math.sin(time * 0.8 - normalizedPosition * 2) * 0.2\n const wave3 = Math.cos(time * 2 + normalizedPosition) * 0.15\n const combinedWave = wave1 + wave2 + wave3\n const processingValue = (0.2 + combinedWave) * centerWeight\n\n let finalValue = processingValue\n if (\n lastActiveDataRef.current.length > 0 &&\n transitionProgressRef.current < 1\n ) {\n const lastDataIndex = Math.min(\n i,\n lastActiveDataRef.current.length - 1\n )\n const lastValue = lastActiveDataRef.current[lastDataIndex] || 0\n finalValue =\n lastValue * (1 - transitionProgressRef.current) +\n processingValue * transitionProgressRef.current\n }\n\n processingData.push(Math.max(0.05, Math.min(1, finalValue)))\n }\n } else {\n for (let i = 0; i < barCount; i++) {\n const normalizedPosition = (i - barCount / 2) / (barCount / 2)\n const centerWeight = 1 - Math.abs(normalizedPosition) * 0.4\n\n const wave1 = Math.sin(time * 1.5 + i * 0.15) * 0.25\n const wave2 = Math.sin(time * 0.8 - i * 0.1) * 0.2\n const wave3 = Math.cos(time * 2 + i * 0.05) * 0.15\n const combinedWave = wave1 + wave2 + wave3\n const processingValue = (0.2 + combinedWave) * centerWeight\n\n let finalValue = processingValue\n if (\n lastActiveDataRef.current.length > 0 &&\n transitionProgressRef.current < 1\n ) {\n const lastDataIndex = Math.floor(\n (i / barCount) * lastActiveDataRef.current.length\n )\n const lastValue = lastActiveDataRef.current[lastDataIndex] || 0\n finalValue =\n lastValue * (1 - transitionProgressRef.current) +\n processingValue * transitionProgressRef.current\n }\n\n processingData.push(Math.max(0.05, Math.min(1, finalValue)))\n }\n }\n\n if (mode === \"static\") {\n staticBarsRef.current = processingData\n } else {\n historyRef.current = processingData\n }\n\n needsRedrawRef.current = true\n processingAnimationRef.current =\n requestAnimationFrame(animateProcessing)\n }\n\n animateProcessing()\n\n return () => {\n if (processingAnimationRef.current) {\n cancelAnimationFrame(processingAnimationRef.current)\n }\n }\n } else if (!active && !processing) {\n const hasData =\n mode === \"static\"\n ? staticBarsRef.current.length > 0\n : historyRef.current.length > 0\n\n if (hasData) {\n let fadeProgress = 0\n const fadeToIdle = () => {\n fadeProgress += 0.03\n if (fadeProgress < 1) {\n if (mode === \"static\") {\n staticBarsRef.current = staticBarsRef.current.map(\n (value) => value * (1 - fadeProgress)\n )\n } else {\n historyRef.current = historyRef.current.map(\n (value) => value * (1 - fadeProgress)\n )\n }\n needsRedrawRef.current = true\n requestAnimationFrame(fadeToIdle)\n } else {\n if (mode === \"static\") {\n staticBarsRef.current = []\n } else {\n historyRef.current = []\n }\n }\n }\n fadeToIdle()\n }\n }\n }, [processing, active, barWidth, barGap, mode])\n\n // Handle microphone setup and teardown\n useEffect(() => {\n if (!active) {\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n streamRef.current = null\n onStreamEnd?.()\n }\n if (\n audioContextRef.current &&\n audioContextRef.current.state !== \"closed\"\n ) {\n audioContextRef.current.close()\n audioContextRef.current = null\n }\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n animationRef.current = 0\n }\n return\n }\n\n const setupMicrophone = async () => {\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: deviceId\n ? {\n deviceId: { exact: deviceId },\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n }\n : {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n },\n })\n streamRef.current = stream\n onStreamReady?.(stream)\n\n const AudioContextConstructor =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext\n const audioContext = new AudioContextConstructor()\n const analyser = audioContext.createAnalyser()\n analyser.fftSize = fftSize\n analyser.smoothingTimeConstant = smoothingTimeConstant\n\n const source = audioContext.createMediaStreamSource(stream)\n source.connect(analyser)\n\n audioContextRef.current = audioContext\n analyserRef.current = analyser\n\n // Clear history when starting\n historyRef.current = []\n } catch (error) {\n onError?.(error as Error)\n }\n }\n\n setupMicrophone()\n\n return () => {\n if (streamRef.current) {\n streamRef.current.getTracks().forEach((track) => track.stop())\n streamRef.current = null\n onStreamEnd?.()\n }\n if (\n audioContextRef.current &&\n audioContextRef.current.state !== \"closed\"\n ) {\n audioContextRef.current.close()\n audioContextRef.current = null\n }\n if (animationRef.current) {\n cancelAnimationFrame(animationRef.current)\n animationRef.current = 0\n }\n }\n }, [\n active,\n deviceId,\n fftSize,\n smoothingTimeConstant,\n onError,\n onStreamReady,\n onStreamEnd,\n ])\n\n // Animation loop\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n let rafId: number\n\n const animate = (currentTime: number) => {\n // Render waveform\n const rect = canvas.getBoundingClientRect()\n\n // Update audio data if active\n if (active && currentTime - lastUpdateRef.current > updateRate) {\n lastUpdateRef.current = currentTime\n\n if (analyserRef.current) {\n const dataArray = new Uint8Array(\n analyserRef.current.frequencyBinCount\n )\n analyserRef.current.getByteFrequencyData(dataArray)\n\n if (mode === \"static\") {\n // For static mode, update bars in place\n const startFreq = Math.floor(dataArray.length * 0.05)\n const endFreq = Math.floor(dataArray.length * 0.4)\n const relevantData = dataArray.slice(startFreq, endFreq)\n\n const barCount = Math.floor(rect.width / (barWidth + barGap))\n const halfCount = Math.floor(barCount / 2)\n const newBars: number[] = []\n\n // Mirror the data for symmetric display\n for (let i = halfCount - 1; i >= 0; i--) {\n const dataIndex = Math.floor(\n (i / halfCount) * relevantData.length\n )\n const value = Math.min(\n 1,\n (relevantData[dataIndex] / 255) * sensitivity\n )\n newBars.push(Math.max(0.05, value))\n }\n\n for (let i = 0; i < halfCount; i++) {\n const dataIndex = Math.floor(\n (i / halfCount) * relevantData.length\n )\n const value = Math.min(\n 1,\n (relevantData[dataIndex] / 255) * sensitivity\n )\n newBars.push(Math.max(0.05, value))\n }\n\n staticBarsRef.current = newBars\n lastActiveDataRef.current = newBars\n } else {\n // Scrolling mode - original behavior\n let sum = 0\n const startFreq = Math.floor(dataArray.length * 0.05)\n const endFreq = Math.floor(dataArray.length * 0.4)\n const relevantData = dataArray.slice(startFreq, endFreq)\n\n for (let i = 0; i < relevantData.length; i++) {\n sum += relevantData[i]\n }\n const average = (sum / relevantData.length / 255) * sensitivity\n\n // Add to history\n historyRef.current.push(Math.min(1, Math.max(0.05, average)))\n lastActiveDataRef.current = [...historyRef.current]\n\n // Maintain history size\n if (historyRef.current.length > historySize) {\n historyRef.current.shift()\n }\n }\n needsRedrawRef.current = true\n }\n }\n\n // Only redraw if needed\n if (!needsRedrawRef.current && !active) {\n rafId = requestAnimationFrame(animate)\n return\n }\n\n needsRedrawRef.current = active\n ctx.clearRect(0, 0, rect.width, rect.height)\n\n const computedBarColor =\n barColor ||\n (() => {\n const style = getComputedStyle(canvas)\n // Try to get the computed color value directly\n const color = style.color\n return color || \"#000\"\n })()\n\n const step = barWidth + barGap\n const barCount = Math.floor(rect.width / step)\n const centerY = rect.height / 2\n\n // Draw bars based on mode\n if (mode === \"static\") {\n // Static mode - bars in fixed positions\n const dataToRender = processing\n ? staticBarsRef.current\n : active\n ? staticBarsRef.current\n : staticBarsRef.current.length > 0\n ? staticBarsRef.current\n : []\n\n for (let i = 0; i < barCount && i < dataToRender.length; i++) {\n const value = dataToRender[i] || 0.1\n const x = i * step\n const barHeight = Math.max(baseBarHeight, value * rect.height * 0.8)\n const y = centerY - barHeight / 2\n\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 0.4 + value * 0.6\n\n if (barRadius > 0) {\n ctx.beginPath()\n ctx.roundRect(x, y, barWidth, barHeight, barRadius)\n ctx.fill()\n } else {\n ctx.fillRect(x, y, barWidth, barHeight)\n }\n }\n } else {\n // Scrolling mode - original behavior\n for (let i = 0; i < barCount && i < historyRef.current.length; i++) {\n const dataIndex = historyRef.current.length - 1 - i\n const value = historyRef.current[dataIndex] || 0.1\n const x = rect.width - (i + 1) * step\n const barHeight = Math.max(baseBarHeight, value * rect.height * 0.8)\n const y = centerY - barHeight / 2\n\n ctx.fillStyle = computedBarColor\n ctx.globalAlpha = 0.4 + value * 0.6\n\n if (barRadius > 0) {\n ctx.beginPath()\n ctx.roundRect(x, y, barWidth, barHeight, barRadius)\n ctx.fill()\n } else {\n ctx.fillRect(x, y, barWidth, barHeight)\n }\n }\n }\n\n // Apply edge fading\n if (fadeEdges && fadeWidth > 0 && rect.width > 0) {\n // Cache gradient if width hasn't changed\n if (!gradientCacheRef.current || lastWidthRef.current !== rect.width) {\n const gradient = ctx.createLinearGradient(0, 0, rect.width, 0)\n const fadePercent = Math.min(0.3, fadeWidth / rect.width)\n\n // destination-out: removes destination where source alpha is high\n // We want: fade edges out, keep center solid\n // Left edge: start opaque (1) = remove, fade to transparent (0) = keep\n gradient.addColorStop(0, \"rgba(255,255,255,1)\")\n gradient.addColorStop(fadePercent, \"rgba(255,255,255,0)\")\n // Center stays transparent = keep everything\n gradient.addColorStop(1 - fadePercent, \"rgba(255,255,255,0)\")\n // Right edge: fade from transparent (0) = keep to opaque (1) = remove\n gradient.addColorStop(1, \"rgba(255,255,255,1)\")\n\n gradientCacheRef.current = gradient\n lastWidthRef.current = rect.width\n }\n\n ctx.globalCompositeOperation = \"destination-out\"\n ctx.fillStyle = gradientCacheRef.current\n ctx.fillRect(0, 0, rect.width, rect.height)\n ctx.globalCompositeOperation = \"source-over\"\n }\n\n ctx.globalAlpha = 1\n\n rafId = requestAnimationFrame(animate)\n }\n\n rafId = requestAnimationFrame(animate)\n\n return () => {\n if (rafId) {\n cancelAnimationFrame(rafId)\n }\n }\n }, [\n active,\n processing,\n sensitivity,\n updateRate,\n historySize,\n barWidth,\n baseBarHeight,\n barGap,\n barRadius,\n barColor,\n fadeEdges,\n fadeWidth,\n mode,\n ])\n\n return (\n \n {!active && !processing && (\n
\n )}\n \n
\n )\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }