{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-visualizer",
  "title": "Bar Visualizer",
  "description": "Frequency bars for audio on its way out, with the state said as well as drawn.",
  "registryDependencies": [
    "utils",
    "https://ui.tinkererslabs.com/r/render-surface.json"
  ],
  "files": [
    {
      "path": "registry/default/bar-visualizer/bar-visualizer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport {\n  RenderSurface,\n  useThemeColors,\n  type SurfaceColor,\n} from \"@/registry/default/render-surface/render-surface\"\nimport { cn } from \"@/lib/utils\"\n\nexport type BarVisualizerState = \"idle\" | \"listening\" | \"speaking\"\n\nexport type BarVisualizerSource =\n  MediaStream | HTMLMediaElement | AnalyserNode | null\n\nexport type BarVisualizerProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  /** The audio to draw. Without one the bars rest at their idle height. */\n  source?: BarVisualizerSource\n  state?: BarVisualizerState\n  bars?: number\n  /** A theme token for the bars. */\n  color?: string\n}\n\nconst REST = 0.06\nconst FFT = 256\n\n/**\n * An element can be handed to createMediaElementSource exactly once, and a\n * second call throws for the lifetime of the page. Development remounts every\n * effect twice, so the tap is kept and handed back instead of rebuilt.\n */\nconst taps = new WeakMap<\n  HTMLMediaElement,\n  { audio: AudioContext; node: MediaElementAudioSourceNode }\n>()\n\nfunction tap(element: HTMLMediaElement) {\n  const found = taps.get(element)\n  if (found) return found\n\n  const audio = new AudioContext()\n  const built = { audio, node: audio.createMediaElementSource(element) }\n\n  taps.set(element, built)\n  return built\n}\n\nconst wording: Record<BarVisualizerState, string> = {\n  idle: \"Idle.\",\n  listening: \"Listening.\",\n  speaking: \"Speaking.\",\n}\n\n/**\n * Frequency bars for audio that is playing or arriving, and the counterpart to\n * the trace a microphone draws while it records.\n *\n * The state is carried in words as well as height, because a reader who has\n * asked for reduced motion is shown a still frame, and a still bar chart says\n * nothing about whether anything is happening.\n */\nexport function BarVisualizer({\n  source = null,\n  state = \"idle\",\n  bars = 24,\n  color = \"--primary\",\n  className,\n  ...rootProps\n}: BarVisualizerProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const analyserRef = React.useRef<AnalyserNode | null>(null)\n  const samples = React.useRef<Uint8Array<ArrayBuffer> | null>(null)\n\n  const tokens = React.useMemo(\n    () => (color.startsWith(\"--\") ? [color] : []),\n    [color]\n  )\n  const resolved = useThemeColors(rootRef, tokens)\n  const ink = color.startsWith(\"--\") ? resolved[color] : undefined\n\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const sync = () => setReduced(query.matches)\n\n    sync()\n    query.addEventListener(\"change\", sync)\n    return () => query.removeEventListener(\"change\", sync)\n  }, [])\n\n  React.useEffect(() => {\n    if (!source) {\n      analyserRef.current = null\n      samples.current = null\n      return\n    }\n\n    if (source instanceof AnalyserNode) {\n      analyserRef.current = source\n      samples.current = new Uint8Array(source.frequencyBinCount)\n      return\n    }\n\n    if (source instanceof MediaStream) {\n      const audio = new AudioContext()\n      const analyser = audio.createAnalyser()\n      analyser.fftSize = FFT\n\n      audio.createMediaStreamSource(source).connect(analyser)\n      analyserRef.current = analyser\n      samples.current = new Uint8Array(analyser.frequencyBinCount)\n\n      return () => {\n        analyserRef.current = null\n        samples.current = null\n        void audio.close().catch(() => {})\n      }\n    }\n\n    const tapped = tap(source)\n    const analyser = tapped.audio.createAnalyser()\n    analyser.fftSize = FFT\n\n    // Routing an element through the graph takes it off the speakers, so the\n    // analyser has to pass it along to the destination or the audio goes\n    // silent.\n    tapped.node.disconnect()\n    tapped.node.connect(analyser)\n    analyser.connect(tapped.audio.destination)\n\n    analyserRef.current = analyser\n    samples.current = new Uint8Array(analyser.frequencyBinCount)\n\n    return () => {\n      analyserRef.current = null\n      samples.current = null\n      analyser.disconnect()\n      tapped.node.disconnect()\n      tapped.node.connect(tapped.audio.destination)\n    }\n  }, [source])\n\n  const setup = React.useCallback(() => null, [])\n\n  const draw = React.useCallback(\n    ({\n      context,\n      size: box,\n    }: {\n      context: CanvasRenderingContext2D\n      size: { width: number; height: number; dpr: number }\n    }) => {\n      if (!ink) return\n\n      context.setTransform(box.dpr, 0, 0, box.dpr, 0, 0)\n      context.clearRect(0, 0, box.width, box.height)\n\n      const analyser = analyserRef.current\n      const data = samples.current\n\n      if (analyser && data) analyser.getByteFrequencyData(data)\n\n      const [r, g, b] = ink as SurfaceColor\n      const width = box.width / bars\n      const middle = box.height / 2\n\n      context.fillStyle = `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`\n\n      for (let bar = 0; bar < bars; bar += 1) {\n        let level = REST\n\n        if (data && data.length > 0 && state !== \"idle\") {\n          // The top of the range is mostly empty for speech, so the bars read\n          // the lower half of the spectrum rather than spreading across all\n          // of it and leaving the right side flat.\n          const from = Math.floor((bar / bars) * (data.length * 0.6))\n          const to = Math.floor(((bar + 1) / bars) * (data.length * 0.6))\n          let sum = 0\n\n          for (let at = from; at < to; at += 1) sum += data[at]!\n          level = Math.max(REST, sum / Math.max(1, to - from) / 255)\n        }\n\n        const height = Math.max(2, level * box.height * 0.9)\n\n        context.beginPath()\n        context.roundRect(\n          bar * width + width * 0.25,\n          middle - height / 2,\n          Math.max(width * 0.5, 1),\n          height,\n          width * 0.25\n        )\n        context.fill()\n      }\n    },\n    [bars, ink, state]\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"bar-visualizer\"\n      data-state={state}\n      className={cn(\"flex h-12 w-full items-center\", className)}\n      {...rootProps}\n    >\n      {ink !== undefined && !reduced ? (\n        <RenderSurface<null, \"2d\">\n          setup={setup}\n          draw={draw}\n          paused={state === \"idle\" && !source}\n          className=\"h-full w-full\"\n          canvasClassName=\"h-full w-full\"\n        />\n      ) : (\n        <div\n          aria-hidden=\"true\"\n          className=\"flex h-full w-full items-center gap-1\"\n        >\n          {Array.from({ length: bars }, (_, bar) => (\n            <span\n              key={bar}\n              className=\"bg-primary h-1 min-w-0 flex-1 rounded-full\"\n            />\n          ))}\n        </div>\n      )}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {wording[state]}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "agent",
    "media",
    "audio",
    "canvas",
    "feedback"
  ],
  "type": "registry:ui"
}