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 · lightlivekit-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] ?? [];
};