Skip to content

Commit a5c272f

Browse files
AudioGridVisualizer
1 parent b0b0bad commit a5c272f

File tree

4 files changed

+584
-0
lines changed

4 files changed

+584
-0
lines changed

app/ui/_components.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import {
2222
AudioBarVisualizer,
2323
audioBarVisualizerVariants,
2424
} from '@/components/livekit/audio-visualizer/audio-bar-visualizer/audio-bar-visualizer';
25+
import {
26+
AudioGridVisualizer,
27+
type GridOptions,
28+
} from '@/components/livekit/audio-visualizer/audio-grid-visualizer/audio-grid-visualizer';
29+
import { gridVariants } from '@/components/livekit/audio-visualizer/audio-grid-visualizer/demos';
2530
import {
2631
AudioRadialVisualizer,
2732
audioRadialVisualizerVariants,
@@ -424,6 +429,139 @@ export const COMPONENTS = {
424429
);
425430
},
426431

432+
// Audio bar visualizer
433+
AudioGridVisualizer: () => {
434+
const rowCounts = ['3', '5', '7', '9', '11', '13', '15'];
435+
const columnCounts = ['3', '5', '7', '9', '11', '13', '15'];
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 [rowCount, setRowCount] = useState(rowCounts[0]);
447+
const [columnCount, setColumnCount] = useState(columnCounts[0]);
448+
const [state, setState] = useState<AgentState>(states[0]);
449+
const [demoIndex, setDemoIndex] = useState(0);
450+
451+
const micTrackRef = useMemo<TrackReferenceOrPlaceholder | undefined>(() => {
452+
return state === 'speaking'
453+
? ({
454+
participant: localParticipant,
455+
source: Track.Source.Microphone,
456+
publication: microphoneTrack,
457+
} as TrackReference)
458+
: undefined;
459+
}, [state, localParticipant, microphoneTrack]);
460+
461+
useMicrophone();
462+
463+
const demoOptions = {
464+
rowCount: parseInt(rowCount),
465+
columnCount: parseInt(columnCount),
466+
...gridVariants[demoIndex],
467+
};
468+
469+
return (
470+
<Container componentName="AudioVisualizer">
471+
<div className="flex items-center gap-2">
472+
<div className="flex-1">
473+
<label className="font-mono text-xs uppercase" htmlFor="state">
474+
State
475+
</label>
476+
<Select value={state} onValueChange={(value) => setState(value as AgentState)}>
477+
<SelectTrigger id="state" className="w-full">
478+
<SelectValue placeholder="Select a state" />
479+
</SelectTrigger>
480+
<SelectContent>
481+
{states.map((state) => (
482+
<SelectItem key={state} value={state}>
483+
{state}
484+
</SelectItem>
485+
))}
486+
</SelectContent>
487+
</Select>
488+
</div>
489+
490+
<div className="flex-1">
491+
<label className="font-mono text-xs uppercase" htmlFor="rowCount">
492+
Row count
493+
</label>
494+
<Select value={rowCount.toString()} onValueChange={(value) => setRowCount(value)}>
495+
<SelectTrigger id="rowCount" className="w-full">
496+
<SelectValue placeholder="Select a bar count" />
497+
</SelectTrigger>
498+
<SelectContent>
499+
{rowCounts.map((rowCount) => (
500+
<SelectItem key={rowCount} value={rowCount.toString()}>
501+
{parseInt(rowCount) || 'Default'}
502+
</SelectItem>
503+
))}
504+
</SelectContent>
505+
</Select>
506+
</div>
507+
508+
<div className="flex-1">
509+
<label className="font-mono text-xs uppercase" htmlFor="columnCount">
510+
Column count
511+
</label>
512+
<Select value={columnCount.toString()} onValueChange={(value) => setColumnCount(value)}>
513+
<SelectTrigger id="columnCount" className="w-full">
514+
<SelectValue placeholder="Select a column count" />
515+
</SelectTrigger>
516+
<SelectContent>
517+
{columnCounts.map((columnCount) => (
518+
<SelectItem key={columnCount} value={columnCount.toString()}>
519+
{parseInt(columnCount) || 'Default'}
520+
</SelectItem>
521+
))}
522+
</SelectContent>
523+
</Select>
524+
</div>
525+
526+
<div className="flex-1">
527+
<label className="font-mono text-xs uppercase" htmlFor="demoIndex">
528+
Demo
529+
</label>
530+
<Select
531+
value={demoIndex.toString()}
532+
onValueChange={(value) => setDemoIndex(parseInt(value))}
533+
>
534+
<SelectTrigger id="demoIndex" className="w-full">
535+
<SelectValue placeholder="Select a demo" />
536+
</SelectTrigger>
537+
<SelectContent>
538+
{gridVariants.map((_, idx) => (
539+
<SelectItem key={idx} value={idx.toString()}>
540+
Demo {String(idx + 1)}
541+
</SelectItem>
542+
))}
543+
</SelectContent>
544+
</Select>
545+
</div>
546+
</div>
547+
548+
<div className="grid place-items-center py-12">
549+
<AudioGridVisualizer
550+
key={`${demoIndex}-${rowCount}-${columnCount}`}
551+
state={state}
552+
audioTrack={micTrackRef!}
553+
options={demoOptions}
554+
/>
555+
</div>
556+
<div className="border-border bg-muted overflow-x-auto rounded-xl border p-8">
557+
<pre className="text-muted-foreground text-sm">
558+
<code>{JSON.stringify(demoOptions, null, 2)}</code>
559+
</pre>
560+
</div>
561+
</Container>
562+
);
563+
},
564+
427565
// Agent control bar
428566
AgentControlBar: () => {
429567
useMicrophone();
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { CSSProperties, ComponentType, JSX, memo, useMemo } from 'react';
2+
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
3+
import {
4+
type AgentState,
5+
type TrackReferenceOrPlaceholder,
6+
useMultibandTrackVolume,
7+
} from '@livekit/components-react';
8+
import { cn } from '@/lib/utils';
9+
import { type Coordinate, useGridAnimator } from './hooks/useGridAnimator';
10+
11+
type GridComponentType =
12+
| ComponentType<{ style?: CSSProperties; className?: string }>
13+
| keyof JSX.IntrinsicElements;
14+
15+
export interface GridOptions {
16+
radius?: number;
17+
interval?: number;
18+
rowCount?: number;
19+
columnCount?: number;
20+
className?: string;
21+
baseClassName?: string;
22+
offClassName?: string;
23+
onClassName?: string;
24+
GridComponent?: GridComponentType;
25+
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
26+
}
27+
28+
function useGrid(options: GridOptions) {
29+
return useMemo(() => {
30+
const { columnCount = 5, rowCount } = options;
31+
32+
const _columnCount = columnCount;
33+
const _rowCount = rowCount ?? columnCount;
34+
const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx);
35+
36+
return { columnCount: _columnCount, rowCount: _rowCount, items };
37+
}, [options]);
38+
}
39+
40+
interface GridCellProps {
41+
index: number;
42+
state: AgentState;
43+
options: GridOptions;
44+
rowCount: number;
45+
volumeBands: number[];
46+
columnCount: number;
47+
highlightedCoordinate: Coordinate;
48+
Component: GridComponentType;
49+
}
50+
51+
const GridCell = memo(function GridCell({
52+
index,
53+
state,
54+
options,
55+
rowCount,
56+
volumeBands,
57+
columnCount,
58+
highlightedCoordinate,
59+
Component,
60+
}: GridCellProps) {
61+
const { interval = 100, baseClassName, onClassName, offClassName, transformer } = options;
62+
63+
if (state === 'speaking') {
64+
const y = Math.floor(index / columnCount);
65+
const rowMidPoint = Math.floor(rowCount / 2);
66+
const volumeChunks = 1 / (rowMidPoint + 1);
67+
const distanceToMid = Math.abs(rowMidPoint - y);
68+
const threshold = distanceToMid * volumeChunks;
69+
const isOn = volumeBands[index % columnCount] >= threshold;
70+
71+
return <Component className={cn(baseClassName, isOn ? onClassName : offClassName)} />;
72+
}
73+
74+
let transformerStyle: CSSProperties | undefined;
75+
if (transformer) {
76+
transformerStyle = transformer(index, rowCount, columnCount);
77+
}
78+
79+
const isOn =
80+
highlightedCoordinate.x === index % columnCount &&
81+
highlightedCoordinate.y === Math.floor(index / columnCount);
82+
83+
const transitionDurationInSeconds = interval / (isOn ? 1000 : 100);
84+
85+
return (
86+
<Component
87+
style={{
88+
transitionProperty: 'all',
89+
transitionDuration: `${transitionDurationInSeconds}s`,
90+
transitionTimingFunction: 'ease-out',
91+
...transformerStyle,
92+
}}
93+
className={cn(baseClassName, isOn ? onClassName : offClassName)}
94+
/>
95+
);
96+
});
97+
98+
export interface AudioGridVisualizerProps {
99+
state: AgentState;
100+
options: GridOptions;
101+
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
102+
}
103+
104+
export function AudioGridVisualizer({ state, options, audioTrack }: AudioGridVisualizerProps) {
105+
const { radius, interval = 100, className, GridComponent = 'div' } = options;
106+
const { columnCount, rowCount, items } = useGrid(options);
107+
const highlightedCoordinate = useGridAnimator(state, rowCount, columnCount, interval, radius);
108+
const volumeBands = useMultibandTrackVolume(audioTrack, {
109+
bands: columnCount,
110+
loPass: 100,
111+
hiPass: 200,
112+
});
113+
114+
return (
115+
<div
116+
className={cn('grid gap-1', className)}
117+
style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
118+
>
119+
{items.map((idx) => (
120+
<GridCell
121+
key={idx}
122+
index={idx}
123+
state={state}
124+
options={options}
125+
rowCount={rowCount}
126+
columnCount={columnCount}
127+
volumeBands={volumeBands}
128+
highlightedCoordinate={highlightedCoordinate}
129+
Component={GridComponent}
130+
/>
131+
))}
132+
</div>
133+
);
134+
}

0 commit comments

Comments
 (0)