Skip to content

Commit a8b2373

Browse files
AudioGridVisualizer
1 parent 2a9a16a commit a8b2373

File tree

4 files changed

+641
-4
lines changed

4 files changed

+641
-4
lines changed

app/ui/_components.tsx

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,20 @@ import { MicrophoneIcon } from '@phosphor-icons/react/dist/ssr';
1313
import { useSession } from '@/components/app/session-provider';
1414
import { AgentControlBar } from '@/components/livekit/agent-control-bar/agent-control-bar';
1515
import { TrackControl } from '@/components/livekit/agent-control-bar/track-control';
16-
import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select';
17-
import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle';
16+
// import { TrackDeviceSelect } from '@/components/livekit/agent-control-bar/track-device-select';
17+
// import { TrackToggle } from '@/components/livekit/agent-control-bar/track-toggle';
1818
import { Alert, AlertDescription, AlertTitle, alertVariants } from '@/components/livekit/alert';
1919
import { AlertToast } from '@/components/livekit/alert-toast';
2020
import { BarVisualizer } from '@/components/livekit/audio-visualizer/audio-bar-visualizer/_bar-visualizer';
2121
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 { Button, buttonVariants } from '@/components/livekit/button';
2631
import { ChatEntry } from '@/components/livekit/chat-entry';
2732
import {
@@ -191,8 +196,8 @@ export const COMPONENTS = {
191196
</Container>
192197
),
193198

194-
// Audio visualizer
195-
AudioVisualizer: () => {
199+
// Audio bar visualizer
200+
AudioBarVisualizer: () => {
196201
const barCounts = ['0', '3', '5', '7', '9'];
197202
const sizes = ['icon', 'xs', 'sm', 'md', 'lg', 'xl'];
198203
const states = [
@@ -309,6 +314,114 @@ export const COMPONENTS = {
309314
);
310315
},
311316

317+
// Audio grid visualizer
318+
AudioGridVisualizer: () => {
319+
const rowCounts = ['0', '3', '5', '7', '9'];
320+
const states = [
321+
'disconnected',
322+
'connecting',
323+
'initializing',
324+
'listening',
325+
'thinking',
326+
'speaking',
327+
] as AgentState[];
328+
329+
const { microphoneTrack, localParticipant } = useLocalParticipant();
330+
const [rowCount, setRowCount] = useState(rowCounts[0]);
331+
const [state, setState] = useState<AgentState>(states[0]);
332+
const [demoIndex, setDemoIndex] = useState(0);
333+
334+
const micTrackRef = useMemo<TrackReferenceOrPlaceholder | undefined>(() => {
335+
return state === 'speaking'
336+
? ({
337+
participant: localParticipant,
338+
source: Track.Source.Microphone,
339+
publication: microphoneTrack,
340+
} as TrackReference)
341+
: undefined;
342+
}, [state, localParticipant, microphoneTrack]);
343+
344+
useMicrophone();
345+
346+
return (
347+
<Container componentName="AudioVisualizer">
348+
<div className="flex items-center gap-2">
349+
<div className="flex-1">
350+
<label className="font-mono text-xs uppercase" htmlFor="state">
351+
State
352+
</label>
353+
<Select value={state} onValueChange={(value) => setState(value as AgentState)}>
354+
<SelectTrigger id="state" className="w-full">
355+
<SelectValue placeholder="Select a state" />
356+
</SelectTrigger>
357+
<SelectContent>
358+
{states.map((state) => (
359+
<SelectItem key={state} value={state}>
360+
{state}
361+
</SelectItem>
362+
))}
363+
</SelectContent>
364+
</Select>
365+
</div>
366+
367+
<div className="flex-1">
368+
<label className="font-mono text-xs uppercase" htmlFor="rowCount">
369+
Row count
370+
</label>
371+
<Select value={rowCount.toString()} onValueChange={(value) => setRowCount(value)}>
372+
<SelectTrigger id="rowCount" className="w-full">
373+
<SelectValue placeholder="Select a bar count" />
374+
</SelectTrigger>
375+
<SelectContent>
376+
{rowCounts.map((rowCount) => (
377+
<SelectItem key={rowCount} value={rowCount.toString()}>
378+
{parseInt(rowCount) || 'Default'}
379+
</SelectItem>
380+
))}
381+
</SelectContent>
382+
</Select>
383+
</div>
384+
385+
<div className="flex-1">
386+
<label className="font-mono text-xs uppercase" htmlFor="demoIndex">
387+
Demo
388+
</label>
389+
<Select
390+
value={demoIndex.toString()}
391+
onValueChange={(value) => setDemoIndex(parseInt(value))}
392+
>
393+
<SelectTrigger id="demoIndex" className="w-full">
394+
<SelectValue placeholder="Select a demo" />
395+
</SelectTrigger>
396+
<SelectContent>
397+
{gridVariants.map((_, idx) => (
398+
<SelectItem key={idx} value={idx.toString()}>
399+
Demo {String(idx + 1)}
400+
</SelectItem>
401+
))}
402+
</SelectContent>
403+
</Select>
404+
</div>
405+
</div>
406+
407+
<div className="grid place-items-center py-12">
408+
<AudioGridVisualizer
409+
key={`${demoIndex}-${rowCount}`}
410+
state={state}
411+
audioTrack={micTrackRef!}
412+
columnCount={parseInt(rowCount) || 5}
413+
options={gridVariants[demoIndex] as GridOptions}
414+
/>
415+
</div>
416+
<div className="border-border bg-muted rounded-xl border p-8">
417+
<pre className="text-muted-foreground text-sm">
418+
<code>{JSON.stringify(gridVariants[demoIndex], null, 2)}</code>
419+
</pre>
420+
</div>
421+
</Container>
422+
);
423+
},
424+
312425
// Agent control bar
313426
AgentControlBar: () => {
314427
useMicrophone();
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { CSSProperties, ComponentType } 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 { type GridAnimationOptions, useGridAnimator } from './hooks/useGridAnimator';
9+
10+
export interface GridOptions {
11+
baseStyle: CSSProperties;
12+
gridComponent?: ComponentType<{ style: CSSProperties }>;
13+
gridSpacing?: string;
14+
onStyle?: CSSProperties;
15+
offStyle?: CSSProperties;
16+
transformer?: (distanceFromCenter: number, volumeBands: number[]) => CSSProperties;
17+
rowCount?: number;
18+
animationOptions?: GridAnimationOptions;
19+
maxHeight?: number;
20+
minHeight?: number;
21+
radiusFactor?: number;
22+
radial?: boolean;
23+
}
24+
25+
export interface AudioGridVisualizerProps {
26+
style?: 'grid' | 'bar' | 'radial' | 'waveform';
27+
columnCount?: number;
28+
state: AgentState;
29+
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
30+
options?: GridOptions;
31+
}
32+
33+
export function AudioGridVisualizer({
34+
state,
35+
columnCount = 5,
36+
audioTrack,
37+
options,
38+
}: AudioGridVisualizerProps) {
39+
const volumeBands = useMultibandTrackVolume(audioTrack, {
40+
bands: columnCount,
41+
loPass: 100,
42+
hiPass: 200,
43+
});
44+
45+
const gridColumns = volumeBands.length;
46+
const gridRows = options?.rowCount ?? gridColumns;
47+
const gridArray = Array.from({ length: gridColumns }).map((_, i) => i);
48+
const gridRowsArray = Array.from({ length: gridRows }).map((_, i) => i);
49+
const highlightedIndex = useGridAnimator(
50+
state,
51+
gridRows,
52+
gridColumns,
53+
options?.animationOptions?.interval ?? 100,
54+
state !== 'speaking' ? 'active' : 'paused',
55+
options?.animationOptions
56+
);
57+
58+
const rowMidPoint = Math.floor(gridRows / 2.0);
59+
const volumeChunks = 1 / (rowMidPoint + 1);
60+
61+
const baseStyle = options?.baseStyle ?? {};
62+
const onStyle = { ...baseStyle, ...(options?.onStyle ?? {}) };
63+
const offStyle = { ...baseStyle, ...(options?.offStyle ?? {}) };
64+
const GridComponent = options?.gridComponent || 'div';
65+
66+
const grid = gridArray.map((x) => {
67+
return (
68+
<div
69+
key={x}
70+
className="flex flex-col"
71+
style={{
72+
gap: options?.gridSpacing ?? '4px',
73+
}}
74+
>
75+
{gridRowsArray.map((y) => {
76+
const distanceToMid = Math.abs(rowMidPoint - y);
77+
const threshold = distanceToMid * volumeChunks;
78+
let targetStyle: CSSProperties;
79+
if (state !== 'speaking') {
80+
if (highlightedIndex.x === x && highlightedIndex.y === y) {
81+
targetStyle = {
82+
transition: `all ${(options?.animationOptions?.interval ?? 100) / 1000}s ease-out`,
83+
...onStyle,
84+
};
85+
} else {
86+
targetStyle = {
87+
transition: `all ${(options?.animationOptions?.interval ?? 100) / 100}s ease-out`,
88+
...offStyle,
89+
};
90+
}
91+
} else {
92+
if (volumeBands[x] >= threshold) {
93+
targetStyle = onStyle;
94+
} else {
95+
targetStyle = offStyle;
96+
}
97+
}
98+
99+
const distanceFromCenter = Math.sqrt(
100+
Math.pow(rowMidPoint - x, 2) + Math.pow(rowMidPoint - y, 2)
101+
);
102+
103+
return (
104+
<GridComponent
105+
style={{ ...targetStyle, ...options?.transformer?.(distanceFromCenter, volumeBands) }}
106+
key={x + '-' + y}
107+
/>
108+
);
109+
})}
110+
</div>
111+
);
112+
});
113+
114+
return (
115+
<div
116+
className="flex h-full items-center justify-center"
117+
style={{
118+
gap: options?.gridSpacing ?? '4px',
119+
}}
120+
>
121+
{grid}
122+
</div>
123+
);
124+
}

0 commit comments

Comments
 (0)