{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-audio-visualizer-grid","title":"Agent Audio Visualizer Grid","description":"A grid 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-grid.tsx","content":"'use client';\n\nimport React, {\n  type CSSProperties,\n  Children,\n  type ComponentProps,\n  type ReactNode,\n  cloneElement,\n  isValidElement,\n  memo,\n  useMemo,\n} from 'react';\nimport { type VariantProps, cva } from 'class-variance-authority';\nimport { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';\nimport {\n  type AgentState,\n  type TrackReferenceOrPlaceholder,\n  useMultibandTrackVolume,\n} from '@livekit/components-react';\nimport {\n  type Coordinate,\n  useAgentAudioVisualizerGridAnimator,\n} from '@/hooks/agents-ui/use-agent-audio-visualizer-grid';\nimport { cn } from '@/lib/utils';\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 AgentAudioVisualizerGridCellVariants = cva(\n  [\n    'h-1 w-1 place-self-center rounded-full bg-current/10 transition-all ease-out',\n    'data-[lk-highlighted=true]:bg-current',\n  ],\n  {\n    variants: {\n      size: {\n        icon: ['h-[2px] w-[2px]'],\n        sm: ['h-[4px] w-[4px]'],\n        md: ['h-[8px] w-[8px]'],\n        lg: ['h-[12px] w-[12px]'],\n        xl: ['h-[16px] w-[16px]'],\n      },\n    },\n    defaultVariants: {\n      size: 'md',\n    },\n  },\n);\n\nexport const AgentAudioVisualizerGridVariants = cva('grid', {\n  variants: {\n    size: {\n      icon: ['gap-[2px]'],\n      sm: ['gap-[4px]'],\n      md: ['gap-[8px]'],\n      lg: ['gap-[12px]'],\n      xl: ['gap-[16px]'],\n    },\n  },\n  defaultVariants: {\n    size: 'md',\n  },\n});\n\n/**\n * Configuration options for the grid visualizer.\n */\nexport interface GridOptions {\n  /**\n   * The radius for the animation spread effect.\n   */\n  radius?: number;\n  /**\n   * The interval in milliseconds between animation frames.\n   * @defaultValue 100\n   */\n  interval?: number;\n  /**\n   * The number of rows in the grid.\n   * @defaultValue 5\n   */\n  rowCount?: number;\n  /**\n   * The number of columns in the grid.\n   * @defaultValue 5\n   */\n  columnCount?: number;\n  /**\n   * Additional CSS class names to apply to the container.\n   */\n  className?: string;\n}\n\nconst sizeDefaults = {\n  icon: 3,\n  sm: 5,\n  md: 5,\n  lg: 5,\n  xl: 5,\n};\n\nfunction useGrid(\n  size: VariantProps<typeof AgentAudioVisualizerGridVariants>['size'] = 'md',\n  columnCount = sizeDefaults[size as keyof typeof sizeDefaults],\n  rowCount = sizeDefaults[size as keyof typeof sizeDefaults],\n) {\n  return useMemo(() => {\n    const _columnCount = columnCount;\n    const _rowCount = rowCount ?? columnCount;\n    const items = Array.from({ length: _columnCount * _rowCount }, (_, idx) => idx);\n\n    return { columnCount: _columnCount, rowCount: _rowCount, items };\n  }, [columnCount, rowCount]);\n}\n\ninterface GridCellProps {\n  index: number;\n  state: AgentState;\n  interval: number;\n  rowCount: number;\n  columnCount: number;\n  volumeBands: number[];\n  highlightedCoordinate: Coordinate;\n  children?: ReactNode;\n}\n\nconst GridCell = memo(function GridCell({\n  index,\n  state,\n  interval,\n  rowCount,\n  columnCount,\n  volumeBands,\n  highlightedCoordinate,\n  children,\n}: GridCellProps) {\n  if (state === 'speaking') {\n    const y = Math.floor(index / columnCount);\n    const rowMidPoint = Math.floor(rowCount / 2);\n    const volumeChunks = 1 / (rowMidPoint + 1);\n    const distanceToMid = Math.abs(rowMidPoint - y);\n    const threshold = distanceToMid * volumeChunks;\n    const isHighlighted = (volumeBands[index % columnCount] ?? 0) >= threshold;\n\n    return cloneSingleChild(children, {\n      'data-lk-index': index,\n      'data-lk-highlighted': isHighlighted,\n    });\n  }\n\n  const isHighlighted =\n    highlightedCoordinate.x === index % columnCount &&\n    highlightedCoordinate.y === Math.floor(index / columnCount);\n\n  const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100);\n\n  return cloneSingleChild(children, {\n    'data-lk-index': index,\n    'data-lk-highlighted': isHighlighted,\n    style: {\n      transitionDuration: `${transitionDurationInSeconds}s`,\n    },\n  });\n});\n\n/**\n * Props for the AgentAudioVisualizerGrid component.\n */\nexport type AgentAudioVisualizerGridProps = GridOptions & {\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 grid cells in hexidecimal format.\n   */\n  color?: `#${string}`;\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   * Externally supplied volume bands. When provided, these override audioTrack volume data.\n   */\n  volumeBands?: number[];\n  /**\n   * Additional CSS class names to apply to the container.\n   */\n  className?: string;\n  /**\n   * Custom element to render as grid cells. Each child receives data-lk-index\n   * and data-lk-highlighted props.\n   */\n  children?: ReactNode;\n} & VariantProps<typeof AgentAudioVisualizerGridVariants>;\n\n/**\n * A grid-style audio visualizer that responds to agent state and audio levels.\n * Displays an animated grid of cells that react to the current agent state\n * and audio volume when speaking.\n *\n * @extends ComponentProps<'div'>\n *\n * @example\n * ```tsx\n * <AgentAudioVisualizerGrid\n *   size=\"md\"\n *   state=\"speaking\"\n *   rowCount={5}\n *   columnCount={5}\n *   audioTrack={agentAudioTrack}\n * />\n * ```\n */\nexport function AgentAudioVisualizerGrid({\n  size = 'md',\n  state = 'connecting',\n  radius,\n  color,\n  rowCount: _rowCount = 5,\n  columnCount: _columnCount = 5,\n  interval = 100,\n  className,\n  children,\n  audioTrack,\n  volumeBands,\n  style,\n  ...props\n}: AgentAudioVisualizerGridProps & ComponentProps<'div'>) {\n  const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount);\n  const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(\n    state,\n    rowCount,\n    columnCount,\n    interval,\n    radius,\n  );\n  const trackVolumeBands = useMultibandTrackVolume(audioTrack, {\n    bands: columnCount,\n    loPass: 100,\n    hiPass: 200,\n  });\n  const resolvedVolumeBands = useMemo(\n    () =>\n      Array.from(\n        { length: columnCount },\n        (_, idx) => volumeBands?.[idx] ?? trackVolumeBands[idx] ?? 0,\n      ),\n    [volumeBands, trackVolumeBands, columnCount],\n  );\n\n  if (children && Array.isArray(children)) {\n    throw new Error('AgentAudioVisualizerGrid children must be a single element.');\n  }\n\n  return (\n    <div\n      data-lk-state={state}\n      className={cn(AgentAudioVisualizerGridVariants({ size }), className)}\n      style={\n        { ...style, gridTemplateColumns: `repeat(${columnCount}, 1fr)`, color } as CSSProperties\n      }\n      {...props}\n    >\n      {items.map((idx) => (\n        <GridCell\n          key={idx}\n          index={idx}\n          state={state}\n          interval={interval}\n          rowCount={rowCount}\n          columnCount={columnCount}\n          volumeBands={resolvedVolumeBands}\n          highlightedCoordinate={highlightedCoordinate}\n        >\n          {children ?? <div className={AgentAudioVisualizerGridCellVariants({ size })} />}\n        </GridCell>\n      ))}\n    </div>\n  );\n}\n","type":"registry:component"},{"path":"hooks/agents-ui/use-agent-audio-visualizer-grid.ts","content":"import { useEffect, useState } from 'react';\nimport { type AgentState } from '@livekit/components-react';\n\nexport interface Coordinate {\n  x: number;\n  y: number;\n}\n\nexport function generateConnectingSequence(rows: number, columns: number, radius: number) {\n  const seq = [];\n  const centerY = Math.floor(rows / 2);\n\n  // Calculate the boundaries of the ring based on the ring distance\n  const topLeft = {\n    x: Math.max(0, centerY - radius),\n    y: Math.max(0, centerY - radius),\n  };\n  const bottomRight = {\n    x: columns - 1 - topLeft.x,\n    y: Math.min(rows - 1, centerY + radius),\n  };\n\n  // Top edge\n  for (let x = topLeft.x; x <= bottomRight.x; x++) {\n    seq.push({ x, y: topLeft.y });\n  }\n\n  // Right edge\n  for (let y = topLeft.y + 1; y <= bottomRight.y; y++) {\n    seq.push({ x: bottomRight.x, y });\n  }\n\n  // Bottom edge\n  for (let x = bottomRight.x - 1; x >= topLeft.x; x--) {\n    seq.push({ x, y: bottomRight.y });\n  }\n\n  // Left edge\n  for (let y = bottomRight.y - 1; y > topLeft.y; y--) {\n    seq.push({ x: topLeft.x, y });\n  }\n\n  return seq;\n}\n\nexport function generateListeningSequence(rows: number, columns: number) {\n  const center = { x: Math.floor(columns / 2), y: Math.floor(rows / 2) };\n  const noIndex = { x: -1, y: -1 };\n\n  return [center, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex];\n}\n\nexport function generateThinkingSequence(rows: number, columns: number) {\n  const seq = [];\n  const y = Math.floor(rows / 2);\n  for (let x = 0; x < columns; x++) {\n    seq.push({ x, y });\n  }\n  for (let x = columns - 1; x >= 0; x--) {\n    seq.push({ x, y });\n  }\n\n  return seq;\n}\n\nexport function useAgentAudioVisualizerGridAnimator(\n  state: AgentState,\n  rows: number,\n  columns: number,\n  interval: number,\n  radius?: number,\n): Coordinate {\n  const [index, setIndex] = useState(0);\n  const [sequence, setSequence] = useState<Coordinate[]>(() => [\n    {\n      x: Math.floor(columns / 2),\n      y: Math.floor(rows / 2),\n    },\n  ]);\n\n  useEffect(() => {\n    const clampedRadius = radius\n      ? Math.min(radius, Math.floor(Math.max(rows, columns) / 2))\n      : Math.floor(Math.max(rows, columns) / 2);\n\n    if (state === 'thinking') {\n      setSequence(generateThinkingSequence(rows, columns));\n    } else if (state === 'connecting' || state === 'initializing') {\n      const sequence = [...generateConnectingSequence(rows, columns, clampedRadius)];\n      setSequence(sequence);\n    } else if (state === 'listening') {\n      setSequence(generateListeningSequence(rows, columns));\n    } else {\n      setSequence([{ x: Math.floor(columns / 2), y: Math.floor(rows / 2) }]);\n    }\n    setIndex(0);\n  }, [state, rows, columns, radius]);\n\n  useEffect(() => {\n    if (state === 'speaking') {\n      return;\n    }\n\n    const indexInterval = setInterval(() => {\n      setIndex((prev) => {\n        return prev + 1;\n      });\n    }, interval);\n\n    return () => clearInterval(indexInterval);\n  }, [interval, columns, rows, state, sequence.length]);\n\n  return (\n    sequence[index % sequence.length] ?? { x: Math.floor(columns / 2), y: Math.floor(rows / 2) }\n  );\n}\n","type":"registry:hook"}],"type":"registry:component"}