diff --git a/docs/storybook/stories/agents-ui/AgentAudioVisualizerOrbit.stories.tsx b/docs/storybook/stories/agents-ui/AgentAudioVisualizerOrbit.stories.tsx new file mode 100644 index 000000000..19f2f75f3 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentAudioVisualizerOrbit.stories.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { animate } from 'motion/react'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { LiveAgentSessionProvider } from '../../.storybook/lk-decorators/LiveAgentSessionProvider'; +import { AgentAudioVisualizerOrbit, AgentAudioVisualizerOrbitProps } from '@livekit/agents-ui'; +import { useAgent, useAgentExpression, type AgentMood } from '@livekit/components-react'; +import { useSimulatedVolumeBands } from './useSimulatedVolumeBands'; + +// Glue code: mapping mood -> color is deliberately kept out of the hook (state) and the +// visualizer (presentation) — it's wiring for this example, not a shared utility. +const MOOD_COLORS: Record = { + angry: '#F5222D', + excited: '#FF7A45', + happy: '#FFC53D', + playful: '#F759AB', + surprised: '#B37FEB', + anxious: '#D46B08', + hopeful: '#52C41A', + empathetic: '#36CFC9', + curious: '#6600ff', + sad: '#2F54EB', + calm: '#1FD5F9', +}; + +type RgbaString = `rgba(${number}, ${number}, ${number}, ${number}, )`; + +function rgbaToHex(colorString: RgbaString) { + const rgbaValues = colorString.match(/[\d.]+/g); + if (!rgbaValues) return null; + + const { r, g, b } = { + r: parseInt(rgbaValues[0], 10), + g: parseInt(rgbaValues[1], 10), + b: parseInt(rgbaValues[2], 10), + }; + + const rHex = r.toString(16).padStart(2, '0'); + const gHex = g.toString(16).padStart(2, '0'); + const bHex = b.toString(16).padStart(2, '0'); + + return `#${rHex}${gHex}${bHex}`; +} + +function useAnimatedColor(newColor: `#${string}`) { + const prevColor = React.useRef(newColor); + const [color, setColor] = React.useState(newColor); + + React.useEffect(() => { + const controls = animate(prevColor.current, newColor, { + duration: 1, + ease: 'linear', + onUpdate: (color: RgbaString) => { + if (color.startsWith('#')) { + return; + } + prevColor.current = newColor; + setColor(rgbaToHex(color)); + }, + }); + return () => controls.stop(); + }, [newColor]); + + return color; +} + +export default { + component: AgentAudioVisualizerOrbit, + decorators: [AgentSessionProvider], + render: (args: AgentAudioVisualizerOrbitProps) => { + const { microphoneTrack } = useAgent(); + + return ; + }, + args: { + size: 'xl', + color: '#1FD5F9', + state: 'connecting', + }, + 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' }, + }, + color: { + control: { type: 'color' }, + }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; + +// Demonstrates the `volume` override prop with a simulated speech waveform instead of +// live audio. +export const OverrideVolume: StoryObj = { + args: { + state: 'speaking', + }, + render: (args: AgentAudioVisualizerOrbitProps) => { + const { microphoneTrack } = useAgent(); + const [volume] = useSimulatedVolumeBands(1); + + return ; + }, +}; + +/** + * Needs a project and agent with Expressive Mode enabled, configured through the storybook env + * vars the `LiveAgentSessionProvider` decorator reads. + */ +export const LiveAgentExpression: StoryObj = { + decorators: [LiveAgentSessionProvider], + render: () => { + const { microphoneTrack } = useAgent(); + const { mood, expression } = useAgentExpression(); + const targetColor = mood ? MOOD_COLORS[mood] : '#1FD5F9'; + const color = useAnimatedColor(targetColor); + + return ( +
+ +
+
mood: {mood ?? 'none'}
+
expression: {expression ?? 'none'}
+
+
+ ); + }, +}; diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-orbit.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-orbit.tsx new file mode 100644 index 000000000..00ad313fe --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-orbit.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { type ComponentProps, useCallback, useEffect, useRef } 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 { + ORBIT_BAND_COUNT, + useAgentAudioVisualizerOrbit, +} from '@/hooks/agents-ui/use-agent-audio-visualizer-orbit'; +import { cn } from '@/lib/utils'; + +const DEFAULT_COLOR = '#1FD5F9'; + +function hexToRgb(hexColor: string): [number, number, number] { + const value = parseInt(hexColor.slice(1), 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; +} + +function rgbToHsl([r0, g0, b0]: [number, number, number]): [number, number, number] { + const r = r0 / 255; + const g = g0 / 255; + const b = b0 / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + const h = + max === r + ? ((g - b) / d + (g < b ? 6 : 0)) / 6 + : max === g + ? ((b - r) / d + 2) / 6 + : ((r - g) / d + 4) / 6; + return [h * 360, s, l]; +} + +interface OrbitBlob { + speed: number; + phase: number; + dist: number; + size: number; + hueShift: number; + band: number; +} + +function createBlobs(count: number): OrbitBlob[] { + return Array.from({ length: count }, (_, i) => ({ + speed: (0.3 + 0.13 * i) * (i % 2 ? 1 : -1), + phase: (i * Math.PI * 2) / count, + dist: 0.16 + 0.07 * (i % 3), + size: 0.3 + 0.06 * (i % 4), + hueShift: (i - 3) * 9, + band: i, + })); +} + +interface OrbitDrawParams { + level: number; + bands: number[]; + connected: boolean; + swirl: number; + breatheFrequency: number; + targetRgb: [number, number, number]; +} + +/** + * A cluster of soft orbs orbiting a hot core, drawn additively on a 2D canvas. + * Orbit speed follows `swirl`/`breatheFrequency` (thinking swirls, listening breathes) + * and per-band audio energy pushes the orbs outward and brightens the core. + */ +class Orbit { + private readonly ctx: CanvasRenderingContext2D | null; + private size = 0; + private rgb: [number, number, number]; + private energy = 0; + private spin = 0; + private bandLevels: number[]; + private readonly blobs: OrbitBlob[]; + + constructor( + private readonly canvas: HTMLCanvasElement, + initialRgb: [number, number, number], + ) { + this.ctx = canvas.getContext('2d'); + this.rgb = initialRgb; + this.bandLevels = new Array(ORBIT_BAND_COUNT).fill(0); + this.blobs = createBlobs(ORBIT_BAND_COUNT); + this.resize(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const size = this.canvas.clientWidth; + if (size === 0) return; + this.canvas.width = size * dpr; + this.canvas.height = size * dpr; + this.ctx?.setTransform(dpr, 0, 0, dpr, 0, 0); + this.size = size; + } + + private orb( + x: number, + y: number, + radius: number, + h: number, + s: number, + l: number, + alpha: number, + ) { + const ctx = this.ctx; + if (!ctx) return; + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); + gradient.addColorStop(0, `hsla(${h}, ${s}%, ${l}%, ${alpha})`); + gradient.addColorStop(0.55, `hsla(${h}, ${s}%, ${l * 0.8}%, ${alpha * 0.4})`); + gradient.addColorStop(1, `hsla(${h}, ${s}%, ${l * 0.7}%, 0)`); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + draw(now: number, params: OrbitDrawParams) { + const { ctx, size } = this; + if (!ctx || !size) return; + const { level, bands, connected, swirl, breatheFrequency, targetRgb } = params; + + const t = now / 1000; + const center = size / 2; + + // ease color and energy so changes wash in instead of snapping + this.rgb = this.rgb.map((c, i) => c + (targetRgb[i] - c) * 0.08) as [number, number, number]; + this.energy += (level - this.energy) * 0.25; + this.bandLevels = this.bandLevels.map((value, i) => value + ((bands[i] ?? 0) - value) * 0.3); + + const [h, s, l] = rgbToHsl(this.rgb); + const sat = s * 100; + this.spin += swirl * 0.016; + + const breathe = connected ? 1 + Math.sin(t * breatheFrequency) * 0.025 : 0.8; + const scale = size * 0.5 * breathe * (connected ? 1 : 0.75); + const dim = connected ? 1 : 0.3; + + ctx.clearRect(0, 0, size, size); + ctx.globalCompositeOperation = 'lighter'; + + // orbiting orbs, pushed outward and brightened by their frequency band + for (const blob of this.blobs) { + const energy = this.bandLevels[blob.band % this.bandLevels.length] * 0.7 + this.energy * 0.3; + const angle = blob.phase + this.spin * blob.speed; + const dist = scale * (blob.dist + energy * 0.34); + const wobble = Math.sin(t * 1.7 + blob.phase * 3) * scale * 0.03; + const x = center + Math.cos(angle) * (dist + wobble); + const y = center + Math.sin(angle) * (dist + wobble); + const radius = scale * (blob.size + energy * 0.25); + this.orb( + x, + y, + radius, + h + blob.hueShift, + Math.min(100, sat), + l * 100, + (0.34 + energy * 0.3) * dim, + ); + } + + // hot core + const coreL = Math.min(88, l * 100 + 18 + this.energy * 30); + this.orb(center, center, scale * (0.4 + this.energy * 0.12), h, sat * 0.9, coreL, 0.75 * dim); + + // halo ring that flares with speech + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = `hsla(${h}, ${sat}%, ${Math.min(90, l * 100 + 20)}%, ${(0.1 + this.energy * 0.35) * dim})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(center, center, scale * (0.72 + this.energy * 0.2), 0, Math.PI * 2); + ctx.stroke(); + } +} + +export const AgentAudioVisualizerOrbitVariants = 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 AgentAudioVisualizerOrbitProps { + /** + * The size of the visualizer. + * @defaultValue 'lg' + */ + size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'; + /** + * Agent state + * @default 'connecting' + */ + state?: AgentState; + /** + * The color of the orbit in hexadecimal format. + * @defaultValue '#1FD5F9' + */ + color?: `#${string}`; + /** + * The audio track to visualize. Can be a local/remote audio track or a track reference. + */ + audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder; + /** + * Volume value (0-1) to use instead of the value computed from the audioTrack. + */ + volume?: number; +} + +/** + * A canvas-based audio visualizer that responds to agent state and audio levels. + * Displays a cluster of orbs orbiting a hot core, drawn with additive blending + * on a dark background. + * + * @extends ComponentProps<'canvas'> + * + * @example + * ```tsx + * + * ``` + */ +export function AgentAudioVisualizerOrbit({ + size = 'lg', + state = 'connecting', + color = DEFAULT_COLOR, + audioTrack, + volume, + className, + ref, + ...props +}: AgentAudioVisualizerOrbitProps & + ComponentProps<'canvas'> & + VariantProps) { + const { level, bands, connected, swirl, breatheFrequency } = useAgentAudioVisualizerOrbit( + state, + audioTrack, + volume, + ); + + const canvasRef = useRef(null); + const orbitRef = useRef(null); + const paramsRef = useRef({ + level, + bands, + connected, + swirl, + breatheFrequency, + targetRgb: hexToRgb(color), + }); + paramsRef.current = { + level, + bands, + connected, + swirl, + breatheFrequency, + targetRgb: hexToRgb(color), + }; + + const setCanvasRef = useCallback( + (node: HTMLCanvasElement | null) => { + canvasRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const orbit = new Orbit(canvas, paramsRef.current.targetRgb); + orbitRef.current = orbit; + + const resizeObserver = new ResizeObserver(() => orbit.resize()); + resizeObserver.observe(canvas); + + let animationFrameId = requestAnimationFrame(function tick(now) { + orbit.draw(now, paramsRef.current); + animationFrameId = requestAnimationFrame(tick); + }); + + return () => { + resizeObserver.disconnect(); + cancelAnimationFrame(animationFrameId); + orbitRef.current = null; + }; + }, []); + + return ( + + ); +} + +AgentAudioVisualizerOrbit.displayName = 'AgentAudioVisualizerOrbit'; diff --git a/packages/shadcn/hooks/agents-ui/use-agent-audio-visualizer-orbit.ts b/packages/shadcn/hooks/agents-ui/use-agent-audio-visualizer-orbit.ts new file mode 100644 index 000000000..817c7f6a5 --- /dev/null +++ b/packages/shadcn/hooks/agents-ui/use-agent-audio-visualizer-orbit.ts @@ -0,0 +1,30 @@ +import { + type AgentState, + type TrackReference, + type TrackReferenceOrPlaceholder, + useMultibandTrackVolume, + useTrackVolume, +} from '@livekit/components-react'; +import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'; + +export const ORBIT_BAND_COUNT = 7; + +export function useAgentAudioVisualizerOrbit( + state: AgentState | undefined, + audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder, + volumeProp?: number, +) { + const bands = useMultibandTrackVolume(audioTrack, { + bands: ORBIT_BAND_COUNT, + loPass: 100, + hiPass: 200, + }); + const trackVolume = useTrackVolume(audioTrack as TrackReference); + const level = volumeProp ?? trackVolume; + + const connected = state !== undefined && state !== 'disconnected'; + const swirl = state === 'thinking' ? 3.2 : state === 'speaking' ? 1.4 : 0.55; + const breatheFrequency = state === 'listening' || state === 'pre-connect-buffering' ? 1.2 : 2.1; + + return { level, bands, connected, swirl, breatheFrequency }; +} diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index 550ce6112..6403e06da 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-orbit'; 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 5e8481e30..e04e24b3d 100644 --- a/packages/shadcn/registry.json +++ b/packages/shadcn/registry.json @@ -276,6 +276,28 @@ ], "registryDependencies": ["utils", "@agents-ui/react-shader-toy"] }, + { + "name": "agent-audio-visualizer-orbit", + "type": "registry:component", + "title": "Agent Audio Visualizer Orbit", + "description": "An orbiting orb-cluster visualizer for audio tracks.", + "files": [ + { + "path": "components/agents-ui/agent-audio-visualizer-orbit.tsx", + "type": "registry:component" + }, + { + "path": "hooks/agents-ui/use-agent-audio-visualizer-orbit.ts", + "type": "registry:hook" + } + ], + "dependencies": [ + "livekit-client@^2.0.0", + "@livekit/components-react@^2.0.0", + "class-variance-authority" + ], + "registryDependencies": ["utils"] + }, { "name": "nextjs-api-token-route", "type": "registry:page", @@ -323,7 +345,8 @@ "@agents-ui/agent-audio-visualizer-bar", "@agents-ui/agent-audio-visualizer-grid", "@agents-ui/agent-audio-visualizer-radial", - "@agents-ui/agent-audio-visualizer-wave" + "@agents-ui/agent-audio-visualizer-wave", + "@agents-ui/agent-audio-visualizer-orbit" ], "dependencies": ["@livekit/components-react@^2.0.0", "livekit-client@^2.0.0", "motion"], "categories": ["agents", "blocks"] @@ -344,6 +367,7 @@ "@agents-ui/agent-audio-visualizer-grid", "@agents-ui/agent-audio-visualizer-wave", "@agents-ui/agent-audio-visualizer-aura", + "@agents-ui/agent-audio-visualizer-orbit", "@agents-ui/agent-session-provider", "@agents-ui/start-audio-button", "@agents-ui/agent-chat-indicator", diff --git a/packages/shadcn/tests/agent-audio-visualizer-orbit.test.tsx b/packages/shadcn/tests/agent-audio-visualizer-orbit.test.tsx new file mode 100644 index 000000000..2dd4d42c6 --- /dev/null +++ b/packages/shadcn/tests/agent-audio-visualizer-orbit.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AgentAudioVisualizerOrbit } from '@/components/agents-ui/agent-audio-visualizer-orbit'; +import { useAgentAudioVisualizerOrbit } from '@/hooks/agents-ui/use-agent-audio-visualizer-orbit'; + +vi.mock('@/hooks/agents-ui/use-agent-audio-visualizer-orbit', () => ({ + ORBIT_BAND_COUNT: 7, + useAgentAudioVisualizerOrbit: vi.fn(() => ({ + level: 0, + bands: new Array(7).fill(0), + connected: true, + swirl: 0.55, + breatheFrequency: 2.1, + })), +})); + +describe('AgentAudioVisualizerOrbit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders by default', () => { + render(); + expect(screen.getByTestId('orbit-viz')).toBeInTheDocument(); + }); + + it('applies html attributes (id, class, style, aria)', () => { + render( + , + ); + const visualizer = screen.getByLabelText('Orbit visualizer'); + expect(visualizer).toHaveAttribute('id', 'orbit-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('orbit-viz'); + fireEvent.click(visualizer); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('passes state to root data attribute', () => { + render(); + expect(screen.getByTestId('orbit-viz')).toHaveAttribute('data-lk-state', 'listening'); + }); + + it('renders as a canvas element', () => { + render(); + expect(screen.getByTestId('orbit-viz').tagName).toBe('CANVAS'); + }); + + it('forwards volume to the underlying hook', () => { + render(); + + expect(useAgentAudioVisualizerOrbit).toHaveBeenCalledWith('speaking', undefined, 0.6); + }); +}); diff --git a/packages/shadcn/tests/use-agent-audio-visualizer-orbit.test.ts b/packages/shadcn/tests/use-agent-audio-visualizer-orbit.test.ts new file mode 100644 index 000000000..43dc6f23a --- /dev/null +++ b/packages/shadcn/tests/use-agent-audio-visualizer-orbit.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useAgentAudioVisualizerOrbit } from '@/hooks/agents-ui/use-agent-audio-visualizer-orbit'; +import * as LiveKitComponents from '@livekit/components-react'; + +vi.mock('@livekit/components-react', async () => { + const actual = await vi.importActual('@livekit/components-react'); + return { + ...actual, + useTrackVolume: vi.fn(() => 0), + useMultibandTrackVolume: vi.fn(() => new Array(7).fill(0)), + }; +}); + +describe('useAgentAudioVisualizerOrbit', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(LiveKitComponents.useTrackVolume).mockReturnValue(0); + vi.mocked(LiveKitComponents.useMultibandTrackVolume).mockReturnValue(new Array(7).fill(0)); + }); + + it('returns bands from useMultibandTrackVolume', () => { + const bands = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]; + vi.mocked(LiveKitComponents.useMultibandTrackVolume).mockReturnValue(bands); + + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('speaking')); + + expect(result.current.bands).toBe(bands); + expect(LiveKitComponents.useMultibandTrackVolume).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ bands: 7, loPass: 100, hiPass: 200 }), + ); + }); + + it('uses the volume prop instead of track volume', () => { + vi.mocked(LiveKitComponents.useTrackVolume).mockReturnValue(0.2); + + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('speaking', undefined, 0.9)); + + expect(result.current.level).toBe(0.9); + }); + + it('falls back to track volume when volume is not supplied', () => { + vi.mocked(LiveKitComponents.useTrackVolume).mockReturnValue(0.4); + + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('speaking')); + + expect(result.current.level).toBe(0.4); + }); + + describe('connected', () => { + it('is false when disconnected', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('disconnected')); + expect(result.current.connected).toBe(false); + }); + + it('is false when state is undefined', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit(undefined)); + expect(result.current.connected).toBe(false); + }); + + it('is true for any other state', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('listening')); + expect(result.current.connected).toBe(true); + }); + }); + + describe('swirl', () => { + it('is fastest when thinking', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('thinking')); + expect(result.current.swirl).toBe(3.2); + }); + + it('is moderate when speaking', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('speaking')); + expect(result.current.swirl).toBe(1.4); + }); + + it('is idle otherwise', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('listening')); + expect(result.current.swirl).toBe(0.55); + }); + }); + + describe('breatheFrequency', () => { + it('is slower when listening', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('listening')); + expect(result.current.breatheFrequency).toBe(1.2); + }); + + it('is slower when pre-connect-buffering', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('pre-connect-buffering')); + expect(result.current.breatheFrequency).toBe(1.2); + }); + + it('is faster otherwise', () => { + const { result } = renderHook(() => useAgentAudioVisualizerOrbit('speaking')); + expect(result.current.breatheFrequency).toBe(2.1); + }); + }); +});