{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-visualizer",
  "type": "registry:ui",
  "title": "Voice Visualizer",
  "description": "Agent-state dots driven by audio level, with an owned useAudioLevel hook.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://seamui.dev/r/utils.json",
    "https://seamui.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/seam/ui/voice-visualizer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { motion, type Transition } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { springs, fades, useMounted, useReducedMotion } from \"@/lib/motion\"\n\ntype VoiceState =\n  | \"disconnected\"\n  | \"connecting\"\n  | \"listening\"\n  | \"thinking\"\n  | \"speaking\"\n\nconst STATE_LABEL: Record<VoiceState, string> = {\n  disconnected: \"Disconnected\",\n  connecting: \"Connecting\",\n  listening: \"Agent is listening\",\n  thinking: \"Agent is thinking\",\n  speaking: \"Agent is speaking\",\n}\n\n/**\n * Owned audio-level hook — samples a MediaStreamTrack's volume via Web Audio\n * on rAF and returns a 0–1 level. No dependency; consumers without a track can\n * drive the `level` prop directly. Cleans up its AudioContext on unmount.\n */\nfunction useAudioLevel(track?: MediaStreamTrack | null): number {\n  const [level, setLevel] = React.useState(0)\n\n  React.useEffect(() => {\n    if (!track) {\n      setLevel(0)\n      return\n    }\n    let raf = 0\n    let stopped = false\n    let ctx: AudioContext | undefined\n    try {\n      ctx = new AudioContext()\n      const source = ctx.createMediaStreamSource(new MediaStream([track]))\n      const analyser = ctx.createAnalyser()\n      analyser.fftSize = 256\n      source.connect(analyser)\n      const data = new Uint8Array(analyser.frequencyBinCount)\n      const tick = () => {\n        analyser.getByteFrequencyData(data)\n        let sum = 0\n        for (let i = 0; i < data.length; i++) sum += data[i]\n        if (!stopped) setLevel(Math.min(1, (sum / data.length / 255) * 1.8))\n        raf = requestAnimationFrame(tick)\n      }\n      tick()\n    } catch {\n      // no audio available (e.g. permission denied) — stay at 0\n    }\n    return () => {\n      stopped = true\n      cancelAnimationFrame(raf)\n      ctx?.close().catch(() => {})\n    }\n  }, [track])\n\n  return level\n}\n\nconst SIZES = {\n  sm: { dot: \"size-1.5\", bar: \"w-1\", gap: \"gap-1\", h: \"h-6\" },\n  default: { dot: \"size-2.5\", bar: \"w-1.5\", gap: \"gap-1.5\", h: \"h-10\" },\n  lg: { dot: \"size-3.5\", bar: \"w-2\", gap: \"gap-2\", h: \"h-14\" },\n} as const\n\n// Opacity-only loops (shimmer / sweep) — the sanctioned duration case; they\n// stay identical under reduced motion since nothing travels.\nconst shimmer = (i: number): Transition => ({\n  duration: 1.1,\n  repeat: Infinity,\n  repeatType: \"mirror\",\n  delay: i * 0.12,\n  ease: \"easeInOut\",\n})\nconst sweep = (i: number): Transition => ({\n  duration: 0.9,\n  repeat: Infinity,\n  repeatType: \"mirror\",\n  delay: i * 0.14,\n  ease: \"easeInOut\",\n})\n\nfunction dotAnimation(\n  state: VoiceState,\n  level: number,\n  i: number,\n  count: number,\n  reduce: boolean,\n  bars: boolean\n) {\n  const center = (count - 1) / 2\n  const dist = center === 0 ? 0 : Math.abs(i - center) / center\n  const weight = 1 - dist * 0.55 // center reacts most\n  const scaleKey = bars ? \"scaleY\" : \"scale\"\n\n  switch (state) {\n    case \"connecting\":\n      return {\n        animate: { opacity: 0.85 },\n        initial: { opacity: 0.25 },\n        transition: shimmer(i),\n      }\n    case \"thinking\":\n      return {\n        animate: { opacity: 1 },\n        initial: { opacity: 0.25 },\n        transition: sweep(i),\n      }\n    case \"listening\":\n    case \"speaking\": {\n      const floor = state === \"listening\" ? 0.5 : 0.35\n      const mag = Math.max(\n        floor,\n        floor + level * weight * (state === \"speaking\" ? 1.7 : 1)\n      )\n      return reduce\n        ? {\n            animate: { opacity: 0.35 + level * weight * 0.65 },\n            transition: fades.fast,\n          }\n        : {\n            animate: { [scaleKey]: mag, opacity: 1 },\n            transition: springs.snappy,\n          }\n    }\n    default: // disconnected\n      return {\n        animate: { opacity: 0.25, [scaleKey]: bars ? 0.5 : 1 },\n        transition: fades.normal,\n      }\n  }\n}\n\nfunction VoiceVisualizer({\n  state = \"listening\",\n  level: levelProp,\n  track,\n  count = 5,\n  size = \"default\",\n  variant = \"dots\",\n  className,\n  \"aria-label\": ariaLabel,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  state?: VoiceState\n  level?: number\n  track?: MediaStreamTrack | null\n  count?: number\n  size?: keyof typeof SIZES\n  variant?: \"dots\" | \"bars\"\n}) {\n  const reduce = useReducedMotion() ?? false\n  const mounted = useMounted()\n  const tracked = useAudioLevel(track)\n  const level = levelProp ?? tracked\n  const bars = variant === \"bars\"\n  const s = SIZES[size]\n\n  return (\n    <div\n      data-slot=\"voice-visualizer\"\n      data-state={state}\n      role=\"status\"\n      aria-label={ariaLabel ?? STATE_LABEL[state]}\n      className={cn(\n        \"flex items-center justify-center\",\n        s.gap,\n        bars && s.h,\n        className\n      )}\n      {...props}\n    >\n      {Array.from({ length: count }).map((_, i) => {\n        const a = dotAnimation(state, level, i, count, reduce, bars)\n        return (\n          <motion.span\n            key={i}\n            aria-hidden\n            className={cn(\n              \"bg-muted-foreground/60 shrink-0\",\n              bars\n                ? cn(s.bar, \"h-full origin-center rounded-full\")\n                : cn(s.dot, \"rounded-full\")\n            )}\n            // SSR hydration safety: `a.animate` depends on useReducedMotion()\n            // + level, which differ between the server and the client's first\n            // paint. Until mounted, render a deterministic resting dot (faint,\n            // no transform) so both sides match; then animate to the live state.\n            initial={false}\n            animate={mounted ? a.animate : { opacity: 0.25 }}\n            transition={a.transition}\n          />\n        )\n      })}\n    </div>\n  )\n}\n\nfunction VoiceVisualizerCaption({\n  className,\n  ...props\n}: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"voice-visualizer-caption\"\n      className={cn(\"text-muted-foreground text-center text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { VoiceVisualizer, VoiceVisualizerCaption, useAudioLevel }\n",
      "type": "registry:ui",
      "target": "components/ui/voice-visualizer.tsx"
    }
  ]
}