Skip to content

Commit 6628736

Browse files
AudioOscilloscopeVisualizer
1 parent 9c6662b commit 6628736

File tree

3 files changed

+409
-2
lines changed

3 files changed

+409
-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: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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 { value: amplitude, animate: animateAmplitude } = useAnimatedValue(DEFAULT_AMPLITUDE);
75+
const { value: frequency, animate: animateFrequency } = useAnimatedValue(DEFAULT_FREQUENCY);
76+
const { value: opacity, animate: animateOpacity } = useAnimatedValue(1.0);
77+
78+
const volume = useTrackVolume(audioTrack as TrackReference, {
79+
fftSize: 512,
80+
smoothingTimeConstant: 0.55,
81+
});
82+
83+
useEffect(() => {
84+
switch (state) {
85+
case 'disconnected':
86+
animateAmplitude(0, DEFAULT_TRANSITION);
87+
animateFrequency(0, DEFAULT_TRANSITION);
88+
animateOpacity(1.0, DEFAULT_TRANSITION);
89+
return;
90+
case 'listening':
91+
case 'connecting':
92+
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
93+
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
94+
animateOpacity([1.0, 0.2], {
95+
duration: 0.75,
96+
repeat: Infinity,
97+
repeatType: 'mirror',
98+
});
99+
return;
100+
case 'thinking':
101+
case 'initializing':
102+
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
103+
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
104+
animateOpacity([1.0, 0.2], {
105+
duration: 0.2,
106+
repeat: Infinity,
107+
repeatType: 'mirror',
108+
});
109+
return;
110+
case 'speaking':
111+
default:
112+
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
113+
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
114+
animateOpacity(1.0, DEFAULT_TRANSITION);
115+
return;
116+
}
117+
}, [state, animateAmplitude, animateFrequency, animateOpacity]);
118+
119+
useEffect(() => {
120+
if (state === 'speaking' && volume > 0) {
121+
animateAmplitude(0.02 + 0.4 * volume, { duration: 0 });
122+
animateFrequency(20 + 60 * volume, { duration: 0 });
123+
}
124+
}, [state, volume, animateAmplitude, animateFrequency]);
125+
126+
return (
127+
<OscilliscopeShaders
128+
speed={speed}
129+
amplitude={amplitude}
130+
frequency={frequency}
131+
lineWidth={0.005}
132+
smoothing={0.001}
133+
style={{ opacity }}
134+
className={cn(
135+
audioShaderVisualizerVariants({ size }),
136+
'[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%)]',
137+
'overflow-hidden rounded-full',
138+
className
139+
)}
140+
/>
141+
);
142+
}

0 commit comments

Comments
 (0)