{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-waveform",
  "type": "registry:ui",
  "title": "Live Waveform",
  "description": "Canvas-based real-time waveform visualizer with processing animation, microphone input, and stop controls.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ruixenui/live-waveform.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Square } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LiveWaveformProps {\n  /** Enable real-time microphone input visualization */\n  active?: boolean;\n  /** Show animated processing wave pattern */\n  processing?: boolean;\n  /** Bar width in pixels */\n  barWidth?: number;\n  /** Gap between bars in pixels */\n  barGap?: number;\n  /** Bar corner radius */\n  barRadius?: number;\n  /** Bar color — inherits currentColor when unset */\n  barColor?: string;\n  /** Waveform height in pixels */\n  height?: number;\n  /** Apply gradient fade at edges */\n  fadeEdges?: boolean;\n  /** Fade gradient width in pixels */\n  fadeWidth?: number;\n  /** Microphone sensitivity multiplier */\n  sensitivity?: number;\n  /** Callback when stop button is pressed */\n  onStop?: () => void;\n  /** Additional CSS classes */\n  className?: string;\n}\n\nfunction roundedBar(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  w: number,\n  h: number,\n  r: number,\n) {\n  const radius = Math.min(r, w / 2, h / 2);\n  ctx.beginPath();\n  ctx.moveTo(x + radius, y);\n  ctx.lineTo(x + w - radius, y);\n  ctx.arcTo(x + w, y, x + w, y + radius, radius);\n  ctx.lineTo(x + w, y + h - radius);\n  ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);\n  ctx.lineTo(x + radius, y + h);\n  ctx.arcTo(x, y + h, x, y + h - radius, radius);\n  ctx.lineTo(x, y + radius);\n  ctx.arcTo(x, y, x + radius, y, radius);\n  ctx.closePath();\n  ctx.fill();\n}\n\nexport default function LiveWaveform({\n  active = false,\n  processing = false,\n  barWidth = 3,\n  barGap = 2,\n  barRadius = 1.5,\n  barColor,\n  height = 48,\n  fadeEdges = true,\n  fadeWidth = 24,\n  sensitivity = 1,\n  onStop,\n  className,\n}: LiveWaveformProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const frameRef = React.useRef<number>(0);\n  const analyserRef = React.useRef<AnalyserNode | null>(null);\n  const streamRef = React.useRef<MediaStream | null>(null);\n  const audioCtxRef = React.useRef<AudioContext | null>(null);\n  const dataRef = React.useRef<Uint8Array<ArrayBuffer> | null>(null);\n\n  const color = React.useCallback(() => {\n    if (barColor) return barColor;\n    const el = canvasRef.current;\n    if (el) return getComputedStyle(el).color;\n    return \"#a1a1aa\";\n  }, [barColor]);\n\n  /* -------- canvas animation -------- */\n  React.useEffect(() => {\n    const cvs = canvasRef.current;\n    const wrap = wrapRef.current;\n    if (!cvs || !wrap) return;\n    const ctx = cvs.getContext(\"2d\");\n    if (!ctx) return;\n\n    const dpr = window.devicePixelRatio || 1;\n\n    const resize = () => {\n      const width = wrap.offsetWidth;\n      cvs.width = Math.round(width * dpr);\n      cvs.height = Math.round(height * dpr);\n      cvs.style.width = `${width}px`;\n      cvs.style.height = `${height}px`;\n    };\n    resize();\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(wrap);\n\n    const t0 = performance.now();\n\n    const paint = (now: number) => {\n      const w = wrap.offsetWidth;\n      const h = height;\n\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.fillStyle = color();\n\n      const step = barWidth + barGap;\n      const count = Math.ceil(w / step) + 1;\n      const t = (now - t0) / 1000;\n      const cy = h / 2;\n\n      for (let i = 0; i < count; i++) {\n        const x = i * step;\n        const pos = i / count;\n        let bh = 4;\n        let alpha = 1;\n\n        if (processing && !active) {\n          /* layered sine waves → organic flowing animation */\n          const s1 = Math.sin(t * 2.5 + pos * Math.PI * 4) * 0.38;\n          const s2 = Math.sin(t * 1.7 + pos * Math.PI * 6 + 1.3) * 0.24;\n          const s3 = Math.sin(t * 3.3 + pos * Math.PI * 2.5 + 0.8) * 0.14;\n          const amp = (s1 + s2 + s3 + 0.76) / 1.52;\n          bh = Math.max(4, amp * h * 0.82);\n        } else if (active && analyserRef.current) {\n          const an = analyserRef.current;\n          if (\n            !dataRef.current ||\n            dataRef.current.length !== an.frequencyBinCount\n          ) {\n            dataRef.current = new Uint8Array(an.frequencyBinCount);\n          }\n          an.getByteFrequencyData(dataRef.current);\n          const idx = Math.floor(pos * dataRef.current.length);\n          const val = (dataRef.current[idx] / 255) * sensitivity;\n          bh = Math.max(4, val * h * 0.85);\n        } else {\n          /* idle: tiny flat bars */\n          alpha = 0.25;\n        }\n\n        if (fadeEdges) {\n          const edge = Math.min(x, w - x - barWidth);\n          if (edge < fadeWidth) alpha *= Math.max(0, edge / fadeWidth);\n        }\n\n        ctx.globalAlpha = alpha;\n        roundedBar(ctx, x, cy - bh / 2, barWidth, bh, barRadius);\n      }\n\n      ctx.globalAlpha = 1;\n      frameRef.current = requestAnimationFrame(paint);\n    };\n\n    frameRef.current = requestAnimationFrame(paint);\n\n    return () => {\n      cancelAnimationFrame(frameRef.current);\n      ro.disconnect();\n    };\n  }, [\n    active,\n    processing,\n    barWidth,\n    barGap,\n    barRadius,\n    height,\n    fadeEdges,\n    fadeWidth,\n    sensitivity,\n    color,\n  ]);\n\n  /* -------- microphone -------- */\n  React.useEffect(() => {\n    if (!active) {\n      streamRef.current?.getTracks().forEach((t) => t.stop());\n      streamRef.current = null;\n      audioCtxRef.current?.close();\n      audioCtxRef.current = null;\n      analyserRef.current = null;\n      return;\n    }\n\n    let cancelled = false;\n\n    (async () => {\n      try {\n        const stream = await navigator.mediaDevices.getUserMedia({\n          audio: true,\n        });\n        if (cancelled) {\n          stream.getTracks().forEach((t) => t.stop());\n          return;\n        }\n        streamRef.current = stream;\n\n        const actx = new AudioContext();\n        audioCtxRef.current = actx;\n\n        const src = actx.createMediaStreamSource(stream);\n        const analyser = actx.createAnalyser();\n        analyser.fftSize = 256;\n        analyser.smoothingTimeConstant = 0.8;\n        src.connect(analyser);\n        analyserRef.current = analyser;\n      } catch (e) {\n        console.error(\"Microphone access failed:\", e);\n      }\n    })();\n\n    return () => {\n      cancelled = true;\n      streamRef.current?.getTracks().forEach((t) => t.stop());\n      streamRef.current = null;\n      audioCtxRef.current?.close();\n      audioCtxRef.current = null;\n      analyserRef.current = null;\n    };\n  }, [active]);\n\n  const isLive = processing || active;\n\n  return (\n    <div\n      className={cn(\n        \"relative flex items-center gap-3 rounded-xl border border-border/50 bg-card px-4 py-3\",\n        className,\n      )}\n    >\n      {isLive && onStop && (\n        <button\n          onClick={onStop}\n          className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-foreground text-background transition-opacity hover:opacity-80\"\n          aria-label=\"Stop\"\n        >\n          <Square className=\"size-3 fill-current\" />\n        </button>\n      )}\n      <div ref={wrapRef} className=\"min-w-0 flex-1 overflow-hidden\">\n        <canvas\n          ref={canvasRef}\n          className=\"block text-foreground\"\n          style={{ height }}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/live-waveform.tsx"
    }
  ]
}