= {
+ args: {
+ colors: ['#F8FAFC00', '#94A3B8', '#1E293B', '#020617'],
+ },
+};
diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-mesh-gradient.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-mesh-gradient.tsx
new file mode 100644
index 000000000..9b872fd0d
--- /dev/null
+++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-mesh-gradient.tsx
@@ -0,0 +1,369 @@
+'use client';
+
+import React, { useMemo, type ComponentProps } from 'react';
+import { type VariantProps, cva } from 'class-variance-authority';
+import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
+import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/components-react';
+
+import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy';
+import { useAgentAudioVisualizerMeshGradient } from '@/hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient';
+import { cn } from '@/lib/utils';
+
+const MAX_COLOR_COUNT = 10;
+
+const DEFAULT_COLORS: `#${string}`[] = ['#1FD5F9', '#2E7BF6', '#7C5CFC', '#1250C4'];
+
+function hexToRgba(hexColor: string): [number, number, number, number] {
+ try {
+ const rgbaColor = hexColor.match(
+ /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})?$/,
+ );
+
+ if (rgbaColor) {
+ const [, r, g, b, a] = rgbaColor;
+ const [red, green, blue] = [r, g, b].map((c) => parseInt(c, 16) / 255);
+ const alpha = a ? parseInt(a, 16) / 255 : 1;
+
+ return [red, green, blue, alpha];
+ }
+ } catch (error) {
+ console.error(`Invalid hex color '${hexColor}'.\nFalling back to default color.`);
+ }
+
+ return hexToRgba(DEFAULT_COLORS[0]);
+}
+
+const shaderSource = `
+const int MAX_COLOR_COUNT = ${MAX_COLOR_COUNT};
+
+float hash21(vec2 p) {
+ p = fract(p * vec2(123.34, 456.21));
+ p += dot(p, p + 45.32);
+ return fract(p.x * p.y);
+}
+
+vec2 rotate(vec2 v, float a) {
+ float s = sin(a);
+ float c = cos(a);
+ return mat2(c, -s, s, c) * v;
+}
+
+float valueNoise(vec2 st) {
+ vec2 i = floor(st);
+ vec2 f = fract(st);
+ float a = hash21(i);
+ float b = hash21(i + vec2(1.0, 0.0));
+ float c = hash21(i + vec2(0.0, 1.0));
+ float d = hash21(i + vec2(1.0, 1.0));
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ float x1 = mix(a, b, u.x);
+ float x2 = mix(c, d, u.x);
+ return mix(x1, x2, u.y);
+}
+
+vec2 getPosition(int i, float t) {
+ float a = float(i) * 0.37;
+ float b = 0.6 + fract(float(i) / 3.0) * 0.9;
+ float c = 0.8 + fract(float(i + 1) / 4.0);
+
+ float x = sin(t * b + a);
+ float y = cos(t * c + a * 1.5);
+
+ return 0.5 + 0.5 * vec2(x, y);
+}
+
+const float BASE_GRAIN_MIXER = 0.15;
+const float BASE_GRAIN_OVERLAY = 0.2;
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+ uv -= 0.5;
+ uv = rotate(uv, radians(uRotation));
+ uv /= uScale;
+ uv += 0.5;
+
+ vec2 grainUV = uv * 1000.0;
+
+ float grain = valueNoise(grainUV);
+ float mixerGrain = uGrain * BASE_GRAIN_MIXER * (grain - 0.5);
+
+ const float firstFrameOffset = 41.5;
+ float t = 0.5 * (iTime * uSpeed + firstFrameOffset);
+
+ float radius = smoothstep(0.0, 1.0, length(uv - 0.5));
+ float center = 1.0 - radius;
+
+ for (float i = 1.0; i <= 2.0; i++) {
+ uv.x += uDistortion * center / i * sin(t + i * 0.4 * smoothstep(0.0, 1.0, uv.y)) * cos(0.2 * t + i * 2.4 * smoothstep(0.0, 1.0, uv.y));
+ uv.y += uDistortion * center / i * cos(t + i * 2.0 * smoothstep(0.0, 1.0, uv.x));
+ }
+
+ vec2 uvRotated = uv - vec2(0.5);
+ float angle = 3.0 * uSwirl * radius;
+ uvRotated = rotate(uvRotated, -angle);
+ uvRotated += vec2(0.5);
+
+ vec3 color = vec3(0.0);
+ float opacity = 0.0;
+ float totalWeight = 0.0;
+
+ for (int i = 0; i < MAX_COLOR_COUNT; i++) {
+ if (i >= int(uColorsCount)) break;
+
+ vec2 pos = getPosition(i, t) + mixerGrain;
+ vec3 colorFraction = uColors[i].rgb * uColors[i].a;
+ float opacityFraction = uColors[i].a;
+
+ float dist = length(uvRotated - pos);
+ dist = pow(dist, 3.5);
+ float weight = 1.0 / (dist + 1e-3);
+
+ color += colorFraction * weight;
+ opacity += opacityFraction * weight;
+ totalWeight += weight;
+ }
+
+ color /= max(1e-4, totalWeight);
+ opacity /= max(1e-4, totalWeight);
+
+ float grainOverlay = valueNoise(rotate(grainUV, 1.0) + vec2(3.0));
+ grainOverlay = mix(grainOverlay, valueNoise(rotate(grainUV, 2.0) + vec2(-1.0)), 0.5);
+ grainOverlay = pow(grainOverlay, 1.3);
+
+ float grainOverlayV = grainOverlay * 2.0 - 1.0;
+ vec3 grainOverlayColor = vec3(step(0.0, grainOverlayV));
+ float grainOverlayStrength = uGrain * BASE_GRAIN_OVERLAY * abs(grainOverlayV);
+ grainOverlayStrength = pow(grainOverlayStrength, 0.8);
+ color = mix(color, grainOverlayColor, 0.35 * grainOverlayStrength);
+
+ opacity += 0.5 * grainOverlayStrength;
+ opacity = clamp(opacity, 0.0, 1.0);
+
+ fragColor = vec4(color, opacity);
+}`;
+
+interface MeshGradientShaderProps {
+ /**
+ * Blob orbit speed multiplier.
+ * @default 0.6
+ */
+ speed?: number;
+
+ /**
+ * Domain-warp distortion amount (0-1).
+ * @default 0.3
+ */
+ distortion?: number;
+
+ /**
+ * Vortex/swirl amount (0-1).
+ * @default 0.15
+ */
+ swirl?: number;
+
+ /**
+ * Overall zoom level of the mesh gradient. `1` is the default zoom;
+ * values above `1` zoom in, values below `1` zoom out.
+ * @default 1
+ */
+ scale?: number;
+
+ /**
+ * Rotation of the mesh gradient, in degrees.
+ * @default 0
+ */
+ rotation?: number;
+
+ /**
+ * The palette of colors blended in the mesh gradient, in hexadecimal format.
+ * @default ['#1FD5F9', '#2E7BF6', '#7C5CFC', '#1250C4']
+ */
+ colors?: string[];
+
+ /**
+ * Amount of grain/dither applied to the mesh gradient. `0` disables it entirely;
+ * `1` is the default strength. Values above `1` intensify the effect further.
+ * @default 1
+ */
+ grain?: number;
+}
+
+function MeshGradientShader({
+ speed = 0.6,
+ distortion = 0.3,
+ swirl = 0.15,
+ scale = 1,
+ rotation = 0,
+ colors = DEFAULT_COLORS,
+ grain = 1,
+ ref,
+ className,
+ ...props
+}: MeshGradientShaderProps & ComponentProps<'div'>) {
+ const flatColors = useMemo(() => {
+ const activeColors = colors.slice(0, MAX_COLOR_COUNT);
+ const flat = activeColors.flatMap(hexToRgba);
+ const padding = new Array((MAX_COLOR_COUNT - activeColors.length) * 4).fill(0);
+
+ return [...flat, ...padding];
+ }, [colors]);
+
+ return (
+
+ {
+ console.error('Shader error:', error);
+ }}
+ onWarning={(warning) => {
+ console.warn('Shader warning:', warning);
+ }}
+ style={{ width: '100%', height: '100%' }}
+ />
+
+ );
+}
+
+MeshGradientShader.displayName = 'MeshGradientShader';
+
+export const AgentAudioVisualizerMeshGradientVariants = cva(['aspect-square'], {
+ variants: {
+ size: {
+ icon: 'h-[24px]',
+ sm: 'h-[56px]',
+ md: 'h-[112px]',
+ lg: 'h-[224px]',
+ xl: 'h-[448px]',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+});
+
+export interface AgentAudioVisualizerMeshGradientProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'lg'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
+ /**
+ * Agent state
+ * @default 'connecting'
+ */
+ state?: AgentState;
+ /**
+ * The palette of colors blended in the mesh gradient, in hexadecimal format.
+ * Accepts `#rrggbb` (alpha defaults to 1.0) or `#rrggbbaa`.
+ * Up to 10 colors are supported; extras beyond 10 are ignored.
+ * @defaultValue ['#1FD5F9', '#2E7BF6', '#7C5CFC', '#1250C4']
+ */
+ colors?: string[];
+ /**
+ * Amount of grain/dither applied to the mesh gradient. `0` disables it entirely;
+ * `1` is the default strength. Values above `1` intensify the effect further.
+ * @defaultValue 1
+ */
+ grain?: number;
+ /**
+ * Overall zoom level of the mesh gradient. `1` is the default zoom;
+ * values above `1` zoom in, values below `1` zoom out.
+ * @defaultValue 1
+ */
+ scale?: number;
+ /**
+ * Multiplier applied to the state/audio-driven domain-warp distortion amount.
+ * `1` preserves the default reactive behavior; `0` disables distortion entirely.
+ * @defaultValue 1
+ */
+ distortion?: number;
+ /**
+ * Multiplier applied to the state/audio-driven vortex/swirl amount.
+ * `1` preserves the default reactive behavior; `0` disables swirl entirely.
+ * @defaultValue 1
+ */
+ swirl?: number;
+ /**
+ * Rotation of the mesh gradient, in degrees.
+ * @defaultValue 0
+ */
+ rotation?: number;
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
+}
+
+/**
+ * An shader-based audio visualizer that responds to agent state and audio levels.
+ * Displays an animated mesh gradient of blended color blobs that reacts to the current
+ * agent state (connecting, thinking, speaking, etc.) and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerMeshGradient({
+ size = 'lg',
+ state = 'connecting',
+ colors = DEFAULT_COLORS,
+ grain = 0,
+ scale = 1,
+ distortion = 1,
+ swirl = 1,
+ rotation = 0,
+ audioTrack,
+ className,
+ ref,
+ ...props
+}: AgentAudioVisualizerMeshGradientProps &
+ ComponentProps<'div'> &
+ VariantProps) {
+ // const {
+ // speed,
+ // swirl: audioSwirl,
+ // distortion: audioDistortion,
+ // } = useAgentAudioVisualizerMeshGradient(state, audioTrack);
+
+ return (
+
+ );
+}
diff --git a/packages/shadcn/hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient.ts b/packages/shadcn/hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient.ts
new file mode 100644
index 000000000..88dde810b
--- /dev/null
+++ b/packages/shadcn/hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient.ts
@@ -0,0 +1,122 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
+import {
+ type AnimationPlaybackControlsWithThen,
+ type ValueAnimationTransition,
+ animate,
+ useMotionValue,
+ useMotionValueEvent,
+} from 'motion/react';
+import {
+ type AgentState,
+ type TrackReference,
+ type TrackReferenceOrPlaceholder,
+ useTrackVolume,
+} from '@livekit/components-react';
+
+const DEFAULT_SPEED = 0.6;
+const DEFAULT_DISTORTION = 0.3;
+const DEFAULT_SWIRL = 0.15;
+const DEFAULT_SCALE = 1;
+const DEFAULT_ROTATION = 0;
+const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.5, ease: 'easeOut' };
+const DEFAULT_PULSE_TRANSITION: ValueAnimationTransition = {
+ duration: 0.35,
+ ease: 'easeOut',
+ repeat: Infinity,
+ repeatType: 'mirror',
+};
+const DEFAULT_SPIN_TRANSITION: ValueAnimationTransition = {
+ duration: 8,
+ ease: 'linear',
+ repeat: Infinity,
+ repeatType: 'loop',
+};
+
+function useAnimatedValue(initialValue: T) {
+ const [value, setValue] = useState(initialValue);
+ const motionValue = useMotionValue(initialValue);
+ const controlsRef = useRef(null);
+ useMotionValueEvent(motionValue, 'change', (value) => setValue(value as T));
+
+ const animateFn = useCallback(
+ (targetValue: T | T[], transition: ValueAnimationTransition) => {
+ controlsRef.current = animate(motionValue, targetValue, transition);
+ },
+ [motionValue],
+ );
+
+ return { value, motionValue, controls: controlsRef, animate: animateFn };
+}
+
+export function useAgentAudioVisualizerMeshGradient(
+ state: AgentState | undefined,
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder,
+) {
+ const [speed, setSpeed] = useState(DEFAULT_SPEED);
+ const {
+ value: distortion,
+ animate: animateDistortion,
+ motionValue: distortionMotionValue,
+ } = useAnimatedValue(DEFAULT_DISTORTION);
+ const { value: swirl, animate: animateSwirl } = useAnimatedValue(DEFAULT_SWIRL);
+ const { value: scale, animate: animateScale } = useAnimatedValue(DEFAULT_SCALE);
+ const { value: rotation, animate: animateRotation } = useAnimatedValue(DEFAULT_ROTATION);
+
+ const volume = useTrackVolume(audioTrack as TrackReference, {
+ fftSize: 512,
+ smoothingTimeConstant: 0.55,
+ });
+
+ useEffect(() => {
+ switch (state) {
+ case 'idle':
+ case 'failed':
+ case 'disconnected':
+ setSpeed(0.3);
+ animateDistortion(0.15, DEFAULT_TRANSITION);
+ animateSwirl(0.05, DEFAULT_TRANSITION);
+ animateScale(DEFAULT_SCALE, DEFAULT_TRANSITION);
+ animateRotation(DEFAULT_ROTATION, DEFAULT_TRANSITION);
+ return;
+ case 'listening':
+ case 'pre-connect-buffering':
+ setSpeed(0.7);
+ animateDistortion([0.35, 0.5], { type: 'spring', duration: 1.0, bounce: 0.35 });
+ animateSwirl(0.3, DEFAULT_TRANSITION);
+ animateScale(DEFAULT_SCALE, DEFAULT_TRANSITION);
+ animateRotation(DEFAULT_ROTATION, DEFAULT_TRANSITION);
+ return;
+ case 'thinking':
+ case 'connecting':
+ case 'initializing':
+ setSpeed(1.1);
+ animateDistortion(0.5, DEFAULT_TRANSITION);
+ animateSwirl([0.2, 0.45], DEFAULT_PULSE_TRANSITION);
+ animateScale([1, 1.15], DEFAULT_PULSE_TRANSITION);
+ animateRotation(DEFAULT_ROTATION, DEFAULT_TRANSITION);
+ return;
+ case 'speaking':
+ setSpeed(1.0);
+ animateDistortion(0.4, DEFAULT_TRANSITION);
+ animateSwirl(0.35, DEFAULT_TRANSITION);
+ animateScale(DEFAULT_SCALE, DEFAULT_TRANSITION);
+ animateRotation(360, DEFAULT_SPIN_TRANSITION);
+ return;
+ }
+ }, [state, animateDistortion, animateSwirl, animateScale, animateRotation]);
+
+ useEffect(() => {
+ if (state === 'speaking' && volume > 0 && !distortionMotionValue.isAnimating()) {
+ animateDistortion(0.25 + 0.5 * volume, { duration: 0 });
+ }
+ }, [state, volume, distortionMotionValue, animateDistortion]);
+
+ return {
+ speed,
+ distortion,
+ swirl,
+ scale,
+ rotation,
+ };
+}
diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts
index 550ce6112..66d306224 100644
--- a/packages/shadcn/index.ts
+++ b/packages/shadcn/index.ts
@@ -11,5 +11,6 @@ export * from './components/agents-ui/agent-audio-visualizer-grid';
export * from './components/agents-ui/agent-audio-visualizer-radial';
export * from './components/agents-ui/agent-audio-visualizer-wave';
export * from './components/agents-ui/agent-audio-visualizer-aura';
+export * from './components/agents-ui/agent-audio-visualizer-mesh-gradient';
export * from './components/agents-ui/react-shader-toy';
export * from './components/agents-ui/blocks/agent-session-view-01/components/agent-session-block';
diff --git a/packages/shadcn/registry.json b/packages/shadcn/registry.json
index dde08e928..93c8f1e01 100644
--- a/packages/shadcn/registry.json
+++ b/packages/shadcn/registry.json
@@ -274,6 +274,28 @@
],
"registryDependencies": ["utils", "@agents-ui/react-shader-toy"]
},
+ {
+ "name": "agent-audio-visualizer-mesh-gradient",
+ "type": "registry:component",
+ "title": "Agent Audio Visualizer Mesh Gradient",
+ "description": "A mesh gradient visualizer for audio tracks.",
+ "files": [
+ {
+ "path": "components/agents-ui/agent-audio-visualizer-mesh-gradient.tsx",
+ "type": "registry:component"
+ },
+ {
+ "path": "hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient.ts",
+ "type": "registry:hook"
+ }
+ ],
+ "dependencies": [
+ "livekit-client@^2.0.0",
+ "@livekit/components-react@^2.0.0",
+ "class-variance-authority"
+ ],
+ "registryDependencies": ["utils", "@agents-ui/react-shader-toy"]
+ },
{
"name": "nextjs-api-token-route",
"type": "registry:page",
@@ -343,6 +365,7 @@
"@agents-ui/agent-audio-visualizer-grid",
"@agents-ui/agent-audio-visualizer-wave",
"@agents-ui/agent-audio-visualizer-aura",
+ "@agents-ui/agent-audio-visualizer-mesh-gradient",
"@agents-ui/agent-session-provider",
"@agents-ui/start-audio-button",
"@agents-ui/agent-chat-indicator",
diff --git a/packages/shadcn/tests/agent-audio-visualizer-mesh-gradient.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-mesh-gradient.test.tsx
new file mode 100644
index 000000000..8d1aa342a
--- /dev/null
+++ b/packages/shadcn/tests/agent-audio-visualizer-mesh-gradient.test.tsx
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { AgentAudioVisualizerMeshGradient } from '@/components/agents-ui/agent-audio-visualizer-mesh-gradient';
+
+vi.mock('@/hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient', () => ({
+ useAgentAudioVisualizerMeshGradient: vi.fn(() => ({
+ speed: 0.6,
+ distortion: 0.3,
+ swirl: 0.15,
+ })),
+}));
+
+describe('AgentAudioVisualizerMeshGradient', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders by default', () => {
+ render();
+ expect(screen.getByTestId('mesh-gradient-viz')).toBeInTheDocument();
+ });
+
+ it('applies html attributes (id, class, style, aria)', () => {
+ render(
+ ,
+ );
+ const visualizer = screen.getByLabelText('Mesh gradient visualizer');
+ expect(visualizer).toHaveAttribute('id', 'mesh-gradient-viz');
+ expect(visualizer).toHaveClass('custom-class');
+ expect(visualizer).toHaveStyle({ opacity: '0.7' });
+ });
+
+ it('applies click handler', () => {
+ const onClick = vi.fn();
+ render();
+ const visualizer = screen.getByTestId('mesh-gradient-viz');
+ fireEvent.click(visualizer);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes state to root data attribute', () => {
+ render();
+ expect(screen.getByTestId('mesh-gradient-viz')).toHaveAttribute('data-lk-state', 'listening');
+ });
+
+ it('renders with a custom grain amount', () => {
+ render();
+ expect(screen.getByTestId('mesh-gradient-viz')).toBeInTheDocument();
+ });
+
+ it('renders with custom scale, distortion, swirl, and rotation', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('mesh-gradient-viz')).toBeInTheDocument();
+ });
+});
diff --git a/packages/shadcn/tests/use-agent-audio-visualizer-mesh-gradient.test.ts b/packages/shadcn/tests/use-agent-audio-visualizer-mesh-gradient.test.ts
new file mode 100644
index 000000000..caf7b0f3d
--- /dev/null
+++ b/packages/shadcn/tests/use-agent-audio-visualizer-mesh-gradient.test.ts
@@ -0,0 +1,228 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useAgentAudioVisualizerMeshGradient } from '@/hooks/agents-ui/use-agent-audio-visualizer-mesh-gradient';
+import type { AgentState } from '@livekit/components-react';
+import * as LiveKitComponents from '@livekit/components-react';
+
+// Mock the @livekit/components-react hooks
+vi.mock('@livekit/components-react', async () => {
+ const actual = await vi.importActual('@livekit/components-react');
+ return {
+ ...actual,
+ useTrackVolume: vi.fn(() => 0),
+ };
+});
+
+// Mock motion/react
+vi.mock('motion/react', () => ({
+ useMotionValue: vi.fn((initial) => ({
+ get: () => initial,
+ set: vi.fn(),
+ isAnimating: () => false,
+ })),
+ useMotionValueEvent: vi.fn(),
+ animate: vi.fn(() => ({
+ stop: vi.fn(),
+ })),
+}));
+
+describe('useAgentAudioVisualizerMeshGradient', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(LiveKitComponents.useTrackVolume).mockReturnValue(0);
+ });
+
+ describe('Basic Hook Behavior', () => {
+ it('returns default values', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient(undefined));
+
+ expect(result.current).toHaveProperty('speed');
+ expect(result.current).toHaveProperty('distortion');
+ expect(result.current).toHaveProperty('swirl');
+ expect(result.current).toHaveProperty('scale');
+ expect(result.current).toHaveProperty('rotation');
+ });
+
+ it('returns numeric values', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('idle'));
+
+ expect(typeof result.current.speed).toBe('number');
+ expect(typeof result.current.distortion).toBe('number');
+ expect(typeof result.current.swirl).toBe('number');
+ expect(typeof result.current.scale).toBe('number');
+ expect(typeof result.current.rotation).toBe('number');
+ });
+ });
+
+ describe('State-based Animation Values', () => {
+ it('handles idle state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('idle'));
+ expect(result.current.speed).toBe(0.3);
+ });
+
+ it('handles failed state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('failed'));
+ expect(result.current.speed).toBe(0.3);
+ });
+
+ it('handles disconnected state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('disconnected'));
+ expect(result.current.speed).toBe(0.3);
+ });
+
+ it('handles listening state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('listening'));
+ expect(result.current.speed).toBe(0.7);
+ });
+
+ it('handles pre-connect-buffering state', () => {
+ const { result } = renderHook(() =>
+ useAgentAudioVisualizerMeshGradient('pre-connect-buffering'),
+ );
+ expect(result.current.speed).toBe(0.7);
+ });
+
+ it('handles thinking state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('thinking'));
+ expect(result.current.speed).toBe(1.1);
+ });
+
+ it('handles connecting state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('connecting'));
+ expect(result.current.speed).toBe(1.1);
+ });
+
+ it('handles initializing state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('initializing'));
+ expect(result.current.speed).toBe(1.1);
+ });
+
+ it('handles speaking state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('speaking'));
+ expect(result.current.speed).toBe(1.0);
+ });
+ });
+
+ describe('Audio Track Integration', () => {
+ it('works without audio track', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('speaking'));
+
+ expect(result.current).toBeDefined();
+ expect(LiveKitComponents.useTrackVolume).toHaveBeenCalled();
+ });
+
+ it('accepts audio track parameter', () => {
+ const mockTrack = {} as any;
+ const { result } = renderHook(() =>
+ useAgentAudioVisualizerMeshGradient('speaking', mockTrack),
+ );
+
+ expect(result.current).toBeDefined();
+ expect(LiveKitComponents.useTrackVolume).toHaveBeenCalledWith(
+ mockTrack,
+ expect.objectContaining({
+ fftSize: 512,
+ smoothingTimeConstant: 0.55,
+ }),
+ );
+ });
+
+ it('uses track volume in speaking state', () => {
+ vi.mocked(LiveKitComponents.useTrackVolume).mockReturnValue(0.8);
+ const mockTrack = {} as any;
+
+ const { result } = renderHook(() =>
+ useAgentAudioVisualizerMeshGradient('speaking', mockTrack),
+ );
+
+ expect(result.current).toBeDefined();
+ });
+ });
+
+ describe('State Transitions', () => {
+ it('updates values when state changes', async () => {
+ const { result, rerender } = renderHook(
+ ({ state }) => useAgentAudioVisualizerMeshGradient(state),
+ {
+ initialProps: { state: 'idle' as AgentState },
+ },
+ );
+
+ const initialSpeed = result.current.speed;
+ expect(initialSpeed).toBe(0.3);
+
+ rerender({ state: 'speaking' as AgentState });
+
+ await waitFor(() => {
+ expect(result.current.speed).toBe(1.0);
+ });
+ });
+
+ it('handles undefined state', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient(undefined));
+ expect(result.current).toBeDefined();
+ });
+
+ it('transitions from listening to speaking', async () => {
+ const { result, rerender } = renderHook(
+ ({ state }) => useAgentAudioVisualizerMeshGradient(state),
+ {
+ initialProps: { state: 'listening' as AgentState },
+ },
+ );
+
+ expect(result.current.speed).toBe(0.7);
+
+ rerender({ state: 'speaking' as AgentState });
+
+ await waitFor(() => {
+ expect(result.current.speed).toBe(1.0);
+ });
+ });
+ });
+
+ describe('Return Value Properties', () => {
+ it('all values are defined', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('speaking'));
+
+ expect(result.current.speed).toBeDefined();
+ expect(result.current.distortion).toBeDefined();
+ expect(result.current.swirl).toBeDefined();
+ expect(result.current.scale).toBeDefined();
+ expect(result.current.rotation).toBeDefined();
+ });
+
+ it('values are not NaN', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('speaking'));
+
+ expect(Number.isNaN(result.current.speed)).toBe(false);
+ expect(Number.isNaN(result.current.distortion)).toBe(false);
+ expect(Number.isNaN(result.current.swirl)).toBe(false);
+ expect(Number.isNaN(result.current.scale)).toBe(false);
+ expect(Number.isNaN(result.current.rotation)).toBe(false);
+ });
+ });
+
+ describe('Hook Stability', () => {
+ it('returns stable values for same state', () => {
+ const { result, rerender } = renderHook(
+ ({ state }) => useAgentAudioVisualizerMeshGradient(state),
+ {
+ initialProps: { state: 'idle' as const },
+ },
+ );
+
+ const firstSpeed = result.current.speed;
+ rerender({ state: 'idle' as const });
+
+ expect(result.current.speed).toBe(firstSpeed);
+ });
+
+ it('does not cause infinite re-renders', () => {
+ const { result } = renderHook(() => useAgentAudioVisualizerMeshGradient('speaking'));
+
+ // If this test completes without timeout, the hook is stable
+ expect(result.current).toBeDefined();
+ });
+ });
+});