Skip to content

Commit cdcd3b0

Browse files
AudioOscilloscopeVisualizer
1 parent 9c6662b commit cdcd3b0

File tree

3 files changed

+421
-2
lines changed

3 files changed

+421
-2
lines changed

app/ui/_components.tsx

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
import { useEffect, useMemo, useState } from 'react';
44
import { type VariantProps } from 'class-variance-authority';
55
import { Track } from 'livekit-client';
6-
import { RoomAudioRenderer, StartAudio } from '@livekit/components-react';
6+
// import { RoomAudioRenderer, StartAudio } from '@livekit/components-react';
77
import {
88
type AgentState,
99
type TrackReference,
1010
type TrackReferenceOrPlaceholder,
1111
useLocalParticipant,
12-
useVoiceAssistant,
12+
// useVoiceAssistant,
1313
} from '@livekit/components-react';
1414
import { MicrophoneIcon } from '@phosphor-icons/react/dist/ssr';
1515
import { useSession } from '@/components/app/session-provider';
@@ -26,6 +26,7 @@ import {
2626
} from '@/components/livekit/audio-visualizer/audio-bar-visualizer/audio-bar-visualizer';
2727
import { AudioGridVisualizer } from '@/components/livekit/audio-visualizer/audio-grid-visualizer/audio-grid-visualizer';
2828
import { gridVariants } from '@/components/livekit/audio-visualizer/audio-grid-visualizer/demos';
29+
import { AudioOscilloscopeVisualizer } from '@/components/livekit/audio-visualizer/audio-oscilloscope-visualizer/audio-oscilloscope-visualizer';
2930
import {
3031
AudioRadialVisualizer,
3132
audioRadialVisualizerVariants,
@@ -694,6 +695,102 @@ export const COMPONENTS = {
694695
);
695696
},
696697

