Skip to content

Commit 90764e4

Browse files
add AudioRadialVisualizer
1 parent de41bcd commit 90764e4

File tree

4 files changed

+447
-0
lines changed

4 files changed

+447
-0
lines changed

app/ui/_components.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import {
2727
type GridOptions,
2828
} from '@/components/livekit/audio-visualizer/audio-grid-visualizer/audio-grid-visualizer';
2929
import { gridVariants } from '@/components/livekit/audio-visualizer/audio-grid-visualizer/demos';
30+
import {
31+
AudioRadialVisualizer,
32+
audioRadialVisualizerVariants,
33+
} from '@/components/livekit/audio-visualizer/audio-radial-visualizer/audio-radial-visualizer';
3034
import { Button, buttonVariants } from '@/components/livekit/button';
3135
import { ChatEntry } from '@/components/livekit/chat-entry';
3236
import {
@@ -45,6 +49,9 @@ type buttonVariantsType = VariantProps<typeof buttonVariants>['variant'];
4549
type buttonVariantsSizeType = VariantProps<typeof buttonVariants>['size'];
4650
type alertVariantsType = VariantProps<typeof alertVariants>['variant'];
4751
type audioBarVisualizerVariantsSizeType = VariantProps<typeof audioBarVisualizerVariants>['size'];
52+
type audioRadialVisualizerVariantsSizeType = VariantProps<
53+
typeof audioRadialVisualizerVariants
54+
>['size'];
4855

4956
export function useMicrophone() {
5057
const { startSession } = useSession();
@@ -422,6 +429,114 @@ export const COMPONENTS = {
422429
);
423430
},
424431

432+
// Audio bar visualizer
433+
AudioRadialVisualizer: () => {
434+
const barCounts = ['0', '4', '8', '12', '16', '20', '24'];
435+
const sizes = ['icon', 'xs', 'sm', 'md', 'lg', 'xl'];
436+
const states = [
437+
'disconnected',
438+
'connecting',
439+
'initializing',
440+
'listening',
441+
'thinking',
442+
'speaking',
443+
] as AgentState[];
444+
445+
const { microphoneTrack, localParticipant } = useLocalParticipant();
446+
const [barCount, setBarCount] = useState<string>(barCounts[0]);
447+
const [size, setSize] = useState<audioRadialVisualizerVariantsSizeType>(
448+
sizes[3] as audioRadialVisualizerVariantsSizeType
449+
);
450+
const [state, setState] = useState<AgentState>(states[0]);
451+
452+
const micTrackRef = useMemo<TrackReferenceOrPlaceholder | undefined>(() => {
453+
return state === 'speaking'
454+
? ({
455+
participant: localParticipant,
456+
source: Track.Source.Microphone,
457+
publication: microphoneTrack,
458+
} as TrackReference)
459+
: undefined;
460+
}, [state, localParticipant, microphoneTrack]);
461+
462+
useMicrophone();
463+
464+
return (
465+
<Container componentName="AudioVisualizer">
466+
<div className="flex items-center gap-2">
467+
<div className="flex-1">
468+
<label className="font-mono text-xs uppercase" htmlFor="state">
469+
State
470+
</label>
471+
<Select value={state} onValueChange={(value) => setState(value as AgentState)}>
472+
<SelectTrigger id="state" className="w-full">
473+
<SelectValue placeholder="Select a state" />
474+
</SelectTrigger>
475+
<SelectContent>
476+
{states.map((state) => (
477+
<SelectItem key={state} value={state}>
478+
{state}
479+
</SelectItem>
480+
))}
481+
</SelectContent>
482+
</Select>
483+
</div>
484+
485+
<div className="flex-1">
486+
<label className="font-mono text-xs uppercase" htmlFor="size">
487+
Size
488+
</label>
489+
<Select
490+
value={size as string}
491+
onValueChange={(value) => setSize(value as audioRadialVisualizerVariantsSizeType)}
492+
>
493+
<SelectTrigger id="size" className="w-full">
494+
<SelectValue placeholder="Select a size" />
495+
</SelectTrigger>
496+
<SelectContent>
497+
{sizes.map((size) => (
498+
<SelectItem key={size} value={size as string}>
499+
{size}
500+
</SelectItem>
501+
))}
502+
</SelectContent>
503+
</Select>
504+
</div>
505+
506+
<div className="flex-1">
507+
<label className="font-mono text-xs uppercase" htmlFor="barCount">
508+
Bar count
509+
</label>
510+
<Select value={barCount.toString()} onValueChange={(value) => setBarCount(value)}>
511+
<SelectTrigger id="barCount" className="w-full">
512+
<SelectValue placeholder="Select a bar count" />
513+
</SelectTrigger>
514+
<SelectContent>
515+
{barCounts.map((barCount) => (
516+
<SelectItem key={barCount} value={barCount.toString()}>
517+
{parseInt(barCount) || 'Default'}
518+
</SelectItem>
519+
))}
520+
</SelectContent>
521+
</Select>
522+
</div>
523+
</div>
524+
525+
<div className="relative flex flex-col justify-center gap-4">
526+
<div className="grid h-56 place-items-center">
527+
<AudioRadialVisualizer
528+
size={size as audioBarVisualizerVariantsSizeType}
529+
state={state}
530+
audioTrack={micTrackRef!}
531+
barCount={parseInt(barCount) || undefined}
532+
className="mx-auto"
533+
/>
534+
</div>
535+
</div>
536+
</Container>
537+
);
538+
},
539+
425540
// Agent control bar
426541
AgentControlBar: () => {
427542
useMicrophone();
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { useEffect, useMemo, useRef } from 'react';
2+
import { type VariantProps, cva } from 'class-variance-authority';
3+
import {
4+
type AgentState,
5+
BarVisualizer as LiveKitBarVisualizer,
6+
type TrackReferenceOrPlaceholder,
7+
} from '@livekit/components-react';
8+
import { cn } from '@/lib/utils';
9+
10+
const MIN_HEIGHT = 15; // 15%
11+
12+
export const barVisualizerVariants = cva(
13+
['relative flex aspect-square h-36 items-center justify-center'],
14+
{
15+
variants: {
16+
size: {
17+
default: 'h-32',
18+
icon: 'h-6',
19+
xs: 'h-8',
20+
sm: 'h-16',
21+
md: 'h-32',
22+
lg: 'h-64',
23+
xl: 'h-96',
24+
'2xl': 'h-128',
25+
},
26+
},
27+
defaultVariants: {
28+
size: 'default',
29+
},
30+
}
31+
);
32+
33+
interface BarVisualizerProps {
34+
state?: AgentState;
35+
barCount?: number;
36+
audioTrack?: TrackReferenceOrPlaceholder;
37+
className?: string;
38+
}
39+
40+
export function BarVisualizer({
41+
size,
42+
state,
43+
barCount,
44+
audioTrack,
45+
className,
46+
}: BarVisualizerProps & VariantProps<typeof barVisualizerVariants>) {
47+
const ref = useRef<HTMLDivElement>(null);
48+
const _barCount = useMemo(() => {
49+
if (barCount) {
50+
return barCount;
51+
}
52+
switch (size) {
53+
case 'icon':
54+
case 'xs':
55+
return 3;
56+
default:
57+
return 5;
58+
}
59+
}, [barCount, size]);
60+
61+
const x = (1 / (_barCount + (_barCount + 1) / 2)) * 100;
62+
63+
// reset bars height when audio track is disconnected
64+
useEffect(() => {
65+
if (ref.current && !audioTrack) {
66+
const bars = [...(ref.current.querySelectorAll('& > span') ?? [])] as HTMLElement[];
67+
68+
bars.forEach((bar) => {
69+
bar.style.height = `${MIN_HEIGHT}%`;
70+
});
71+
}
72+
}, [audioTrack]);
73+
74+
return (
75+
<LiveKitBarVisualizer
76+
ref={ref}
77+
barCount={_barCount}
78+
state={state}
79+
trackRef={audioTrack}
80+
options={{ minHeight: x }}
81+
className={cn(barVisualizerVariants({ size }), className)}
82+
style={{
83+
gap: `${x / 2}%`,
84+
}}
85+
>
86+
<span
87+
className={cn([
88+
'bg-muted rounded-full',
89+
'origin-center transition-colors duration-250 ease-linear',
90+
'data-[lk-highlighted=true]:bg-foreground data-[lk-muted=true]:bg-muted',
91+
])}
92+
style={{
93+
minHeight: `${x}%`,
94+
width: `${x}%`,
95+
}}
96+
/>
97+
</LiveKitBarVisualizer>
98+
);
99+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { useMemo } from 'react';
2+
import { type VariantProps, cva } from 'class-variance-authority';
3+
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
4+
import {
5+
type AgentState,
6+
type TrackReferenceOrPlaceholder,
7+
useMultibandTrackVolume,
8+
} from '@livekit/components-react';
9+
import { cn } from '@/lib/utils';
10+
import { useBarAnimator } from './hooks/useBarAnimator';
11+
12+
const sequencerIntervals = new Map<AgentState, number>([
13+
['connecting', 2000],
14+
['initializing', 2000],
15+
['listening', 500],
16+
['thinking', 150],
17+
]);
18+
19+
const getSequencerInterval = (
20+
state: AgentState | undefined,
21+
barCount: number
22+
): number | undefined => {
23+
if (state === undefined) {
24+
return 1000;
25+
}
26+
let interval = sequencerIntervals.get(state);
27+
if (interval) {
28+
switch (state) {
29+
case 'connecting':
30+
// case 'thinking':
31+
interval /= barCount;
32+
break;
33+
34+
default:
35+
break;
36+
}
37+
}
38+
return interval;
39+
};
40+
41+
export const audioRadialVisualizerVariants = cva(['relative flex items-center justify-center'], {
42+
variants: {
43+
size: {
44+
icon: 'h-[24px] gap-[2px]',
45+
xs: 'h-[32px] gap-[2px]',
46+
sm: 'h-[56px] gap-[4px]',
47+
md: 'h-[112px] gap-[8px]',
48+
lg: 'h-[224px] gap-[16px]',
49+
xl: 'h-[448px] gap-[32px]',
50+
},
51+
},
52+
defaultVariants: {
53+
size: 'md',
54+
},
55+
});
56+
57+
export const audioRadialVisualizerBarVariants = cva(
58+
[
59+
'bg-muted rounded-full transition-colors duration-250 ease-linear data-[lk-highlighted=true]:bg-foreground',
60+
],
61+
{
62+
variants: {
63+
size: {
64+
icon: 'w-[4px] min-h-[4px]',
65+
xs: 'w-[4px] min-h-[4px]',
66+
sm: 'w-[8px] min-h-[8px]',
67+
md: 'w-[16px] min-h-[16px]',
68+
lg: 'w-[32px] min-h-[32px]',
69+
xl: 'w-[64px] min-h-[64px]',
70+
},
71+
},
72+
defaultVariants: {
73+
size: 'md',
74+
},
75+
}
76+
);
77+
78+
interface AudioRadialVisualizerProps {
79+
state?: AgentState;
80+
barCount?: number;
81+
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
82+
className?: string;
83+
barClassName?: string;
84+
}
85+
86+
export function AudioRadialVisualizer({
87+
size,
88+
state,
89+
barCount,
90+
audioTrack,
91+
className,
92+
barClassName,
93+
}: AudioRadialVisualizerProps & VariantProps<typeof audioRadialVisualizerVariants>) {
94+
const _barCount = useMemo(() => {
95+
if (barCount) {
96+
return barCount;
97+
}
98+
switch (size) {
99+
case 'icon':
100+
case 'xs':
101+
return 9;
102+
default:
103+
return 12;
104+
}
105+
}, [barCount, size]);
106+
107+
const volumeBands = useMultibandTrackVolume(audioTrack, {
108+
bands: _barCount,
109+
loPass: 100,
110+
hiPass: 200,
111+
});
112+
113+
const highlightedIndices = useBarAnimator(
114+
state,
115+
_barCount,
116+
getSequencerInterval(state, _barCount) ?? 100
117+
);
118+
119+
const bands = audioTrack ? volumeBands : new Array(_barCount).fill(0);
120+
return (
121+
<div className={cn(audioRadialVisualizerVariants({ size }), 'relative', className)}>
122+
{bands.map((band, idx) => {
123+
const angle = (idx / _barCount) * 2 * Math.PI;
124+
125+
return (
126+
<div
127+
key={idx}
128+
className={cn('absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2')}
129+
style={{
130+
transform: `rotate(${angle}rad) translateY(50px)`,
131+
transformOrigin: 'center',
132+
}}
133+
>
134+
<div
135+
data-lk-bar-index={idx}
136+
data-lk-highlighted={highlightedIndices.includes(idx)}
137+
className={cn(
138+
audioRadialVisualizerBarVariants({ size }),
139+
'absolute top-1/2 left-1/2 origin-bottom -translate-x-1/2',
140+
barClassName
141+
)}
142+
style={{ height: `${band * 100}px` }}
143+
/>
144+
</div>
145+
);
146+
})}
147+
</div>
148+
);
149+
}

0 commit comments

Comments
 (0)