{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-audio-visualizer-radial","title":"Agent Audio Visualizer Radial","description":"A radial bar visualizer for audio tracks.","dependencies":["livekit-client@^2.0.0","@livekit/components-react@^2.0.0","class-variance-authority"],"registryDependencies":["utils"],"files":[{"path":"components/agents-ui/agent-audio-visualizer-radial.tsx","content":"'use client';\n\nimport { type ComponentProps, type CSSProperties, useMemo } from 'react';\nimport { type VariantProps, cva } from 'class-variance-authority';\nimport { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';\nimport {\n  type AgentState,\n  type TrackReferenceOrPlaceholder,\n  useMultibandTrackVolume,\n} from '@livekit/components-react';\nimport { cn } from '@/lib/utils';\nimport { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial';\n\n/**\n * Resizes an array of per-band volume values to exactly `count` entries.\n * Excess values are trimmed from the end; if there are too few, the last\n * value is duplicated to fill the remainder. An empty array is padded with 0s.\n */\nexport function normalizeVolumeBands(bands: number[], count: number): number[] {\n  if (bands.length === count) return bands;\n  if (bands.length > count) return bands.slice(0, count);\n  const lastValue = bands[bands.length - 1] ?? 0;\n  return [...bands, ...new Array(count - bands.length).fill(lastValue)];\n}\n\nexport const AgentAudioVisualizerRadialVariants = cva(\n  [\n    'relative flex items-center justify-center',\n    '**:data-lk-index:bg-current/10',\n    '**:data-lk-index:absolute **:data-lk-index:top-1/2 **:data-lk-index:left-1/2 **:data-lk-index:origin-bottom **:data-lk-index:-translate-x-1/2',\n    '**:data-lk-index:rounded-full **:data-lk-index:transition-colors **:data-lk-index:duration-150 **:data-lk-index:ease-linear **:data-lk-index:data-[lk-highlighted=true]:bg-current',\n    'has-data-[lk-state=connecting]:**:data-lk-index:duration-300',\n    'has-data-[lk-state=initializing]:**:data-lk-index:duration-300',\n    'has-data-[lk-state=listening]:**:data-lk-index:duration-300',\n    'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:**:data-lk-index:bg-current',\n  ],\n  {\n    variants: {\n      size: {\n        icon: ['h-[24px] gap-[2px]'],\n        sm: ['h-[56px] gap-[4px]'],\n        md: ['h-[112px] gap-[8px]'],\n        lg: ['h-[224px] gap-[16px]'],\n        xl: ['h-[448px] gap-[32px]'],\n      },\n    },\n    defaultVariants: {\n      size: 'md',\n    },\n  },\n);\n\n/**\n * Props for the AgentAudioVisualizerRadial component.\n */\nexport interface AgentAudioVisualizerRadialProps {\n  /**\n   * The size of the visualizer.\n   * @defaultValue 'md'\n   */\n  size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';\n  /**\n   * The current state of the agent. Determines the animation pattern.\n   * @defaultValue 'connecting'\n   */\n  state?: AgentState;\n  /**\n   * The color of the radial bars in hexidecimal format.\n   */\n  color?: `#${string}`;\n  /**\n   * The radius (distance from center) for the radial bars.\n   * If not provided, defaults based on size.\n   */\n  radius?: number;\n  /**\n   * The number of bars to display around the circle.\n   * Should be divisible by 4 for optimal visual results.\n   * If not provided, defaults to 12 for 'icon'/'sm', 24 for others.\n   */\n  barCount?: number;\n  /**\n   * The audio track to visualize. Can be a local/remote audio track or a track reference.\n   */\n  audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;\n  /**\n   * Volume values (0-1) to use instead of the values computed from the audioTrack.\n   * The volumeBands.length should match barCount.\n   */\n  volumeBands?: number[];\n  /**\n   * Additional CSS class names to apply to the container.\n   */\n  className?: string;\n}\n\n/**\n * A radial (circular) audio visualizer that responds to agent state and audio levels.\n * Displays animated bars arranged in a circle that react to the current agent state\n * and audio volume when speaking.\n *\n * @extends ComponentProps<'div'>\n *\n * @example\n * ```tsx\n * <AgentAudioVisualizerRadial\n *   size=\"lg\"\n *   state=\"speaking\"\n *   barCount={24}\n *   audioTrack={agentAudioTrack}\n * />\n * ```\n */\nexport function AgentAudioVisualizerRadial({\n  size = 'md',\n  state = 'connecting',\n  color,\n  radius,\n  barCount,\n  audioTrack,\n  volumeBands,\n  className,\n  style,\n  ...props\n}: AgentAudioVisualizerRadialProps &\n  ComponentProps<'div'> &\n  VariantProps<typeof AgentAudioVisualizerRadialVariants>) {\n  const _barCount = useMemo(() => {\n    if (barCount) {\n      return barCount;\n    }\n    switch (size) {\n      case 'icon':\n      case 'sm':\n        return 12;\n      default:\n        return 24;\n    }\n  }, [barCount, size]);\n\n  const multibandVolume = useMultibandTrackVolume(audioTrack, {\n    bands: _barCount,\n    loPass: 100,\n    hiPass: 200,\n  });\n  const resolvedVolumeBands = volumeBands\n    ? normalizeVolumeBands(volumeBands, _barCount)\n    : multibandVolume;\n\n  const sequencerInterval = useMemo(() => {\n    switch (state) {\n      case 'connecting':\n      case 'listening':\n        return 500;\n      case 'initializing':\n        return 250;\n      case 'thinking':\n        return Infinity;\n      default:\n        return 1000;\n    }\n  }, [state, _barCount]);\n\n  const distanceFromCenter = useMemo(() => {\n    if (radius) {\n      return radius;\n    }\n    switch (size) {\n      case 'icon':\n        return 6;\n      case 'xl':\n        return 128;\n      case 'lg':\n        return 64;\n      case 'sm':\n        return 16;\n      case 'md':\n      default:\n        return 32;\n    }\n  }, [size, radius]);\n\n  if (_barCount % 4 !== 0) {\n    console.warn('barCount should be divisible by 4 for optimal visual results');\n  }\n\n  const highlightedIndices = useAgentAudioVisualizerRadialAnimator(\n    state,\n    _barCount,\n    sequencerInterval,\n  );\n\n  const dotSize = useMemo(() => {\n    return (distanceFromCenter * Math.PI) / _barCount;\n  }, [distanceFromCenter, _barCount]);\n\n  return (\n    <div\n      data-lk-state={state}\n      className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}\n      style={{ ...style, color } as CSSProperties}\n      {...props}\n    >\n      {resolvedVolumeBands.map((band, idx) => {\n        const angle = (idx / _barCount) * Math.PI * 2;\n\n        return (\n          <div\n            key={`${_barCount}-${idx}`}\n            data-lk-state={state}\n            className=\"absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2\"\n            style={{\n              transformOrigin: 'center',\n              transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,\n            }}\n          >\n            <div\n              data-lk-index={idx}\n              data-lk-highlighted={highlightedIndices.includes(idx)}\n              style={{\n                width: dotSize,\n                minHeight: dotSize,\n                height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,\n              }}\n            />\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n","type":"registry:component"},{"path":"hooks/agents-ui/use-agent-audio-visualizer-radial.ts","content":"import { useEffect, useRef, useState } from 'react';\nimport { type AgentState } from '@livekit/components-react';\n\nfunction findGcdLessThan(columns: number, max: number = columns): number {\n  function gcd(a: number, b: number): number {\n    while (b !== 0) {\n      const t = b;\n      b = a % b;\n      a = t;\n    }\n    return a;\n  }\n  for (let i = max; i >= 1; i--) {\n    if (gcd(columns, i) === i) {\n      return i;\n    }\n  }\n  return 1;\n}\n\nfunction generateConnectingSequenceBar(columns: number): number[][] {\n  const seq = [];\n  const center = Math.floor(columns / 2);\n\n  for (let x = 0; x < columns; x++) {\n    seq.push([x, (x + center) % columns]);\n  }\n\n  return seq;\n}\n\nfunction generateListeningSequenceBar(columns: number): number[][] {\n  const divisor = columns > 8 ? columns / findGcdLessThan(columns, 4) : findGcdLessThan(columns, 2);\n\n  return Array.from({ length: divisor }, (_, idx) => [\n    ...Array(Math.floor(columns / divisor))\n      .fill(1)\n      .map((_, idx2) => idx2 * divisor + idx),\n  ]);\n}\n\nexport const useAgentAudioVisualizerRadialAnimator = (\n  state: AgentState | undefined,\n  barCount: number,\n  interval: number,\n): number[] => {\n  const [index, setIndex] = useState(0);\n  const [sequence, setSequence] = useState<number[][]>([[]]);\n\n  useEffect(() => {\n    if (state === 'thinking') {\n      setSequence(generateListeningSequenceBar(barCount));\n    } else if (state === 'connecting' || state === 'initializing') {\n      setSequence(generateConnectingSequenceBar(barCount));\n    } else if (state === 'listening') {\n      setSequence(generateListeningSequenceBar(barCount));\n    } else if (state === undefined || state === 'speaking') {\n      setSequence([new Array(barCount).fill(0).map((_, idx) => idx)]);\n    } else {\n      setSequence([[]]);\n    }\n    setIndex(0);\n  }, [state, barCount]);\n\n  const animationFrameId = useRef<number | null>(null);\n  useEffect(() => {\n    let startTime = performance.now();\n\n    const animate = (time: DOMHighResTimeStamp) => {\n      const timeElapsed = time - startTime;\n\n      if (timeElapsed >= interval) {\n        setIndex((prev) => prev + 1);\n        startTime = time;\n      }\n\n      animationFrameId.current = requestAnimationFrame(animate);\n    };\n\n    animationFrameId.current = requestAnimationFrame(animate);\n\n    return () => {\n      if (animationFrameId.current !== null) {\n        cancelAnimationFrame(animationFrameId.current);\n      }\n    };\n  }, [interval, barCount, state, sequence.length]);\n\n  return sequence[index % sequence.length] ?? [];\n};\n","type":"registry:hook"}],"type":"registry:component"}