1- import { CSSProperties , ComponentType } from 'react' ;
1+ import { CSSProperties , ComponentType , JSX , useMemo } from 'react' ;
22import { LocalAudioTrack , RemoteAudioTrack } from 'livekit-client' ;
33import {
44 type AgentState ,
55 type TrackReferenceOrPlaceholder ,
66 useMultibandTrackVolume ,
77} from '@livekit/components-react' ;
8+ import { cn } from '@/lib/utils' ;
89import { 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+
1017export 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
2597export 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