{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-waveform",
  "description": "A canvas-based real-time audio waveform visualizer with microphone input and customizable rendering modes.",
  "registryDependencies": [
    "@ondo-ui/utils"
  ],
  "files": [
    {
      "path": "components/ui/live-waveform.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type LiveWaveformProps = React.HTMLAttributes<HTMLDivElement> & {\n  active?: boolean\n  processing?: boolean\n  stream?: MediaStream | null\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\nfunction prefersReducedMotion() {\n  return (\n    typeof window !== \"undefined\" &&\n    typeof window.matchMedia === \"function\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  )\n}\n\nexport const LiveWaveform = ({\n  active = false,\n  processing = false,\n  stream = null,\n  deviceId,\n  barWidth = 4,\n  barGap = 2,\n  barRadius = 2,\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 = React.useRef<HTMLCanvasElement>(null)\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const historyRef = React.useRef<number[]>([])\n  const analyserRef = React.useRef<AnalyserNode | null>(null)\n  const audioContextRef = React.useRef<AudioContext | null>(null)\n  const streamRef = React.useRef<MediaStream | null>(null)\n  const lastUpdateRef = React.useRef<number>(0)\n  const lastActiveDataRef = React.useRef<number[]>([])\n  const transitionProgressRef = React.useRef(0)\n  const staticBarsRef = React.useRef<number[]>([])\n  const needsRedrawRef = React.useRef(true)\n  const gradientCacheRef = React.useRef<CanvasGradient | null>(null)\n  const lastWidthRef = React.useRef(0)\n  // Wakes the render loop when it has parked itself in the idle state.\n  const wakeRef = React.useRef<(() => void) | null>(null)\n\n  // Keep the latest callbacks in refs so the microphone effect does not\n  // re-acquire the stream every time an inline callback identity changes.\n  const onErrorRef = React.useRef(onError)\n  const onStreamReadyRef = React.useRef(onStreamReady)\n  const onStreamEndRef = React.useRef(onStreamEnd)\n  React.useEffect(() => {\n    onErrorRef.current = onError\n    onStreamReadyRef.current = onStreamReady\n    onStreamEndRef.current = onStreamEnd\n  })\n\n  const heightStyle = typeof height === \"number\" ? `${height}px` : height\n\n  // Handle canvas resizing\n  React.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      wakeRef.current?.()\n    })\n\n    resizeObserver.observe(container)\n    return () => resizeObserver.disconnect()\n  }, [])\n\n  // Processing animation and fade-to-idle transition\n  React.useEffect(() => {\n    let raf = 0\n    const reduced = prefersReducedMotion()\n\n    if (processing && !active) {\n      const barCount = Math.floor(\n        (containerRef.current?.getBoundingClientRect().width || 200) /\n          (barWidth + barGap)\n      )\n\n      // Respect reduced motion: paint a single calm, symmetric frame instead\n      // of the looping wave.\n      if (reduced) {\n        const data: number[] = []\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          data.push(Math.max(0.05, 0.3 * centerWeight))\n        }\n        if (mode === \"static\") {\n          staticBarsRef.current = data\n        } else {\n          historyRef.current = data\n        }\n        needsRedrawRef.current = true\n        wakeRef.current?.()\n        return\n      }\n\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: number[] = []\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        wakeRef.current?.()\n        raf = requestAnimationFrame(animateProcessing)\n      }\n\n      animateProcessing()\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        // Reduced motion: drop straight to idle without the fade tween.\n        if (reduced) {\n          staticBarsRef.current = []\n          historyRef.current = []\n          needsRedrawRef.current = true\n          wakeRef.current?.()\n          return\n        }\n\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            wakeRef.current?.()\n            raf = requestAnimationFrame(fadeToIdle)\n          } else {\n            if (mode === \"static\") {\n              staticBarsRef.current = []\n            } else {\n              historyRef.current = []\n            }\n            needsRedrawRef.current = true\n            wakeRef.current?.()\n          }\n        }\n        fadeToIdle()\n      }\n    }\n\n    return () => {\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [processing, active, barWidth, barGap, mode])\n\n  // Handle microphone / stream setup and teardown\n  React.useEffect(() => {\n    const teardown = () => {\n      if (streamRef.current) {\n        streamRef.current.getTracks().forEach((track) => track.stop())\n        streamRef.current = null\n        onStreamEndRef.current?.()\n      }\n      if (\n        audioContextRef.current &&\n        audioContextRef.current.state !== \"closed\"\n      ) {\n        audioContextRef.current.close()\n      }\n      audioContextRef.current = null\n      analyserRef.current = null\n    }\n\n    if (!active) {\n      teardown()\n      return\n    }\n\n    let cancelled = false\n    // We own (and must stop) a stream only when we acquire it ourselves.\n    const ownsStream = !stream\n\n    const setup = async () => {\n      try {\n        const mediaStream =\n          stream ??\n          (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\n        if (cancelled) {\n          if (ownsStream) {\n            mediaStream.getTracks().forEach((track) => track.stop())\n          }\n          return\n        }\n\n        // Only track the stream for teardown when we own it.\n        streamRef.current = ownsStream ? mediaStream : null\n        onStreamReadyRef.current?.(mediaStream)\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(mediaStream)\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        onErrorRef.current?.(error as Error)\n      }\n    }\n\n    setup()\n\n    return () => {\n      cancelled = true\n      teardown()\n    }\n  }, [active, deviceId, fftSize, smoothingTimeConstant, stream])\n\n  // Render loop. Parks itself when idle and is woken via wakeRef.\n  React.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 = 0\n    let running = false\n\n    const frame = (currentTime: number) => {\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          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          if (mode === \"static\") {\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            let sum = 0\n            for (let i = 0; i < relevantData.length; i++) {\n              sum += relevantData[i]\n            }\n            const average = (sum / relevantData.length / 255) * sensitivity\n\n            historyRef.current.push(Math.min(1, Math.max(0.05, average)))\n            lastActiveDataRef.current = [...historyRef.current]\n\n            if (historyRef.current.length > historySize) {\n              historyRef.current.shift()\n            }\n          }\n          needsRedrawRef.current = true\n        }\n      }\n\n      needsRedrawRef.current = false\n      ctx.clearRect(0, 0, rect.width, rect.height)\n\n      const computedBarColor =\n        barColor ||\n        (() => {\n          const style = getComputedStyle(canvas)\n          return style.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      if (mode === \"static\") {\n        const dataToRender = staticBarsRef.current\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        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        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          gradient.addColorStop(0, \"rgba(255,255,255,1)\")\n          gradient.addColorStop(fadePercent, \"rgba(255,255,255,0)\")\n          gradient.addColorStop(1 - fadePercent, \"rgba(255,255,255,0)\")\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      // Keep looping while there is live audio or a processing animation;\n      // otherwise park until something wakes us (resize, fade, state change).\n      if (active || processing) {\n        rafId = requestAnimationFrame(frame)\n      } else {\n        running = false\n      }\n    }\n\n    const wake = () => {\n      if (running) return\n      running = true\n      rafId = requestAnimationFrame(frame)\n    }\n    wakeRef.current = wake\n\n    // Draw at least one frame so the current state is reflected immediately.\n    needsRedrawRef.current = true\n    wake()\n\n    return () => {\n      running = false\n      if (rafId) cancelAnimationFrame(rafId)\n      wakeRef.current = null\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    <div\n      className={cn(\"relative h-full w-full text-primary\", className)}\n      ref={containerRef}\n      style={{ height: heightStyle }}\n      aria-label={\n        active\n          ? \"Live audio waveform\"\n          : processing\n            ? \"Processing audio\"\n            : \"Audio waveform idle\"\n      }\n      role=\"img\"\n      data-slot=\"live-waveform\"\n      {...props}\n    >\n      {!active && !processing && (\n        <div className=\"absolute top-1/2 right-0 left-0 -translate-y-1/2 border-t-2 border-dotted border-muted-foreground/20\" />\n      )}\n      <canvas\n        className=\"block h-full w-full\"\n        ref={canvasRef}\n        aria-hidden=\"true\"\n      />\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}