Skip to content

Commit 81cd4bf

Browse files
AudioRadialVisualizer
1 parent b00181b commit 81cd4bf

File tree

5 files changed

+453
-8
lines changed

5 files changed

+453
-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}
@@ -447,6 +454,114 @@ export const COMPONENTS = {
447454
);
448455
},
449456

457+
// Audio bar visualizer
458+
AudioRadialVisualizer: () => {
459+
const barCounts = ['0', '4', '8', '12', '16', '24'];
460+
const sizes = ['icon', 'sm', 'md', 'lg', 'xl'];
461+
const states = [
462+
'disconnected',
463+
'connecting',
464+
'initializing',
465+
'listening',
466+
'thinking',
467+
'speaking',
468+
] as AgentState[];
469+
470+
const { microphoneTrack, localParticipant } = useLocalParticipant();
471+
const [barCount, setBarCount] = useState<string>(barCounts[0]);
472+
const [size, setSize] = useState<audioRadialVisualizerVariantsSizeType>(
473+
'md' as audioRadialVisualizerVariantsSizeType
474+
);
475+
const [state, setState] = useState<AgentState>(states[0]);
476+
477+
const micTrackRef = useMemo<TrackReferenceOrPlaceholder | undefined>(() => {
478+
return state === 'speaking'
479+
? ({
480+
participant: localParticipant,
481+
source: Track.Source.Microphone,
482+
publication: microphoneTrack,
483+
} as TrackReference)
484+
: undefined;
485+
}, [state, localParticipant, microphoneTrack]);
486+
487+
useMicrophone();
488+
489+
return (
490+
<Container componentName="AudioVisualizer">
491+
<div className="flex items-center gap-2">
492+
<div className="flex-1">
493+
<label className="font-mono text-xs uppercase" htmlFor="state">
494+
State
495+
</label>
496+
<Select value={state} onValueChange={(value) => setState(value as AgentState)}>
497+
<SelectTrigger id="state" className="w-full">
498+
<SelectValue placeholder="Select a state" />
499+
</SelectTrigger>
500+
<SelectContent>
501+
{states.map((state) => (
502+
<SelectItem key={state} value={state}>
503+
{state}
504+
</SelectItem>
505+
))}
506+
</SelectContent>
507+
</Select>
508+
</div>
509+
510+
<div className="flex-1">
511+
<label className="font-mono text-xs uppercase" htmlFor="size">
512+
Size
513+
</label>
514+
<Select
515+
value={size as string}
516+
onValueChange={(value) => setSize(value as audioRadialVisualizerVariantsSizeType)}
517+
>
518+
<SelectTrigger id="size" className="w-full">
519+
<SelectValue placeholder="Select a size" />
520+
</SelectTrigger>
521+
<SelectContent>
522+
{sizes.map((size) => (
523+
<SelectItem key={size} value={size as string}>
524+
{size.toUpperCase()}
525+
</SelectItem>
526+
))}
527+
</SelectContent>
528+
</Select>
529+
</div>
530+
531+
<div className="flex-1">
532+
<label className="font-mono text-xs uppercase" htmlFor="barCount">
533+
Bar count
534+
</label>
535+
<Select value={barCount.toString()} onValueChange={(value) => setBarCount(value)}>
536+
<SelectTrigger id="barCount" className="w-full">
537+
<SelectValue placeholder="Select a bar count" />
538+
</SelectTrigger>
539+
<SelectContent>
540+
{barCounts.map((barCount) => (
541+
<SelectItem key={barCount} value={barCount.toString()}>
542+
{parseInt(barCount) || 'Default'}
543+
</SelectItem>
544+
))}
545+
</SelectContent>
546+
</Select>
547+
</div>
548+
</div>
549+
550+
<div className="relative flex flex-col justify-center gap-4">
551+
<div className="grid place-items-center py-20">
552+
<AudioRadialVisualizer
553+
size={size as audioBarVisualizerVariantsSizeType}
554+
state={state}
555+
audioTrack={micTrackRef!}
556+
barCount={parseInt(barCount) || undefined}
557+
className="mx-auto"
558+
/>
559+
</div>
560+
</div>
561+
</Container>
562+
);
563+
},
564+
450565
// Agent control bar
451566
AgentControlBar: () => {
452567
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
@@ -13,7 +13,6 @@ export const audioBarVisualizerVariants = cva(['relative flex items-center justi
1313
variants: {
1414
size: {
1515
icon: 'h-[24px] gap-[2px]',
16-
xs: 'h-[32px] gap-[2px]',
1716
sm: 'h-[56px] gap-[4px]',
1817
md: 'h-[112px] gap-[8px]',
1918
lg: 'h-[224px] gap-[16px]',
@@ -33,7 +32,6 @@ export const audioBarVisualizerBarVariants = cva(
3332
variants: {
3433
size: {
3534
icon: 'w-[4px] min-h-[4px]',
36-
xs: 'w-[4px] min-h-[4px]',
3735
sm: 'w-[8px] min-h-[8px]',
3836
md: 'w-[16px] min-h-[16px]',
3937
lg: 'w-[32px] min-h-[32px]',
@@ -68,7 +66,7 @@ export function AudioBarVisualizer({
6866
}
6967
switch (size) {
7068
case 'icon':
71-
case 'xs':
69+
case 'sm':
7270
return 3;
7371
default:
7472
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)