{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "smooth-waveform",
  "title": "Smooth Waveform",
  "description": "One clean line across the width that follows the sound, as a smooth wave shaped by the frequency bands or a triggered oscilloscope trace.",
  "registryDependencies": [
    "@audiocn/core",
    "@audiocn/use-frame-source",
    "@audiocn/use-reduced-motion",
    "@audiocn/use-visibility"
  ],
  "files": [
    {
      "path": "components/ui/smooth-waveform.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useEffectEvent,\n  useImperativeHandle,\n  useRef,\n} from \"react\";\nimport type { ComponentProps, Ref } from \"react\";\n\nimport { useFrameSource } from \"@/hooks/use-frame-source\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibility } from \"@/hooks/use-visibility\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { subscribeFrame } from \"@/lib/audio/frame-loop\";\nimport type { FrameSource, VisualFrame } from \"@/lib/audio/types\";\nimport { createWaveLine } from \"@/lib/audio/wave-line\";\nimport type { WaveLine, WaveLineMode } from \"@/lib/audio/wave-line\";\nimport { cn } from \"@/lib/utils\";\n\nconst DEFAULT_LINE_WIDTH = 2;\nconst REDUCED_MOTION_INTERVAL_MS = 250;\nconst COLOR_REFRESH_FRAMES = 30;\n/** One point every this many pixels along the line. */\nconst POINT_SPACING_PX = 3;\n/** Width of the faded ends, in pixels. */\nconst FADE_PX = 24;\n\nexport type SmoothWaveformMode = WaveLineMode;\n\nexport interface SmoothWaveformActions {\n  /** Paint a frame directly. */\n  paint: (frame: VisualFrame) => void;\n  /** Forget the last frame, so the line falls flat. */\n  clear: () => void;\n}\n\nexport interface SmoothWaveformProps extends ComponentProps<\"div\"> {\n  source?: FrameSource<VisualFrame> | null;\n  /**\n   * `wave` draws a smooth wave shaped by the frequency bands. `scope` draws\n   * the signal itself, like an oscilloscope. Default `wave`.\n   */\n  mode?: SmoothWaveformMode;\n  /** Runs a pulse along the line, for connecting or thinking states. Default false. */\n  loading?: boolean;\n  /** Visual gain. Default 1. */\n  sensitivity?: number;\n  /** Line width in pixels. Default 2. */\n  lineWidth?: number;\n  /** Fade the left and right ends. Default true. */\n  fadeEdges?: boolean;\n  actionsRef?: Ref<SmoothWaveformActions>;\n}\n\ninterface Size {\n  width: number;\n  height: number;\n  ratio: number;\n}\n\nconst drawLine = (\n  context: CanvasRenderingContext2D,\n  line: WaveLine,\n  size: Size,\n  lineWidth: number\n) => {\n  const middle = size.height / 2;\n  const reach = Math.max(0, middle - lineWidth);\n  context.lineWidth = lineWidth;\n  context.lineCap = \"round\";\n  context.lineJoin = \"round\";\n  context.beginPath();\n  for (let point = 0; point < line.count; point += 1) {\n    const x = (point / (line.count - 1)) * size.width;\n    const y = middle - (line.heights[point] ?? 0) * reach;\n    if (point === 0) {\n      context.moveTo(x, y);\n    } else {\n      context.lineTo(x, y);\n    }\n  }\n  context.stroke();\n};\n\n/** Cuts the ends away with a gradient, so the line fades in and out. */\nconst fadeEnds = (context: CanvasRenderingContext2D, size: Size) => {\n  const edge = Math.min(FADE_PX, size.width / 2);\n  context.globalCompositeOperation = \"destination-out\";\n  const left = context.createLinearGradient(0, 0, edge, 0);\n  left.addColorStop(0, \"rgba(0, 0, 0, 1)\");\n  left.addColorStop(1, \"rgba(0, 0, 0, 0)\");\n  context.fillStyle = left;\n  context.fillRect(0, 0, edge, size.height);\n  const right = context.createLinearGradient(\n    size.width - edge,\n    0,\n    size.width,\n    0\n  );\n  right.addColorStop(0, \"rgba(0, 0, 0, 0)\");\n  right.addColorStop(1, \"rgba(0, 0, 0, 1)\");\n  context.fillStyle = right;\n  context.fillRect(size.width - edge, 0, edge, size.height);\n  context.globalCompositeOperation = \"source-over\";\n};\n\nexport const SmoothWaveform = ({\n  source,\n  mode = \"wave\",\n  loading = false,\n  sensitivity = 1,\n  lineWidth = DEFAULT_LINE_WIDTH,\n  fadeEdges = true,\n  actionsRef,\n  className,\n  ref,\n  ...props\n}: SmoothWaveformProps) => {\n  const reducedMotion = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const visibleRef = useVisibility(rootRef);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const frameRef = useRef<VisualFrame | null>(null);\n\n  const paint = useCallback((frame: VisualFrame) => {\n    frameRef.current = frame;\n  }, []);\n\n  useFrameSource(source, paint);\n\n  useImperativeHandle(\n    actionsRef,\n    () => ({\n      clear: () => {\n        frameRef.current = null;\n      },\n      paint,\n    }),\n    [paint]\n  );\n\n  // Stroke options are read each paint, so changing them doesn't rebuild the\n  // line and snap it flat.\n  const readStroke = useEffectEvent(() => ({\n    fade: fadeEdges,\n    width: lineWidth,\n  }));\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const context = canvas?.getContext(\"2d\");\n    if (!(root && canvas && context)) {\n      return;\n    }\n    const line = createWaveLine({ loading, mode, reducedMotion, sensitivity });\n    const size: Size = { height: 0, ratio: 1, width: 0 };\n    let { color } = getComputedStyle(canvas);\n    let framesSinceColor = 0;\n    let lastPaintMs = 0;\n    let active = false;\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      size.ratio = window.devicePixelRatio || 1;\n      size.width = rect.width;\n      size.height = rect.height;\n      canvas.width = Math.max(1, Math.round(rect.width * size.ratio));\n      canvas.height = Math.max(1, Math.round(rect.height * size.ratio));\n    };\n    resize();\n    const observer = new ResizeObserver(resize);\n    observer.observe(canvas);\n\n    const tick = (nowMs: number) => {\n      if (!visibleRef.current || size.width === 0) {\n        return;\n      }\n      framesSinceColor += 1;\n      if (framesSinceColor >= COLOR_REFRESH_FRAMES) {\n        framesSinceColor = 0;\n        ({ color } = getComputedStyle(canvas));\n      }\n      if (reducedMotion && nowMs - lastPaintMs < REDUCED_MOTION_INTERVAL_MS) {\n        return;\n      }\n      lastPaintMs = nowMs;\n      const count = Math.round(size.width / POINT_SPACING_PX) + 1;\n      const nextActive = line.step(nowMs, frameRef.current, count);\n      if (nextActive !== active) {\n        active = nextActive;\n        root.toggleAttribute(\"data-active\", active);\n      }\n      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);\n      context.clearRect(0, 0, size.width, size.height);\n      context.strokeStyle = color;\n      const stroke = readStroke();\n      drawLine(context, line, size, clamp(stroke.width, 0.5, size.height / 2));\n      if (stroke.fade) {\n        fadeEnds(context, size);\n      }\n    };\n\n    const unsubscribe = subscribeFrame(tick);\n    return () => {\n      unsubscribe();\n      observer.disconnect();\n      delete root.dataset.active;\n    };\n  }, [loading, mode, reducedMotion, sensitivity, visibleRef]);\n\n  const setRootRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref]\n  );\n\n  return (\n    <div\n      aria-label=\"Audio waveform\"\n      className={cn(\n        \"relative h-24 w-full [--waveform:currentColor]\",\n        className\n      )}\n      data-loading={loading ? \"\" : undefined}\n      data-mode={mode}\n      data-slot=\"smooth-waveform\"\n      role=\"img\"\n      {...props}\n      ref={setRootRef}\n    >\n      <canvas\n        aria-hidden\n        className=\"absolute inset-0 size-full text-(--waveform)\"\n        data-slot=\"smooth-waveform-canvas\"\n        ref={canvasRef}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}