698+
AudioOscilloscopeVisualizer: () => {
699+
// shape
700+
const [shape, setShape] = useState(1.0);
701+
702+
const sizes = ['icon', 'sm', 'md', 'lg', 'xl'];
703+
const states = [
704+
'disconnected',
705+
'connecting',
706+
'initializing',
707+
'listening',
708+
'thinking',
709+
'speaking',
710+
] as AgentState[];
711+
712+
const [size, setSize] = useState<audioShaderVisualizerVariantsSizeType>('lg');
713+
const [state, setState] = useState<AgentState>(states[0]);
714+
715+
const { microphoneTrack, localParticipant } = useLocalParticipant();
716+
const micTrackRef = useMemo<TrackReferenceOrPlaceholder | undefined>(() => {
717+
return state === 'speaking'
718+
? ({
719+
participant: localParticipant,
720+
source: Track.Source.Microphone,
721+
publication: microphoneTrack,
722+
} as TrackReference)
723+
: undefined;
724+
}, [state, localParticipant, microphoneTrack]);
725+
726+
useMicrophone();
727+
728+
return (
729+
<Container componentName="AudioShaderVisualizer">
730+
<div className="flex gap-4">
731+
<div className="flex-1">
732+
<label className="font-mono text-xs uppercase" htmlFor="size">
733+
Size
734+
</label>
735+
<Select
736+
value={size as string}
737+
onValueChange={(value) => setSize(value as audioShaderVisualizerVariantsSizeType)}
738+
>
739+
<SelectTrigger id="size" className="w-full">
740+
<SelectValue placeholder="Select a size" />
741+
</SelectTrigger>
742+
<SelectContent>
743+
{sizes.map((size) => (
744+
<SelectItem key={size} value={size as string}>
745+
{size.toUpperCase()}
746+
</SelectItem>
747+
))}
748+
</SelectContent>
749+
</Select>
750+
</div>
751+
752+
<div className="flex-1">
753+
<label className="font-mono text-xs uppercase" htmlFor="shape">
754+
Shape
755+
</label>
756+
<Select value={shape.toString()} onValueChange={(value) => setShape(parseInt(value))}>
757+
<SelectTrigger id="shape" className="w-full">
758+
<SelectValue placeholder="Select a shape" />
759+
</SelectTrigger>
760+
<SelectContent>
761+
<SelectItem value="1">Circle</SelectItem>
762+
<SelectItem value="2">Line</SelectItem>
763+
</SelectContent>
764+
</Select>
765+
</div>
766+
</div>
767+
768+
<div className="py-12">
769+
<AudioOscilloscopeVisualizer
770+
size={size}
771+
state={state}
772+
audioTrack={micTrackRef!}
773+
className="mx-auto"
774+
/>
775+
</div>
776+
777+
<div className="flex flex-wrap gap-4">
778+
{states.map((stateType) => (
779+
<Button
780+
key={stateType}
781+
size="sm"
782+
variant={state === stateType ? 'primary' : 'default'}
783+
onClick={() => setState(stateType)}
784+
className={'flex-1'}
785+
>
786+
{stateType}
787+
</Button>
788+
))}
789+
</div>
790+
</Container>
791+
);
792+
},
793+
697794
// Agent control bar
698795
AgentControlBar: () => {
699796
useMicrophone();
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4+
import { type VariantProps, cva } from 'class-variance-authority';
5+
import {
6+
type AnimationPlaybackControlsWithThen,
7+
type ValueAnimationTransition,
8+
animate,
9+
useMotionValue,
10+
useMotionValueEvent,
11+
} from 'motion/react';
12+
import {
13+
type AgentState,
14+
type TrackReference,
15+
type TrackReferenceOrPlaceholder,
16+
// useMultibandTrackVolume,
17+
useTrackVolume,
18+
} from '@livekit/components-react';
19+
import { cn } from '@/lib/utils';
20+
import { OscilliscopeShaders, type OscilliscopeShadersProps } from './oscilliscope-shaders';
21+
22+
const DEFAULT_SPEED = 5;
23+
const DEFAULT_AMPLITUDE = 0.025;
24+
const DEFAULT_FREQUENCY = 10;
25+
const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.2, ease: 'easeOut' };
26+
27+
function useAnimatedValue<T>(initialValue: T) {
28+
const [value, setValue] = useState(initialValue);
29+
const motionValue = useMotionValue(initialValue);
30+
const controlsRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
31+
useMotionValueEvent(motionValue, 'change', (value) => setValue(value as T));
32+
33+
const animateFn = useCallback(
34+
(targetValue: T | T[], transition: ValueAnimationTransition) => {
35+
controlsRef.current = animate(motionValue, targetValue, transition);
36+
},
37+
[motionValue]
38+
);
39+
40+
return { value, controls: controlsRef, animate: animateFn };
41+
}
42+
43+
export const audioShaderVisualizerVariants = cva(['aspect-square'], {
44+
variants: {
45+
size: {
46+
icon: 'h-[24px] gap-[2px]',
47+
sm: 'h-[56px] gap-[4px]',
48+
md: 'h-[112px] gap-[8px]',
49+
lg: 'h-[224px] gap-[16px]',
50+
xl: 'h-[448px] gap-[32px]',
51+
},
52+
},
53+
defaultVariants: {
54+
size: 'md',
55+
},
56+
});
57+
58+
interface AudioOscilloscopeVisualizerProps {
59+
speed?: number;
60+
state?: AgentState;
61+
audioTrack: TrackReferenceOrPlaceholder;
62+
className?: string;
63+
}
64+
65+
export function AudioOscilloscopeVisualizer({
66+
size = 'lg',
67+
state = 'speaking',
68+
speed = DEFAULT_SPEED,
69+
audioTrack,
70+
className,
71+
}: AudioOscilloscopeVisualizerProps &
72+
OscilliscopeShadersProps &
73+
VariantProps<typeof audioShaderVisualizerVariants>) {
74+
const {
75+
value: amplitude,
76+
controls: amplitudeControls,
77+
animate: animateAmplitude,
78+
} = useAnimatedValue(DEFAULT_AMPLITUDE);
79+
const {
80+
value: frequency,
81+
controls: frequencyControls,
82+
animate: animateFrequency,
83+
} = useAnimatedValue(DEFAULT_FREQUENCY);
84+
const {
85+
value: opacity,
86+
controls: opacityControls,
87+
animate: animateOpacity,
88+
} = useAnimatedValue(1.0);
89+
90+
const volume = useTrackVolume(audioTrack as TrackReference, {
91+
fftSize: 512,
92+
smoothingTimeConstant: 0.55,
93+
});
94+
95+
useEffect(() => {
96+
switch (state) {
97+
case 'disconnected':
98+
animateAmplitude(0, DEFAULT_TRANSITION);
99+
animateFrequency(0, DEFAULT_TRANSITION);
100+
animateOpacity(1.0, DEFAULT_TRANSITION);
101+
return;
102+
case 'listening':
103+
case 'connecting':
104+
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
105+
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
106+
animateOpacity([1.0, 0.2], {
107+
duration: 0.75,
108+
repeat: Infinity,
109+
repeatType: 'mirror',
110+
});
111+
return;
112+
case 'thinking':
113+
case 'initializing':
114+
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
115+
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
116+
animateOpacity([1.0, 0.2], {
117+
duration: 0.2,
118+
repeat: Infinity,
119+
repeatType: 'mirror',
120+
});
121+
return;
122+
case 'speaking':
123+
default:
124+
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
125+
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
126+
animateOpacity(1.0, DEFAULT_TRANSITION);
127+
return;
128+
}
129+
}, [state, animateAmplitude, animateFrequency, animateOpacity]);
130+
131+
useEffect(() => {
132+
if (state === 'speaking' && volume > 0) {
133+
animateAmplitude(0.02 + 0.4 * volume, { duration: 0 });
134+
animateFrequency(20 + 60 * volume, { duration: 0 });
135+
}
136+
}, [state, volume, animateAmplitude, animateFrequency, amplitudeControls, frequencyControls]);
137+
138+
return (
139+
<OscilliscopeShaders
140+
speed={speed}
141+
amplitude={amplitude}
142+
frequency={frequency}
143+
lineWidth={0.005}
144+
smoothing={0.001}
145+
style={{ opacity }}
146+
className={cn(
147+
audioShaderVisualizerVariants({ size }),
148+
'[mask-image:linear-gradient(90deg,rgba(0,0,0,0)_0%,rgba(0,0,0,1)_20%,rgba(0,0,0,1)_80%,rgba(0,0,0,0)_100%)]',
149+
'overflow-hidden rounded-full',
150+
className
151+
)}
152+
/>
153+
);
154+
}

0 commit comments

Comments
 (0)