{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "live-waveform",
  "title": "Live Waveform",
  "description": "A canvas waveform of a live signal: scrolling level history or the current frame, as bars, a mirrored shape or a line.",
  "registryDependencies": [
    "@audiocn/core",
    "@audiocn/use-frame-source",
    "@audiocn/use-reduced-motion",
    "@audiocn/use-visibility"
  ],
  "files": [
    {
      "path": "components/ui/live-waveform.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\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 { resampleLevels } from \"@/lib/audio/bands\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { createFrameTask } from \"@/lib/audio/frame-loop\";\nimport { createHistoryPlayback } from \"@/lib/audio/history-playback\";\nimport type { FrameSource, VisualFrame } from \"@/lib/audio/types\";\nimport { cn } from \"@/lib/utils\";\n\nconst COLOR_REFRESH_FRAMES = 30;\nconst REDUCED_MOTION_INTERVAL_MS = 250;\nconst IDLE_ALPHA = 0.35;\n\nconst noop = () => {\n  // Nothing to wake before the painter starts.\n};\n\n/**\n * Calls `onChange` when the page theme may have changed the canvas colour: a\n * class, style or data-theme change on the root element, or the system colour\n * scheme. A sleeping painter would otherwise keep the old colour.\n */\nconst observeTheme = (onChange: () => void) => {\n  const stops: (() => void)[] = [];\n  if (typeof MutationObserver !== \"undefined\") {\n    const observer = new MutationObserver(onChange);\n    observer.observe(document.documentElement, {\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n      attributes: true,\n    });\n    stops.push(() => {\n      observer.disconnect();\n    });\n  }\n  if (typeof window.matchMedia === \"function\") {\n    const media = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    media.addEventListener(\"change\", onChange);\n    stops.push(() => {\n      media.removeEventListener(\"change\", onChange);\n    });\n  }\n  return () => {\n    for (const stop of stops) {\n      stop();\n    }\n  };\n};\n\nexport interface LiveWaveformActions {\n  /** Paint a frame directly. */\n  paint: (frame: VisualFrame) => void;\n  /** Clear the canvas and forget the last frame. */\n  clear: () => void;\n}\n\nexport interface LiveWaveformProps extends ComponentProps<\"div\"> {\n  source?: FrameSource<VisualFrame> | null;\n  /** `scrolling` shows the level history; `static` shows the current frame. Default `static`. */\n  mode?: \"scrolling\" | \"static\";\n  /** Default `bars`. `line` draws the waveform trace. */\n  variant?: \"bars\" | \"line\" | \"mirror\";\n  /** Bar width in pixels. Default 3. */\n  barWidth?: number;\n  /** Gap between bars in pixels. Default 1. */\n  barGap?: number;\n  /** Bar corner radius in pixels. Default 1.5. */\n  barRadius?: number;\n  /** Smallest bar height in pixels. Default 4. */\n  minBarHeight?: number;\n  /** Line width for the `line` variant. Default 1.5. */\n  lineWidth?: number;\n  /** Fade the left and right edges. Default true. */\n  fadeEdges?: boolean;\n  /** Width of the fade in pixels. Default 24. */\n  fadeWidth?: number;\n  /** When false, shows an idle dotted line. Default true. */\n  active?: boolean;\n  /** Visual gain. Default 1. */\n  sensitivity?: number;\n  actionsRef?: Ref<LiveWaveformActions>;\n}\n\ninterface Size {\n  width: number;\n  height: number;\n  ratio: number;\n}\n\ninterface DrawOptions {\n  mode: \"scrolling\" | \"static\";\n  variant: \"bars\" | \"line\" | \"mirror\";\n  barWidth: number;\n  barGap: number;\n  barRadius: number;\n  minBarHeight: number;\n  lineWidth: number;\n  fadeEdges: boolean;\n  fadeWidth: number;\n  active: boolean;\n  sensitivity: number;\n}\n\nconst drawBar = (\n  context: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  radius: number\n) => {\n  if (typeof context.roundRect === \"function\") {\n    context.beginPath();\n    context.roundRect(\n      x,\n      y,\n      width,\n      height,\n      Math.min(radius, width / 2, height / 2)\n    );\n    context.fill();\n  } else {\n    context.fillRect(x, y, width, height);\n  }\n};\n\nconst historyLevels = (frame: VisualFrame, count: number) => {\n  const available = Math.min(frame.historyLength, count);\n  const size = frame.history.length;\n  const includePrevious =\n    available === size &&\n    available < count &&\n    frame.historyPreviousLevel !== undefined;\n  const offset = includePrevious ? 1 : 0;\n  const out = new Float32Array(available + offset);\n  if (includePrevious) {\n    out[0] = frame.historyPreviousLevel ?? 0;\n  }\n  const first = frame.historyStart + frame.historyLength - available;\n  for (let index = 0; index < available; index += 1) {\n    out[index + offset] = frame.history[(first + index) % size] ?? 0;\n  }\n  return out;\n};\n\nconst levelsFor = (\n  frame: VisualFrame,\n  count: number,\n  options: DrawOptions\n): Float32Array => {\n  if (options.mode === \"scrolling\") {\n    return historyLevels(frame, count + 1);\n  }\n  if (options.variant !== \"mirror\") {\n    return resampleLevels(\n      frame.bands,\n      0,\n      frame.bands.length,\n      new Float32Array(count)\n    );\n  }\n  const half = resampleLevels(\n    frame.bands,\n    0,\n    frame.bands.length,\n    new Float32Array(Math.ceil(count / 2))\n  );\n  const out = new Float32Array(count);\n  const center = (count - 1) / 2;\n  for (let index = 0; index < count; index += 1) {\n    out[index] =\n      half[Math.min(half.length - 1, Math.floor(Math.abs(index - center)))] ??\n      0;\n  }\n  return out;\n};\n\nconst drawIdle = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  options: DrawOptions\n) => {\n  const step = (options.barWidth + options.barGap) * 2;\n  const y = size.height / 2 - 0.5;\n  context.globalAlpha = IDLE_ALPHA;\n  for (let x = 0; x < size.width; x += step) {\n    context.fillRect(x, y, options.barWidth, 1);\n  }\n  context.globalAlpha = 1;\n};\n\nconst drawLine = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  frame: VisualFrame,\n  options: DrawOptions,\n  scrollProgress: number\n) => {\n  const middle = size.height / 2;\n  context.lineWidth = options.lineWidth;\n  context.lineJoin = \"round\";\n  context.beginPath();\n\n  if (\n    options.mode === \"static\" &&\n    frame.timeDomain &&\n    frame.timeDomain.length > 1\n  ) {\n    const samples = frame.timeDomain;\n    for (let index = 0; index < samples.length; index += 1) {\n      const x = (index / (samples.length - 1)) * size.width;\n      const value = clamp((samples[index] ?? 0) * options.sensitivity, -1, 1);\n      const y = middle - value * (middle - options.lineWidth);\n      if (index === 0) {\n        context.moveTo(x, y);\n      } else {\n        context.lineTo(x, y);\n      }\n    }\n    context.stroke();\n    return;\n  }\n\n  const count = Math.max(\n    2,\n    Math.floor(size.width / (options.barWidth + options.barGap))\n  );\n  const levels = levelsFor(frame, count, { ...options, variant: \"bars\" });\n  const offset = count - levels.length + 1 - scrollProgress;\n  for (let index = 0; index < levels.length; index += 1) {\n    const x = ((offset + index) / (count - 1)) * size.width;\n    const value = clamp((levels[index] ?? 0) * options.sensitivity, 0, 1);\n    const y = middle - value * (middle - options.lineWidth);\n    if (index === 0) {\n      context.moveTo(x, y);\n    } else {\n      context.lineTo(x, y);\n    }\n  }\n  for (let index = levels.length - 1; index >= 0; index -= 1) {\n    const x = ((offset + index) / (count - 1)) * size.width;\n    const value = clamp((levels[index] ?? 0) * options.sensitivity, 0, 1);\n    context.lineTo(x, middle + value * (middle - options.lineWidth));\n  }\n  context.closePath();\n  context.globalAlpha = 0.25;\n  context.fill();\n  context.globalAlpha = 1;\n  context.stroke();\n};\n\nconst drawLevelBar = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  options: DrawOptions,\n  x: number,\n  level: number\n) => {\n  const value = clamp(level * options.sensitivity, 0, 1);\n  const height = Math.max(options.minBarHeight, value * size.height);\n  const y = (size.height - height) / 2;\n  context.globalAlpha = 0.4 + 0.6 * value;\n  drawBar(context, x, y, options.barWidth, height, options.barRadius);\n};\n\nconst drawScrollingMirror = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  frame: VisualFrame,\n  options: DrawOptions,\n  scrollProgress: number,\n  count: number\n) => {\n  const levels = historyLevels(frame, Math.ceil(count / 2) + 1);\n  const center = (size.width - options.barWidth) / 2;\n  const pitch = options.barWidth + options.barGap;\n  context.save();\n  context.beginPath();\n  context.rect(0, 0, center, size.height);\n  context.rect(center + options.barWidth, 0, center, size.height);\n  context.clip();\n  for (let age = levels.length - 1; age > 0; age -= 1) {\n    const value = levels[levels.length - 1 - age] ?? 0;\n    const distance = (age - 1 + scrollProgress) * pitch;\n    drawLevelBar(context, size, options, center - distance, value);\n    drawLevelBar(context, size, options, center + distance, value);\n  }\n  context.restore();\n  if (levels.length > 0) {\n    const newest = levels.at(-1) ?? 0;\n    const previous = levels.at(-2) ?? newest;\n    drawLevelBar(\n      context,\n      size,\n      options,\n      center,\n      previous + (newest - previous) * scrollProgress\n    );\n  }\n};\n\nconst drawBars = (\n  context: CanvasRenderingContext2D,\n  size: Size,\n  frame: VisualFrame,\n  options: DrawOptions,\n  scrollProgress: number\n) => {\n  const pitch = options.barWidth + options.barGap;\n  const count = Math.max(1, Math.floor((size.width + options.barGap) / pitch));\n  if (options.mode === \"scrolling\" && options.variant === \"mirror\") {\n    drawScrollingMirror(context, size, frame, options, scrollProgress, count);\n  } else {\n    const levels = levelsFor(frame, count, options);\n    const offset = count - levels.length + 1 - scrollProgress;\n    const used = count * pitch - options.barGap;\n    const left = (size.width - used) / 2;\n    for (let index = 0; index < levels.length; index += 1) {\n      const x = left + (offset + index) * pitch;\n      drawLevelBar(context, size, options, x, levels[index] ?? 0);\n    }\n  }\n  context.globalAlpha = 1;\n};\n\nconst fade = (context: CanvasRenderingContext2D, size: Size, width: number) => {\n  const edge = Math.min(width, size.width / 2);\n  context.globalCompositeOperation = \"destination-out\";\n  const leftGradient = context.createLinearGradient(0, 0, edge, 0);\n  leftGradient.addColorStop(0, \"rgba(0, 0, 0, 1)\");\n  leftGradient.addColorStop(1, \"rgba(0, 0, 0, 0)\");\n  context.fillStyle = leftGradient;\n  context.fillRect(0, 0, edge, size.height);\n  const rightGradient = context.createLinearGradient(\n    size.width - edge,\n    0,\n    size.width,\n    0\n  );\n  rightGradient.addColorStop(0, \"rgba(0, 0, 0, 0)\");\n  rightGradient.addColorStop(1, \"rgba(0, 0, 0, 1)\");\n  context.fillStyle = rightGradient;\n  context.fillRect(size.width - edge, 0, edge, size.height);\n  context.globalCompositeOperation = \"source-over\";\n};\n\nexport const LiveWaveform = ({\n  source,\n  mode = \"static\",\n  variant = \"bars\",\n  barWidth = 3,\n  barGap = 1,\n  barRadius = 1.5,\n  minBarHeight = 4,\n  lineWidth = 1.5,\n  fadeEdges = true,\n  fadeWidth = 24,\n  active = true,\n  sensitivity = 1,\n  actionsRef,\n  className,\n  ref,\n  ...props\n}: LiveWaveformProps) => {\n  const reducedMotion = useReducedMotion();\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  // Wakes the painter when there is something new to draw. A drawn canvas\n  // sleeps until then.\n  const wakeRef = useRef<() => void>(noop);\n  const visibleRef = useVisibility(canvasRef, (visible) => {\n    if (visible) {\n      wakeRef.current();\n    }\n  });\n  const frameRef = useRef<VisualFrame | null>(null);\n  const dirtyRef = useRef(true);\n  const historyPlayback = useMemo(() => createHistoryPlayback(), []);\n  const previousSourceRef = useRef(source);\n\n  useEffect(() => {\n    if (previousSourceRef.current !== source) {\n      historyPlayback.clear();\n      frameRef.current = null;\n      dirtyRef.current = true;\n      previousSourceRef.current = source;\n      wakeRef.current();\n    }\n  }, [historyPlayback, source]);\n\n  const paint = useCallback((frame: VisualFrame) => {\n    frameRef.current = frame;\n    dirtyRef.current = true;\n    wakeRef.current();\n  }, []);\n\n  useFrameSource(source, paint);\n\n  useImperativeHandle(\n    actionsRef,\n    () => ({\n      clear: () => {\n        historyPlayback.clear();\n        frameRef.current = null;\n        dirtyRef.current = true;\n        wakeRef.current();\n      },\n      paint,\n    }),\n    [historyPlayback, paint]\n  );\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const context = canvas?.getContext(\"2d\");\n    if (!(canvas && context)) {\n      return;\n    }\n    const options: DrawOptions = {\n      active,\n      barGap,\n      barRadius,\n      barWidth,\n      fadeEdges,\n      fadeWidth,\n      lineWidth,\n      minBarHeight,\n      mode,\n      sensitivity,\n      variant,\n    };\n    const size: Size = { height: 0, ratio: 1, width: 0 };\n    let color = \"\";\n    let framesSinceColor = COLOR_REFRESH_FRAMES;\n    let lastPaintMs = 0;\n    let wakeTask = noop;\n    let lastScrollProgress = 1;\n    dirtyRef.current = true;\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      framesSinceColor = COLOR_REFRESH_FRAMES;\n      dirtyRef.current = true;\n      wakeTask();\n    };\n    resize();\n    const observer = new ResizeObserver(resize);\n    observer.observe(canvas);\n\n    const tick = (nowMs: number): boolean => {\n      framesSinceColor += 1;\n      if (framesSinceColor >= COLOR_REFRESH_FRAMES) {\n        framesSinceColor = 0;\n        const next = getComputedStyle(canvas).color;\n        if (next !== color) {\n          color = next;\n          dirtyRef.current = true;\n        }\n      }\n      let frame = frameRef.current;\n      let scrollProgress = 1;\n      if (\n        frame &&\n        options.active &&\n        options.mode === \"scrolling\" &&\n        !reducedMotion\n      ) {\n        ({ frame, progress: scrollProgress } = historyPlayback.read(\n          frame,\n          nowMs\n        ));\n      } else {\n        historyPlayback.clear();\n      }\n      if (scrollProgress !== lastScrollProgress) {\n        dirtyRef.current = true;\n      }\n      // Off screen it stays dirty, so it repaints when it comes back.\n      if (size.width === 0 || !visibleRef.current) {\n        return false;\n      }\n      if (!dirtyRef.current) {\n        return scrollProgress < 1;\n      }\n      if (reducedMotion && nowMs - lastPaintMs < REDUCED_MOTION_INTERVAL_MS) {\n        return true;\n      }\n      lastPaintMs = nowMs;\n      lastScrollProgress = scrollProgress;\n      dirtyRef.current = false;\n\n      context.setTransform(size.ratio, 0, 0, size.ratio, 0, 0);\n      context.clearRect(0, 0, size.width, size.height);\n      context.fillStyle = color;\n      context.strokeStyle = color;\n\n      if (!(options.active && frame)) {\n        drawIdle(context, size, options);\n        return false;\n      }\n      if (options.variant === \"line\") {\n        drawLine(context, size, frame, options, scrollProgress);\n      } else {\n        drawBars(context, size, frame, options, scrollProgress);\n      }\n      if (options.fadeEdges) {\n        fade(context, size, options.fadeWidth);\n      }\n      return scrollProgress < 1;\n    };\n\n    const task = createFrameTask(tick);\n    wakeTask = task.wake;\n    wakeRef.current = task.wake;\n    const stopObservingTheme = observeTheme(() => {\n      framesSinceColor = COLOR_REFRESH_FRAMES;\n      task.wake();\n    });\n    return () => {\n      wakeRef.current = noop;\n      task.stop();\n      observer.disconnect();\n      stopObservingTheme();\n    };\n  }, [\n    active,\n    barGap,\n    barRadius,\n    barWidth,\n    fadeEdges,\n    fadeWidth,\n    historyPlayback,\n    lineWidth,\n    minBarHeight,\n    mode,\n    reducedMotion,\n    sensitivity,\n    variant,\n    visibleRef,\n  ]);\n\n  return (\n    <div\n      aria-label=\"Live waveform\"\n      className={cn(\n        \"relative h-16 w-full [--waveform:currentColor]\",\n        className\n      )}\n      data-active={active ? \"\" : undefined}\n      data-mode={mode}\n      data-slot=\"live-waveform\"\n      data-variant={variant}\n      ref={ref}\n      role=\"img\"\n      {...props}\n    >\n      <canvas\n        className=\"absolute inset-0 size-full text-(--waveform)\"\n        data-slot=\"live-waveform-canvas\"\n        ref={canvasRef}\n      />\n    </div>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}