Skip to content

Commit 210591c

Browse files
AudioRadialVisualizer
1 parent 28705e0 commit 210591c

File tree

5 files changed

+470
-8
lines changed

5 files changed

+470
-8
lines changed

app/ui/_components.tsx

Lines changed: 120 additions & 5 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();
@@ -199,7 +206,7 @@ export const COMPONENTS = {
199206
// Audio bar visualizer
200207
AudioBarVisualizer: () => {
201208
const barCounts = ['0', '3', '5', '7', '9'];
202-
const sizes = ['icon', 'xs', 'sm', 'md', 'lg', 'xl'];
209+
const sizes = ['icon', 'sm', 'md', 'lg', 'xl'];
203210
const states = [
204211
'disconnected',
205212
'connecting',
@@ -212,7 +219,7 @@ export const COMPONENTS = {
212219
const { microphoneTrack, localParticipant } = useLocalParticipant();
213220
const [barCount, setBarCount] = useState<string>(barCounts[0]);
214221
const [size, setSize] = useState<audioBarVisualizerVariantsSizeType>(
215-
sizes[3] as audioBarVisualizerVariantsSizeType
222+
'md' as audioBarVisualizerVariantsSizeType
216223
);
217224
const [state, setState] = useState<AgentState>(states[0]);
218225

@@ -263,7 +270,7 @@ export const COMPONENTS = {
263270
<SelectContent>
264271
{sizes.map((size) => (
265272
<SelectItem key={size} value={size as string}>
266-
{size}
273+
{size.toUpperCase()}
267274
</SelectItem>
268275
))}
269276
</SelectContent>
@@ -290,7 +297,7 @@ export const COMPONENTS = {
290297
</div>
291298

292299
<div className="relative flex flex-col justify-center gap-4">
293-
<div className="grid h-40 place-items-center">
300+
<div className="grid place-items-center py-8">
294301
<AudioBarVisualizer
295302
size={size as audioBarVisualizerVariantsSizeType}
296303
state={state}
@@ -300,7 +307,7 @@ export const COMPONENTS = {
300307
/>
301308
</div>
302309
<div className="text-center">Original BarVisualizer</div>
303-
<div className="border-border grid h-40 place-items-center space-y-4 rounded-xl border p-4">
310+
<div className="border-border grid place-items-center rounded-xl border p-4 py-8">
304311
<BarVisualizer
305312
size={size as audioBarVisualizerVariantsSizeType}
306313
state={state}
@@ -422,6 +429,114 @@ export const COMPONENTS = {
422429
);
423430
},
424431

432+
// Audio bar visualizer
433+
AudioRadialVisualizer: () => {
434+
const barCounts = ['0', '4', '8', '12', '16', '24'];
435+
const sizes = ['icon', '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+
'md' 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.toUpperCase()}
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 place-items-center py-20">
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();

components/livekit/audio-visualizer/audio-bar-visualizer/audio-bar-visualizer.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export const audioBarVisualizerVariants = cva(['relative flex items-center justi
4242
variants: {
4343
size: {
4444
icon: 'h-[24px] gap-[2px]',
45-
xs: 'h-[32px] gap-[2px]',
4645
sm: 'h-[56px] gap-[4px]',
4746
md: 'h-[112px] gap-[8px]',
4847
lg: 'h-[224px] gap-[16px]',
@@ -62,7 +61,6 @@ export const audioBarVisualizerBarVariants = cva(
6261
variants: {
6362
size: {
6463
icon: 'w-[4px] min-h-[4px]',
65-
xs: 'w-[4px] min-h-[4px]',
6664
sm: 'w-[8px] min-h-[8px]',
6765
md: 'w-[16px] min-h-[16px]',
6866
lg: 'w-[32px] min-h-[32px]',
@@ -97,7 +95,7 @@ export function AudioBarVisualizer({
9795
}
9896
switch (size) {
9997
case 'icon':
100-
case 'xs':
98+
case 'sm':
10199
return 3;
102100
default:
103101
return 5;
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+
}

0 commit comments

Comments
 (0)