shadimage
agents-ui
componentagent-audio-visualizer-radialsource: ok · hit

Agent Audio Visualizer Radial

A radial bar visualizer for audio tracks.

npx shadcn add "https://livekit.com/ui/r/agent-audio-visualizer-radial.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
livekit-client@^2.0.0@livekit/components-react@^2.0.0class-variance-authority

Registry dependencies

utils

Files2 · source cached 10/8/2026, 12:53:58 PM

components/agents-ui/agent-audio-visualizer-radial.tsx
'use client';

import { type ComponentProps, type CSSProperties, useMemo } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
import {
  type AgentState,
  type TrackReferenceOrPlaceholder,
  useMultibandTrackVolume,
} from '@livekit/components-react';
import { cn } from '@/lib/utils';
import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial';

/**
 * Resizes an array of per-band volume values to exactly `count` entries.
 * Excess values are trimmed from the end; if there are too few, the last
 * value is duplicated to fill the remainder. An empty array is padded with 0s.
 */
export function normalizeVolumeBands(bands: number[], count: number): number[] {
  if (bands.length === count) return bands;
  if (bands.length > count) return bands.slice(0, count);
  const lastValue = bands[bands.length - 1] ?? 0;
  return [...bands, ...new Array(count - bands.length).fill(lastValue)];
}

export const AgentAudioVisualizerRadialVariants = cva(
  [
    'relative flex items-center justify-center',
    '**:data-lk-index:bg-current/10',
    '**: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',
    '**: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',
    'has-data-[lk-state=connecting]:**:data-lk-index:duration-300',
    'has-data-[lk-state=initializing]:**:data-lk-index:duration-300',
    'has-data-[lk-state=listening]:**:data-lk-index:duration-300',
    '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',
  ],
  {
    variants: {
      size: {
        icon: ['h-[24px] gap-[2px]'],
        sm: ['h-[56px] gap-[4px]'],
        md: ['h-[112px] gap-[8px]'],
        lg: ['h-[224px] gap-[16px]'],
        xl: ['h-[448px] gap-[32px]'],
      },
    },
    defaultVariants: {
      size: 'md',
    },
  },
);

/**
 * Props for the AgentAudioVisualizerRadial component.
 */
export interface AgentAudioVisualizerRadialProps {
  /**
   * The size of the visualizer.
   * @defaultValue 'md'
   */
  size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
  /**
   * The current state of the agent. Determines the animation pattern.
   * @defaultValue 'connecting'
   */
  state?: AgentState;
  /**
   * The color of the radial bars in hexidecimal format.
   */
  color?: `#${string}`;
  /**
   * The radius (distance from center) for the radial bars.
   * If not provided, defaults based on size.
   */
  radius?: number;
  /**
   * The number of bars to display around the circle.
   * Should be divisible by 4 for optimal visual results.
   * If not provided, defaults to 12 for 'icon'/'sm', 24 for others.
   */
  barCount?: number;
  /**
   * The audio track to visualize. Can be a local/remote audio track or a track reference.
   */
  audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
  /**
   * Volume values (0-1) to use instead of the values computed from the audioTrack.
   * The volumeBands.length should match barCount.
   */
  volumeBands?: number[];
  /**
   * Additional CSS class names to apply to the container.
   */
  className?: string;
}

/**
 * A radial (circular) audio visualizer that responds to agent state and audio levels.
 * Displays animated bars arranged in a circle that react to the current agent state
 * and audio volume when speaking.
 *
 * @extends ComponentProps<'div'>
 *
 * @example
 * ```tsx
 * <AgentAudioVisualizerRadial
 *   size="lg"
 *   state="speaking"
 *   barCount={24}
 *   audioTrack={agentAudioTrack}
 * />
 * ```
 */
