| 
1 |  | -import { CSSProperties, ComponentType } from 'react';  | 
 | 1 | +import { CSSProperties, ComponentType, JSX, useMemo } from 'react';  | 
2 | 2 | import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';  | 
3 | 3 | import {  | 
4 | 4 |   type AgentState,  | 
5 | 5 |   type TrackReferenceOrPlaceholder,  | 
6 | 6 |   useMultibandTrackVolume,  | 
7 | 7 | } from '@livekit/components-react';  | 
8 |  | -import { type GridAnimationOptions, useGridAnimator } from './hooks/useGridAnimator';  | 
 | 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;  | 
9 | 14 | 
 
  | 
10 | 15 | 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;  | 
 | 16 | +  radius?: number;  | 
 | 17 | +  interval?: number;  | 
17 | 18 |   rowCount?: number;  | 
18 |  | -  animationOptions?: GridAnimationOptions;  | 
19 |  | -  maxHeight?: number;  | 
20 |  | -  minHeight?: number;  | 
21 |  | -  radiusFactor?: number;  | 
22 |  | -  radial?: boolean;  | 
 | 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;  | 
23 | 26 | }  | 
24 | 27 | 
 
  | 
25 |  | -export interface AudioGridVisualizerProps {  | 
26 |  | -  style?: 'grid' | 'bar' | 'radial' | 'waveform';  | 
27 |  | -  columnCount?: number;  | 
 | 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;  | 
28 | 42 |   state: AgentState;  | 
29 |  | -  audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;  | 
30 |  | -  options?: GridOptions;  | 
 | 43 | +  options: GridOptions;  | 
 | 44 | +  rowCount: number;  | 
 | 45 | +  volumeBands: number[];  | 
 | 46 | +  columnCount: number;  | 
 | 47 | +  highlightedCoordinate: Coordinate;  | 
 | 48 | +  Component: GridComponentType;  | 
31 | 49 | }  | 
32 | 50 | 
 
  | 
33 |  | -export function AudioGridVisualizer({  | 
 | 51 | +function GridCell({  | 
 | 52 | +  index,  | 
34 | 53 |   state,  | 
35 |  | -  columnCount = 5,  | 
36 |  | -  audioTrack,  | 
37 | 54 |   options,  | 
38 |  | -}: AudioGridVisualizerProps) {  | 
 | 55 | +  rowCount,  | 
 | 56 | +  volumeBands,  | 
 | 57 | +  columnCount,  | 
 | 58 | +  highlightedCoordinate,  | 
 | 59 | +  Component,  | 
 | 60 | +}: GridCellProps) {  | 
 | 61 | +  const { interval = 100, baseClassName, onClassName, offClassName, transformer } = options;  | 
 | 62 | +  const rowMidPoint = Math.floor(rowCount / 2);  | 
 | 63 | + | 
 | 64 | +  if (state === 'speaking') {  | 
 | 65 | +    const y = Math.floor(index / columnCount);  | 
 | 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 } = options;  | 
 | 106 | +  const { columnCount, rowCount, items } = useGrid(options);  | 
 | 107 | +  const highlightedCoordinate = useGridAnimator(state, rowCount, columnCount, interval, radius);  | 
39 | 108 |   const volumeBands = useMultibandTrackVolume(audioTrack, {  | 
40 | 109 |     bands: columnCount,  | 
41 | 110 |     loPass: 100,  | 
42 | 111 |     hiPass: 200,  | 
43 | 112 |   });  | 
44 | 113 | 
 
  | 
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 |  | -  });  | 
 | 114 | +  const GridComponent = gridComponent || 'div';  | 
113 | 115 | 
 
  | 
114 | 116 |   return (  | 
115 | 117 |     <div  | 
116 |  | -      className="flex h-full items-center justify-center"  | 
117 |  | -      style={{  | 
118 |  | -        gap: options?.gridSpacing ?? '4px',  | 
119 |  | -      }}  | 
 | 118 | +      className={cn('grid gap-1', className)}  | 
 | 119 | +      style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}  | 
120 | 120 |     >  | 
121 |  | -      {grid}  | 
 | 121 | +      {items.map((idx) => (  | 
 | 122 | +        <GridCell  | 
 | 123 | +          key={idx}  | 
 | 124 | +          index={idx}  | 
 | 125 | +          state={state}  | 
 | 126 | +          options={options}  | 
 | 127 | +          rowCount={rowCount}  | 
 | 128 | +          columnCount={columnCount}  | 
 | 129 | +          volumeBands={volumeBands}  | 
 | 130 | +          highlightedCoordinate={highlightedCoordinate}  | 
 | 131 | +          Component={GridComponent}  | 
 | 132 | +        />  | 
 | 133 | +      ))}  | 
122 | 134 |     </div>  | 
123 | 135 |   );  | 
124 | 136 | }  | 
0 commit comments