diff --git a/docs/storybook/stories/agents-ui/AgentAudioVisualizerMeshGradient.stories.tsx b/docs/storybook/stories/agents-ui/AgentAudioVisualizerMeshGradient.stories.tsx new file mode 100644 index 000000000..a1191c269 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentAudioVisualizerMeshGradient.stories.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { + AgentAudioVisualizerMeshGradient, + AgentAudioVisualizerMeshGradientProps, +} from '@agents-ui'; +import { useAgent } from '@livekit/components-react'; + +export default { + component: AgentAudioVisualizerMeshGradient, + decorators: [AgentSessionProvider], + render: (args: AgentAudioVisualizerMeshGradientProps) => { + const { microphoneTrack } = useAgent(); + + return ( + + ); + }, + args: { + size: 'xl', + colors: ['#1FD5F9', '#2E7BF6', '#7C5CFC', '#1250C4'], + state: 'connecting', + grain: 0, + scale: 1, + distortion: 1, + swirl: 1, + rotation: 0, + }, + argTypes: { + size: { + options: ['icon', 'sm', 'md', 'lg', 'xl'], + control: { type: 'radio' }, + }, + state: { + options: [ + 'idle', + 'disconnected', + 'pre-connect-buffering', + 'connecting', + 'initializing', + 'listening', + 'thinking', + 'speaking', + 'failed', + ], + control: { type: 'radio' }, + }, + colors: { + control: { type: 'object' }, + }, + grain: { + control: { type: 'range', min: 0, max: 2, step: 0.05 }, + }, + scale: { + control: { type: 'range', min: 0.25, max: 3, step: 0.05 }, + }, + distortion: { + control: { type: 'range', min: 0, max: 2, step: 0.05 }, + }, + swirl: { + control: { type: 'range', min: 0, max: 2, step: 0.05 }, + }, + rotation: { + control: { type: 'range', min: 0, max: 360, step: 1 }, + }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; + +export const Sunset: StoryObj = { + args: { + colors: ['#FF6B6B', '#FFA94D', '#FF8CC6', '#845EF7'], + }, +}; + +export const Ocean: StoryObj = { + args: { + colors: ['#0369A1', '#0EA5E9', '#22D3EE', '#14B8A6'], + }, +}; + +export const Aurora: StoryObj = { + args: { + colors: ['#22D3EE', '#34D399', '#A78BFA', '#818CF8'], + }, +}; + +export const Monochrome: StoryObj = { + 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(); + }); + }); +});