{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-audio-visualizer-bar","title":"Agent Audio Visualizer Bar","description":"A linear bar visualizer for audio tracks.","dependencies":["livekit-client@^2.0.0","@livekit/components-react@^2.0.0","class-variance-authority","lucide-react"],"registryDependencies":["utils"],"files":[{"path":"components/agents-ui/agent-audio-visualizer-bar.tsx","content":"'use client';\n\nimport React, {\n  type CSSProperties,\n  Children,\n  type ComponentProps,\n  type ReactNode,\n  cloneElement,\n  isValidElement,\n  useMemo,\n} 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 { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar';\nimport { cn } from '@/lib/utils';\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\nfunction cloneSingleChild(\n  children: ReactNode | ReactNode[],\n  props?: Record<string, unknown>,\n  key?: unknown,\n) {\n  return Children.map(children, (child) => {\n    // Checking isValidElement is the safe way and avoids a typescript error too.\n    if (isValidElement(child) && Children.only(children)) {\n      const childProps = child.props as Record<string, unknown>;\n      if (childProps.className) {\n        // make sure we retain classnames of both passed props and child\n        props ??= {};\n        props.className = cn(childProps.className as string, props.className as string);\n        props.style = {\n          ...(childProps.style as CSSProperties),\n          ...(props.style as CSSProperties),\n        };\n      }\n      return cloneElement(child, { ...props, key: key ? String(key) : undefined });\n    }\n    return child;\n  });\n}\n\nexport const AgentAudioVisualizerBarElementVariants = cva(\n  [\n    'rounded-full transition-colors duration-250 ease-linear',\n    'bg-current/10 data-[lk-highlighted=true]:bg-current',\n  ],\n  {\n    variants: {\n      size: {\n        icon: 'w-[4px] min-h-[4px]',\n        sm: 'w-[8px] min-h-[8px]',\n        md: 'w-[16px] min-h-[16px]',\n        lg: 'w-[32px] min-h-[32px]',\n        xl: 'w-[64px] min-h-[64px]',\n      },\n    },\n    defaultVariants: {\n      size: 'md',\n    },\n  },\n);\n\nexport const AgentAudioVisualizerBarVariants = cva('relative flex items-center justify-center', {\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 * Props for the AgentAudioVisualizerBar component.\n */\nexport interface AgentAudioVisualizerBarProps {\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 bars in hexidecimal format.\n   */\n  color?: `#${string}`;\n  /**\n   * The number of bars to display in the visualizer.\n   * If not provided, defaults based on size: 3 for 'icon'/'sm', 5 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   * Custom div element to render as grid cells. Each child receives data-lk-index,\n   * data-lk-highlighted props and style props for height. Must be a single div element.\n   */\n  children?: ReactNode;\n}\n\n/**\n * A bar-style audio visualizer that responds to agent state and audio levels.\n * Displays animated bars that react to the current agent state (connecting, thinking, speaking, etc.)\n * and audio volume when speaking.\n *\n * @extends ComponentProps<'div'>\n *\n * @example\n * ```tsx\n * <AgentAudioVisualizerBar\n *   size=\"md\"\n *   state=\"speaking\"\n *   audioTrack={agentAudioTrack}\n * />\n * ```\n */\nexport function AgentAudioVisualizerBar({\n  size = 'md',\n  state = 'connecting',\n  color,\n  barCount,\n  audioTrack,\n  volumeBands,\n  className,\n  children,\n  style,\n  ...props\n}: AgentAudioVisualizerBarProps &\n  VariantProps<typeof AgentAudioVisualizerBarVariants> &\n  ComponentProps<'div'>) {\n  const _barCount = useMemo(() => {\n    if (barCount) {\n      return barCount;\n    }\n    switch (size) {\n      case 'icon':\n      case 'sm':\n        return 3;\n      default:\n        return 5;\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        return 2000 / _barCount;\n      case 'initializing':\n        return 2000;\n      case 'listening':\n        return 500;\n      case 'thinking':\n        return 150;\n      default:\n        return 1000;\n    }\n  }, [state, _barCount]);\n\n  const highlightedIndices = useAgentAudioVisualizerBarAnimator(\n    state,\n    _barCount,\n    sequencerInterval,\n  );\n\n  const bands = useMemo(\n    () => (state === 'speaking' ? resolvedVolumeBands : new Array(_barCount).fill(0)),\n    [state, resolvedVolumeBands, _barCount],\n  );\n\n  if (children && Array.isArray(children)) {\n    throw new Error('AgentAudioVisualizerBar children must be a single element.');\n  }\n\n  return (\n    <div\n      data-lk-state={state}\n      style={{ ...style, color } as CSSProperties}\n      className={cn(AgentAudioVisualizerBarVariants({ size }), className)}\n      {...props}\n    >\n      {bands.map((band: number, idx: number) =>\n        children ? (\n          <React.Fragment key={idx}>\n            {cloneSingleChild(children, {\n              'data-lk-index': idx,\n              'data-lk-highlighted': highlightedIndices.includes(idx),\n              style: { height: `${band * 100}%` },\n            })}\n          </React.Fragment>\n        ) : (\n          <div\n            key={idx}\n            data-lk-index={idx}\n            data-lk-highlighted={highlightedIndices.includes(idx)}\n            style={{ height: `${band * 100}%` }}\n            className={cn(AgentAudioVisualizerBarElementVariants({ size }))}\n          />\n        ),\n      )}\n    </div>\n  );\n}\n","type":"registry:component"},{"path":"hooks/agents-ui/use-agent-audio-visualizer-bar.ts","content":"import { useEffect, useRef, useState } from 'react';\nimport { type AgentState } from '@livekit/components-react';\n\nfunction generateConnectingSequenceBar(columns: number): number[][] {\n  const seq = [];\n\n  for (let x = 0; x < columns; x++) {\n    seq.push([x, columns - 1 - x]);\n  }\n\n  return seq;\n}\n\nfunction generateListeningSequenceBar(columns: number): number[][] {\n  const center = Math.floor(columns / 2);\n  const noIndex = -1;\n\n  return [[center], [noIndex]];\n}\n\nexport function useAgentAudioVisualizerBarAnimator(\n  state: AgentState | undefined,\n  columns: 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(columns));\n    } else if (state === 'connecting' || state === 'initializing') {\n      const sequence = [...generateConnectingSequenceBar(columns)];\n      setSequence(sequence);\n    } else if (state === 'listening') {\n      setSequence(generateListeningSequenceBar(columns));\n    } else if (state === undefined || state === 'speaking') {\n      setSequence([new Array(columns).fill(0).map((_, idx) => idx)]);\n    } else {\n      setSequence([[]]);\n    }\n    setIndex(0);\n  }, [state, columns]);\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, columns, state, sequence.length]);\n\n  return sequence[index % sequence.length] ?? [];\n}\n","type":"registry:hook"}],"type":"registry:component"}