export function AgentAudioVisualizerRadial({
  size = 'md',
  state = 'connecting',
  color,
  radius,
  barCount,
  audioTrack,
  volumeBands,
  className,
  style,
  ...props
}: AgentAudioVisualizerRadialProps &
  ComponentProps<'div'> &
  VariantProps<typeof AgentAudioVisualizerRadialVariants>) {
  const _barCount = useMemo(() => {
    if (barCount) {
      return barCount;
    }
    switch (size) {
      case 'icon':
      case 'sm':
        return 12;
      default:
        return 24;
    }
  }, [barCount, size]);

  const multibandVolume = useMultibandTrackVolume(audioTrack, {
    bands: _barCount,
    loPass: 100,
    hiPass: 200,
  });
  const resolvedVolumeBands = volumeBands
    ? normalizeVolumeBands(volumeBands, _barCount)
    : multibandVolume;

  const sequencerInterval = useMemo(() => {
    switch (state) {
      case 'connecting':
      case 'listening':
        return 500;
      case 'initializing':
        return 250;
      case 'thinking':
        return Infinity;
      default:
        return 1000;
    }
  }, [state, _barCount]);

  const distanceFromCenter = useMemo(() => {
    if (radius) {
      return radius;
    }
    switch (size) {
      case 'icon':
        return 6;
      case 'xl':
        return 128;
      case 'lg':
        return 64;
      case 'sm':
        return 16;
      case 'md':
      default:
        return 32;
    }
  }, [size, radius]);

  if (_barCount % 4 !== 0) {
    console.warn('barCount should be divisible by 4 for optimal visual results');
  }

  const highlightedIndices = useAgentAudioVisualizerRadialAnimator(
    state,
    _barCount,
    sequencerInterval,
  );

  const dotSize = useMemo(() => {
    return (distanceFromCenter * Math.PI) / _barCount;
  }, [distanceFromCenter, _barCount]);

  return (
    <div
      data-lk-state={state}
      className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}
      style={{ ...style, color } as CSSProperties}
      {...props}
    >
      {resolvedVolumeBands.map((band, idx) => {
        const angle = (idx / _barCount) * Math.PI * 2;

        return (
          <div
            key={`${_barCount}-${idx}`}
            data-lk-state={state}
            className="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
            style={{
              transformOrigin: 'center',
              transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,
            }}
          >
            <div
              data-lk-index={idx}
              data-lk-highlighted={highlightedIndices.includes(idx)}
              style={{
                width: dotSize,
                minHeight: dotSize,
                height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,
              }}
            />
          </div>
        );
      })}
    </div>
  );
}
hooks/agents-ui/use-agent-audio-visualizer-radial.ts
import { useEffect, useRef, useState } from 'react';
import { type AgentState } from '@livekit/components-react';

function findGcdLessThan(columns: number, max: number = columns): number {
  function gcd(a: number, b: number): number {
    while (b !== 0) {
      const t = b;
      b = a % b;
      a = t;
    }
    return a;
  }
  for (let i = max; i >= 1; i--) {
    if (gcd(columns, i) === i) {
      return i;
    }
  }
  return 1;
}

function generateConnectingSequenceBar(columns: number): number[][] {
  const seq = [];
  const center = Math.floor(columns / 2);

  for (let x = 0; x < columns; x++) {
    seq.push([x, (x + center) % columns]);
  }

  return seq;
}

function generateListeningSequenceBar(columns: number): number[][] {
  const divisor = columns > 8 ? columns / findGcdLessThan(columns, 4) : findGcdLessThan(columns, 2);

  return Array.from({ length: divisor }, (_, idx) => [
    ...Array(Math.floor(columns / divisor))
      .fill(1)
      .map((_, idx2) => idx2 * divisor + idx),
  ]);
}

export const useAgentAudioVisualizerRadialAnimator = (
  state: AgentState | undefined,
  barCount: number,
  interval: number,
): number[] => {
  const [index, setIndex] = useState(0);
  const [sequence, setSequence] = useState<number[][]>([[]]);

  useEffect(() => {
    if (state === 'thinking') {
      setSequence(generateListeningSequenceBar(barCount));
    } else if (state === 'connecting' || state === 'initializing') {
      setSequence(generateConnectingSequenceBar(barCount));
    } else if (state === 'listening') {
      setSequence(generateListeningSequenceBar(barCount));
    } else if (state === undefined || state === 'speaking') {
      setSequence([new Array(barCount).fill(0).map((_, idx) => idx)]);
    } else {
      setSequence([[]]);
    }
    setIndex(0);
  }, [state, barCount]);

  const animationFrameId = useRef<number | null>(null);
  useEffect(() => {
    let startTime = performance.now();

    const animate = (time: DOMHighResTimeStamp) => {
      const timeElapsed = time - startTime;

      if (timeElapsed >= interval) {
        setIndex((prev) => prev + 1);
        startTime = time;
      }

      animationFrameId.current = requestAnimationFrame(animate);
    };

    animationFrameId.current = requestAnimationFrame(animate);

    return () => {
      if (animationFrameId.current !== null) {
        cancelAnimationFrame(animationFrameId.current);
      }
    };
  }, [interval, barCount, state, sequence.length]);

  return sequence[index % sequence.length] ?? [];
};

More from agents-ui

Browse all components in this library →