Skip to content

Commit 7835efb

Browse files
update AudioGridVisualizer
1 parent 210591c commit 7835efb

File tree

3 files changed

+184
-266
lines changed

3 files changed

+184
-266
lines changed

app/ui/_components.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ export const COMPONENTS = {
420420
options={gridVariants[demoIndex] as GridOptions}
421421
/>
422422
</div>
423-
<div className="border-border bg-muted rounded-xl border p-8">
423+
<div className="border-border bg-muted overflow-x-auto rounded-xl border p-8">
424424
<pre className="text-muted-foreground text-sm">
425425
<code>{JSON.stringify(gridVariants[demoIndex], null, 2)}</code>
426426
</pre>
Lines changed: 107 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,97 @@
1-
import { CSSProperties, ComponentType } from 'react';
1+
import { CSSProperties, ComponentType, JSX, useMemo } from 'react';
22
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
33
import {
44
type AgentState,
55
type TrackReferenceOrPlaceholder,
66
useMultibandTrackVolume,
77
} from '@livekit/components-react';
8+
import { cn } from '@/lib/utils';
89
import { type GridAnimationOptions, useGridAnimator } from './hooks/useGridAnimator';
910

11+
const DEFAULT_OPTIONS: GridOptions = {};
12+
13+
type GridComponentType =
14+
| ComponentType<{ style?: CSSProperties; className?: string }>
15+
| keyof JSX.IntrinsicElements;
16+
1017
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;
1718
rowCount?: number;
19+
className?: string;
20+
baseClassName?: string;
21+
offClassName?: string;
22+
onClassName?: string;
1823
animationOptions?: GridAnimationOptions;
19-
maxHeight?: number;
20-
minHeight?: number;
21-
radiusFactor?: number;
22-
radial?: boolean;
24+
gridComponent?: GridComponentType;
25+
transformer?: (distanceFromCenter: number, volumeBands: number[]) => CSSProperties;
26+
}
27+
28+
function useGrid(columnCount: number, rowCount?: number) {
29+
return useMemo(() => {
30+
const gridColumnCount = columnCount;
31+
const gridRowCount = rowCount ?? columnCount;
32+
const rowMidPoint = Math.floor(gridRowCount / 2);
33+
const volumeChunks = 1 / (rowMidPoint + 1);
34+
const items = new Array(gridColumnCount * gridRowCount).fill(0).map((_, idx) => idx);
35+
36+
return { gridColumnCount, gridRowCount, rowMidPoint, volumeChunks, items };
37+
}, [columnCount, rowCount]);
38+
}
39+
40+
function Dot({
41+
index,
42+
state,
43+
gridColumnCount,
44+
rowMidPoint,
45+
volumeChunks,
46+
volumeBands,
47+
options,
48+
Component,
49+
highlightedIndex,
50+
}: {
51+
index: number;
52+
state: AgentState;
53+
options: GridOptions;
54+
rowMidPoint: number;
55+
volumeChunks: number;
56+
volumeBands: number[];
57+
gridColumnCount: number;
58+
highlightedIndex: { x: number; y: number };
59+
Component: GridComponentType;
60+
}) {
61+
const { baseClassName, onClassName, offClassName, transformer } = options;
62+
if (state === 'speaking') {
63+
const y = Math.floor(index / gridColumnCount);
64+
const distanceToMid = Math.abs(rowMidPoint - y);
65+
const threshold = distanceToMid * volumeChunks;
66+
const isOn = volumeBands[index % gridColumnCount] >= threshold;
67+
68+
return <Component className={cn(baseClassName, isOn ? onClassName : offClassName)} />;
69+
}
70+
71+
const distanceFromCenter = Math.sqrt(
72+
Math.pow(rowMidPoint - (index % gridColumnCount), 2) +
73+
Math.pow(rowMidPoint - Math.floor(index / gridColumnCount), 2)
74+
);
75+
const transformerStyle = transformer?.(distanceFromCenter, volumeBands);
76+
77+
const isOn =
78+
highlightedIndex.x === index % gridColumnCount &&
79+
highlightedIndex.y === Math.floor(index / gridColumnCount);
80+
81+
const transitionDurationInSeconds =
82+
(options?.animationOptions?.interval ?? 100) / (isOn ? 1000 : 100);
83+
84+
return (
85+
<Component
86+
style={{
87+
transitionProperty: 'all',
88+
transitionDuration: `${transitionDurationInSeconds}s`,
89+
transitionTimingFunction: 'ease-out',
90+
...transformerStyle,
91+
}}
92+
className={cn(baseClassName, isOn ? onClassName : offClassName)}
93+
/>
94+
);
2395
}
2496

2597
export interface AudioGridVisualizerProps {
@@ -34,91 +106,51 @@ export function AudioGridVisualizer({
34106
state,
35107
columnCount = 5,
36108
audioTrack,
37-
options,
109+
options = DEFAULT_OPTIONS,
38110
}: AudioGridVisualizerProps) {
111+
const { gridColumnCount, gridRowCount, rowMidPoint, volumeChunks, items } = useGrid(
112+
columnCount,
113+
options?.rowCount
114+
);
115+
39116
const volumeBands = useMultibandTrackVolume(audioTrack, {
40117
bands: columnCount,
41118
loPass: 100,
42119
hiPass: 200,
43120
});
44121

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);
49122
const highlightedIndex = useGridAnimator(
50123
state,
51-
gridRows,
52-
gridColumns,
124+
gridRowCount,
125+
gridColumnCount,
53126
options?.animationOptions?.interval ?? 100,
54127
state !== 'speaking' ? 'active' : 'paused',
55128
options?.animationOptions
56129
);
57130

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 ?? {}) };
64131
const GridComponent = options?.gridComponent || 'div';
65132

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-
114133
return (
115134
<div
116-
className="flex h-full items-center justify-center"
135+
className={cn('grid gap-1', options?.className)}
117136
style={{
118-
gap: options?.gridSpacing ?? '4px',
137+
gridTemplateColumns: `repeat(${gridColumnCount}, 1fr)`,
119138
}}
120139
>
121-
{grid}
140+
{items.map((idx) => (
141+
<Dot
142+
key={idx}
143+
index={idx}
144+
state={state}
145+
options={options}
146+
volumeBands={volumeBands}
147+
gridColumnCount={gridColumnCount}
148+
rowMidPoint={rowMidPoint}
149+
volumeChunks={volumeChunks}
150+
highlightedIndex={highlightedIndex}
151+
Component={GridComponent}
152+
/>
153+
))}
122154
</div>
123155
);
124156
}

0 commit comments

Comments
 (0)