Skip to content

Commit df90867

Browse files
AudioOscilloscopeVisualizer
1 parent fd8bb32 commit df90867

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,
@@ -710,6 +711,102 @@ export const COMPONENTS = {
710711
);
711712
},
712713

714+
AudioOscilloscopeVisualizer: () => {
715+
// shape
716+
const [shape, setShape] = useState(1.0);
717+
718+
const sizes = ['icon', 'sm', 'md', 'lg', 'xl'];
719+
const states = [
720+
'disconnected',
721+
'connecting',
722+
'initializing',
723+
'listening',
724+
'thinking',
725+
'speaking',
726+
] as AgentState[];
727+
728+
const [size, setSize] = useState<audioShaderVisualizerVariantsSizeType>('lg');
729+
const [state, setState] = useState<AgentState>(states[0]);
730+
731+
const { microphoneTrack, localParticipant } = useLocalParticipant();
732+
const micTrackRef = useMemo<TrackReferenceOrPlaceholder | undefined>(() => {
733+
return state === 'speaking'
734+
? ({
735+
participant: localParticipant,
736+
source: Track.Source.Microphone,
737+
publication: microphoneTrack,
738+
} as TrackReference)
739+
: undefined;
740+
}, [state, localParticipant, microphoneTrack]);
741+
742+
useMicrophone();
743+
744+
return (
745+
<Container componentName="AudioShaderVisualizer">
746+
<div className="flex gap-4">
747+
<div className="flex-1">
748+
<label className="font-mono text-xs uppercase" htmlFor="size">
749+
Size
750+
</label>
751+
<Select
752+
value={size as string}
753+
onValueChange={(value) => setSize(value as audioShaderVisualizerVariantsSizeType)}
754+
>
755+
<SelectTrigger id="size" className="w-full">
756+
<SelectValue placeholder="Select a size" />
757+
</SelectTrigger>
758+
<SelectContent>
759+
{sizes.map((size) => (
760+
<SelectItem key={size} value={size as string}>
761+
{size.toUpperCase()}
762+
</SelectItem>
763+
))}
764+
</SelectContent>
765+
</Select>
766+
</div>
767+
768+
<div className="flex-1">
769+
<label className="font-mono text-xs uppercase" htmlFor="shape">
770+
Shape
771+
</label>
772+
<Select value={shape.toString()} onValueChange={(value) => setShape(parseInt(value))}>
773+
<SelectTrigger id="shape" className="w-full">
774+
<SelectValue placeholder="Select a shape" />
775+
</SelectTrigger>
776+
<SelectContent>
777+
<SelectItem value="1">Circle</SelectItem>
778+
<SelectItem value="2">Line</SelectItem>
779+
</SelectContent>
780+
</Select>
781+
</div>
782+
</div>
783+
784+
<div className="py-12">
785+
<AudioOscilloscopeVisualizer
786+
size={size}
787+
state={state}
788+
audioTrack={micTrackRef!}
789+
className="mx-auto"
790+
/>
791+
</div>
792+
793+
<div className="flex flex-wrap gap-4">
794+
{states.map((stateType) => (
795+
<Button
796+
key={stateType}
797+
size="sm"
798+
variant={state === stateType ? 'primary' : 'default'}
799+
onClick={() => setState(stateType)}
800+
className={'flex-1'}
801+
>
802+
{stateType}
803+
</Button>
804+
))}
805+
</div>
806+
</Container>
807+
);
808+
},
809+
713810
// Agent control bar
714811
AgentControlBar: () => {
715812
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)