diff --git a/app-config.ts b/app-config.ts
index 80fb6a902..cfb5ef9cb 100644
--- a/app-config.ts
+++ b/app-config.ts
@@ -14,6 +14,14 @@ export interface AppConfig {
logoDark?: string;
accentDark?: string;
+ audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura';
+ audioVisualizerRadius?: number;
+ audioVisualizerBarCount?: number;
+ audioVisualizerRowCount?: number;
+ audioVisualizerColumnCount?: number;
+ audioVisualizerColor?: `#${string}`;
+ audioVisualizerColorShift?: number;
+
// agent dispatch configuration
agentName?: string;
@@ -37,6 +45,13 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
accentDark: '#1fd5f9',
startButtonText: 'Start call',
+ audioVisualizerType: 'wave',
+ audioVisualizerBarCount: 25,
+ audioVisualizerRowCount: 11,
+ audioVisualizerColumnCount: 11,
+ audioVisualizerColor: '#1fd5f9',
+ audioVisualizerColorShift: 0.3,
+
// agent dispatch configuration
agentName: process.env.AGENT_NAME ?? undefined,
diff --git a/components/agents-ui/agent-audio-visualizer-aura.tsx b/components/agents-ui/agent-audio-visualizer-aura.tsx
new file mode 100644
index 000000000..28f5474dc
--- /dev/null
+++ b/components/agents-ui/agent-audio-visualizer-aura.tsx
@@ -0,0 +1,438 @@
+'use client';
+
+import React, { type ComponentProps, useMemo } 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 { useAgentAudioVisualizerAura } from '@/hooks/agents-ui/use-agent-audio-visualizer-aura';
+import { cn } from '@/lib/shadcn/utils';
+
+const DEFAULT_COLOR = '#1FD5F9';
+
+function hexToRgb(hexColor: string) {
+ try {
+ const rgbColor = hexColor.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
+
+ if (rgbColor) {
+ const [, r, g, b] = rgbColor;
+ const color = [r, g, b].map((c = '00') => parseInt(c, 16) / 255);
+
+ return color;
+ }
+ } catch (error) {
+ console.error(
+ `Invalid hex color '${hexColor}'.\nFalling back to default color '${DEFAULT_COLOR}'.`
+ );
+ }
+
+ return hexToRgb(DEFAULT_COLOR);
+}
+
+const shaderSource = `
+const float TAU = 6.283185;
+
+// Noise for dithering
+vec2 randFibo(vec2 p) {
+ p = fract(p * vec2(443.897, 441.423));
+ p += dot(p, p.yx + 19.19);
+ return fract((p.xx + p.yx) * p.xy);
+}
+
+// Tonemap
+vec3 Tonemap(vec3 x) {
+ x *= 4.0;
+ return x / (1.0 + x);
+}
+
+// Luma for alpha
+float luma(vec3 color) {
+ return dot(color, vec3(0.299, 0.587, 0.114));
+}
+
+// RGB to HSV
+vec3 rgb2hsv(vec3 c) {
+ vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
+ vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
+ vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
+ float d = q.x - min(q.w, q.y);
+ float e = 1.0e-10;
+ return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
+}
+
+// HSV to RGB
+vec3 hsv2rgb(vec3 c) {
+ vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
+ vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
+}
+
+// SDF shapes
+float sdCircle(vec2 st, float r) {
+ return length(st) - r;
+}
+
+float sdLine(vec2 p, float r) {
+ float halfLen = r * 2.0;
+ vec2 a = vec2(-halfLen, 0.0);
+ vec2 b = vec2(halfLen, 0.0);
+ vec2 pa = p - a;
+ vec2 ba = b - a;
+ float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
+ return length(pa - ba * h);
+}
+
+float getSdf(vec2 st) {
+ if(uShape == 1.0) return sdCircle(st, uScale);
+ else if(uShape == 2.0) return sdLine(st, uScale);
+ return sdCircle(st, uScale); // Default
+}
+
+vec2 turb(vec2 pos, float t, float it) {
+ // Initial rotation matrix for swirl direction
+ mat2 rotation = mat2(0.6, -0.25, 0.25, 0.9);
+ // Secondary rotation applied each iteration (approx 53 degree rotation)
+ mat2 layerRotation = mat2(0.6, -0.8, 0.8, 0.6);
+
+ float frequency = mix(2.0, 15.0, uFrequency);
+ float amplitude = uAmplitude;
+ float frequencyGrowth = 1.4;
+ float animTime = t * 0.1 * uSpeed;
+
+ const int LAYERS = 4;
+ for(int i = 0; i < LAYERS; i++) {
+ // Calculate wave displacement for this layer
+ vec2 rotatedPos = pos * rotation;
+ vec2 wave = sin(frequency * rotatedPos + float(i) * animTime + it);
+
+ // Apply displacement along rotation direction
+ pos += (amplitude / frequency) * rotation[0] * wave;
+
+ // Evolve parameters for next layer
+ rotation *= layerRotation;
+ amplitude *= mix(1.0, max(wave.x, wave.y), uVariance);
+ frequency *= frequencyGrowth;
+ }
+
+ return pos;
+}
+
+const float ITERATIONS = 36.0;
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+
+ vec3 pp = vec3(0.0);
+ vec3 bloom = vec3(0.0);
+ float t = iTime * 0.5;
+ vec2 pos = uv - 0.5;
+
+ vec2 prevPos = turb(pos, t, 0.0 - 1.0 / ITERATIONS);
+ float spacing = mix(1.0, TAU, uSpacing);
+
+ for(float i = 1.0; i < ITERATIONS + 1.0; i++) {
+ float iter = i / ITERATIONS;
+ vec2 st = turb(pos, t, iter * spacing);
+ float d = abs(getSdf(st));
+ float pd = distance(st, prevPos);
+ prevPos = st;
+ float dynamicBlur = exp2(pd * 2.0 * 1.4426950408889634) - 1.0;
+ float ds = smoothstep(0.0, uBlur * 0.05 + max(dynamicBlur * uSmoothing, 0.001), d);
+
+ // Shift color based on iteration using uColorScale
+ vec3 color = uColor;
+ if(uColorShift > 0.01) {
+ vec3 hsv = rgb2hsv(color);
+ // Shift hue by iteration
+ hsv.x = fract(hsv.x + (1.0 - iter) * uColorShift * 0.3);
+ color = hsv2rgb(hsv);
+ }
+
+ float invd = 1.0 / max(d + dynamicBlur, 0.001);
+ pp += (ds - 1.0) * color;
+ bloom += clamp(invd, 0.0, 250.0) * color;
+ }
+
+ pp *= 1.0 / ITERATIONS;
+
+ vec3 color;
+
+ // Dark mode (default)
+ if(uMode < 0.5) {
+ // use bloom effect
+ bloom = bloom / (bloom + 2e4);
+ color = (-pp + bloom * 3.0 * uBloom) * 1.2;
+ color += (randFibo(fragCoord).x - 0.5) / 255.0;
+ color = Tonemap(color);
+ float alpha = luma(color) * uMix;
+ fragColor = vec4(color * uMix, alpha);
+ }
+
+ // Light mode
+ else {
+ // no bloom effect
+ color = -pp;
+ color += (randFibo(fragCoord).x - 0.5) / 255.0;
+
+ // Preserve hue by tone mapping brightness only
+ float brightness = length(color);
+ vec3 direction = brightness > 0.0 ? color / brightness : color;
+
+ // Reinhard on brightness
+ float factor = 2.0;
+ float mappedBrightness = (brightness * factor) / (1.0 + brightness * factor);
+ color = direction * mappedBrightness;
+
+ // Boost saturation to compensate for white background bleed-through
+ // When alpha < 1.0, white bleeds through making colors look desaturated
+ // So we increase saturation to maintain vibrant appearance
+ float gray = dot(color, vec3(0.2, 0.5, 0.1));
+ float saturationBoost = 3.0;
+ color = mix(vec3(gray), color, saturationBoost);
+
+ // Clamp between 0-1
+ color = clamp(color, 0.0, 1.0);
+
+ float alpha = mappedBrightness * clamp(uMix, 1.0, 2.0);
+ fragColor = vec4(color, alpha);
+ }
+}`;
+
+interface AuraShaderProps {
+ /**
+ * Aurora wave speed
+ * @default 1.0
+ */
+ speed?: number;
+
+ /**
+ * Turbulence amplitude
+ * @default 0.5
+ */
+ amplitude?: number;
+
+ /**
+ * Wave frequency and complexity
+ * @default 0.5
+ */
+ frequency?: number;
+
+ /**
+ * Shape scale
+ * @default 0.3
+ */
+ scale?: number;
+
+ /**
+ * Shape type: 1=circle, 2=line
+ * @default 1
+ */
+ shape?: number;
+
+ /**
+ * Edge blur/softness
+ * @default 1.0
+ */
+ blur?: number;
+
+ /**
+ * Color of the aura
+ * @default '#1FD5F9'
+ */
+ color?: string;
+
+ /**
+ * Color variation across layers (0-1)
+ * Controls how much colors change between iterations
+ * @default 0.5
+ * @example 0.0 - minimal color variation (more uniform)
+ * @example 0.5 - moderate variation (default)
+ * @example 1.0 - maximum variation (rainbow effect)
+ */
+ colorShift?: number;
+
+ /**
+ * Brightness of the aurora (0-1)
+ * @default 1.0
+ */
+ brightness?: number;
+
+ /**
+ * Display mode for different backgrounds
+ * - 'dark': Optimized for dark backgrounds (default)
+ * - 'light': Optimized for light/white backgrounds (inverts colors)
+ * @default 'dark'
+ */
+ themeMode?: 'dark' | 'light';
+}
+
+function AuraShader({
+ shape = 1.0,
+ speed = 1.0,
+ amplitude = 0.5,
+ frequency = 0.5,
+ scale = 0.2,
+ blur = 1.0,
+ color = DEFAULT_COLOR,
+ colorShift = 1.0,
+ brightness = 1.0,
+ themeMode = typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
+ ? 'dark'
+ : 'light',
+ ref,
+ className,
+ ...props
+}: AuraShaderProps & ComponentProps<'div'>) {
+ const rgbColor = useMemo(() => hexToRgb(color), [color]);
+
+ return (
+
+ {
+ console.error('Shader error:', error);
+ }}
+ onWarning={(warning) => {
+ console.warn('Shader warning:', warning);
+ }}
+ style={{ width: '100%', height: '100%' }}
+ />
+
+ );
+}
+
+AuraShader.displayName = 'AuraShader';
+
+export const AgentAudioVisualizerAuraVariants = cva(['aspect-square'], {
+ variants: {
+ size: {
+ icon: 'h-[24px] gap-[2px]',
+ sm: 'h-[56px] gap-[4px]',
+ md: 'h-[112px] gap-[8px]',
+ lg: 'h-[224px] gap-[16px]',
+ xl: 'h-[448px] gap-[32px]',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ },
+});
+
+export interface AgentAudioVisualizerAuraProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'lg'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
+ /**
+ * Agent state
+ * @default 'connecting'
+ */
+ state?: AgentState;
+ /**
+ * The color of the aura in hex format.
+ * @defaultValue '#1FD5F9'
+ */
+ color?: string;
+ /**
+ * The color shift of the aura.
+ * @defaultValue 0.05
+ */
+ colorShift?: number;
+ /**
+ * The theme mode of the aura.
+ * @defaultValue 'dark'
+ */
+ themeMode?: 'dark' | 'light';
+ /**
+ * 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 elliptical aura that reacts to the current agent state (connecting, thinking, speaking, etc.)
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerAura({
+ size = 'lg',
+ state = 'connecting',
+ color = DEFAULT_COLOR,
+ colorShift = 0.05,
+ audioTrack,
+ themeMode,
+ className,
+ ref,
+ ...props
+}: AgentAudioVisualizerAuraProps &
+ ComponentProps<'div'> &
+ VariantProps) {
+ const { speed, scale, amplitude, frequency, brightness } = useAgentAudioVisualizerAura(
+ state,
+ audioTrack
+ );
+
+ return (
+
+ );
+}
diff --git a/components/agents-ui/agent-audio-visualizer-grid.tsx b/components/agents-ui/agent-audio-visualizer-grid.tsx
index 182aaf669..0ed09b4e1 100644
--- a/components/agents-ui/agent-audio-visualizer-grid.tsx
+++ b/components/agents-ui/agent-audio-visualizer-grid.tsx
@@ -51,16 +51,16 @@ export const AgentAudioVisualizerGridVariants = cva(
[
'grid',
'*:size-1 *:rounded-full',
- '*:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-foreground [&_>_[data-lk-highlighted=true]]:scale-125 [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
+ '*:bg-foreground/10 *:data-[lk-highlighted=true]:bg-foreground *:data-[lk-highlighted=true]:scale-125 *:data-[lk-highlighted=true]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
],
{
variants: {
size: {
- icon: ['gap-[2px] *:size-[4px]'],
+ icon: ['gap-[2px] *:size-[2px]'],
sm: ['gap-[4px] *:size-[4px]'],
md: ['gap-[8px] *:size-[8px]'],
- lg: ['gap-[8px] *:size-[8px]'],
- xl: ['gap-[8px] *:size-[8px]'],
+ lg: ['gap-[12px] *:size-[12px]'],
+ xl: ['gap-[16px] *:size-[16px]'],
},
},
defaultVariants: {
diff --git a/components/agents-ui/agent-audio-visualizer-radial.tsx b/components/agents-ui/agent-audio-visualizer-radial.tsx
index 211e276d3..7da6c118f 100644
--- a/components/agents-ui/agent-audio-visualizer-radial.tsx
+++ b/components/agents-ui/agent-audio-visualizer-radial.tsx
@@ -14,12 +14,13 @@ import { cn } from '@/lib/shadcn/utils';
export const AgentAudioVisualizerRadialVariants = cva(
[
'relative flex items-center justify-center',
- '[&_[data-lk-index]]:absolute [&_[data-lk-index]]:top-1/2 [&_[data-lk-index]]:left-1/2 [&_[data-lk-index]]:origin-bottom [&_[data-lk-index]]:-translate-x-1/2',
- '[&_[data-lk-index]]:rounded-full [&_[data-lk-index]]:transition-colors [&_[data-lk-index]]:duration-150 [&_[data-lk-index]]:ease-linear [&_[data-lk-index]]:bg-transparent [&_[data-lk-index]]:data-[lk-highlighted=true]:bg-current',
- 'has-data-[lk-state=connecting]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=connecting]:[&_[data-lk-index]]:bg-current/10',
- 'has-data-[lk-state=initializing]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=initializing]:[&_[data-lk-index]]:bg-current/10',
- 'has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=listening]:[&_[data-lk-index]]:bg-current/10 has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300',
- 'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:[&_[data-lk-index]]:bg-current',
+ '**:data-lk-index:bg-current/10',
+ '**:data-lk-index:absolute **:data-lk-index:top-1/2 **:data-lk-index:left-1/2 **:data-lk-index:origin-bottom **:data-lk-index:-translate-x-1/2',
+ '**:data-lk-index:rounded-full **:data-lk-index:transition-colors **:data-lk-index:duration-150 **:data-lk-index:ease-linear **:data-lk-index:data-[lk-highlighted=true]:bg-current',
+ 'has-data-[lk-state=connecting]:**:data-lk-index:duration-300',
+ 'has-data-[lk-state=initializing]:**:data-lk-index:duration-300',
+ 'has-data-[lk-state=listening]:**:data-lk-index:duration-300 has-data-[lk-state=listening]:**:data-lk-index:duration-300',
+ 'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:**:data-lk-index:bg-current',
],
{
variants: {
diff --git a/components/agents-ui/agent-audio-visualizer-wave.tsx b/components/agents-ui/agent-audio-visualizer-wave.tsx
new file mode 100644
index 000000000..4b89088e0
--- /dev/null
+++ b/components/agents-ui/agent-audio-visualizer-wave.tsx
@@ -0,0 +1,328 @@
+'use client';
+
+import { type ComponentProps, useMemo } from 'react';
+import { type VariantProps, cva } from 'class-variance-authority';
+import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
+import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/components-react';
+import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy';
+import { useAgentAudioVisualizerWave } from '@/hooks/agents-ui/use-agent-audio-visualizer-wave';
+import { cn } from '@/lib/shadcn/utils';
+
+const DEFAULT_COLOR = '#1FD5F9';
+
+function hexToRgb(hexColor: string) {
+ try {
+ const rgbColor = hexColor.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
+
+ if (rgbColor) {
+ const [, r, g, b] = rgbColor;
+ const color = [r, g, b].map((c = '00') => parseInt(c, 16) / 255);
+
+ return color;
+ }
+ } catch (error) {
+ console.error(
+ `Invalid hex color '${hexColor}'.\nFalling back to default color '${DEFAULT_COLOR}'.`
+ );
+ }
+
+ return hexToRgb(DEFAULT_COLOR);
+}
+
+const shaderSource = `
+const float TAU = 6.28318530718;
+
+// Noise for dithering
+vec2 randFibo(vec2 p) {
+ p = fract(p * vec2(443.897, 441.423));
+ p += dot(p, p.yx + 19.19);
+ return fract((p.xx + p.yx) * p.xy);
+}
+
+// Luma for alpha
+float luma(vec3 color) {
+ return dot(color, vec3(0.299, 0.587, 0.114));
+}
+
+// Bell curve function for attenuation from center with rounded top
+float bellCurve(float distanceFromCenter, float maxDistance) {
+ float normalizedDistance = distanceFromCenter / maxDistance;
+ // Use cosine with high power for smooth rounded top
+ return pow(cos(normalizedDistance * (3.14159265359 / 4.0)), 16.0);
+}
+
+// Calculate the sine wave
+float oscilloscopeWave(float x, float centerX, float time) {
+ float relativeX = x - centerX;
+ float maxDistance = centerX;
+ float distanceFromCenter = abs(relativeX);
+
+ // Apply bell curve for amplitude attenuation
+ float bell = bellCurve(distanceFromCenter, maxDistance);
+
+ // Calculate wave with uniforms and bell curve attenuation
+ float wave = sin(relativeX * uFrequency + time * uSpeed) * uAmplitude * bell;
+
+ return wave;
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+ vec2 uv = fragCoord / iResolution.xy;
+ vec2 pos = uv - 0.5;
+
+ // Calculate center and positions
+ float centerX = 0.5;
+ float centerY = 0.5;
+ float x = uv.x;
+ float y = uv.y;
+
+ // Convert line width from pixels to UV space
+ // Use the average of width and height to handle aspect ratio
+ float pixelSize = 2.0 / (iResolution.x + iResolution.y);
+ float lineWidthUV = uLineWidth * pixelSize;
+ float smoothingUV = uSmoothing * pixelSize;
+
+ // Find minimum distance to the wave by sampling nearby points
+ // This gives us consistent line width without high-frequency artifacts
+ const int NUM_SAMPLES = 50; // Must be const for GLSL loop
+ float minDist = 1000.0;
+ float sampleRange = 0.02; // Range to search for closest point
+
+ for(int i = 0; i < NUM_SAMPLES; i++) {
+ float offset = (float(i) / float(NUM_SAMPLES - 1) - 0.5) * sampleRange;
+ float sampleX = x + offset;
+ float waveY = centerY + oscilloscopeWave(sampleX, centerX, iTime);
+
+ // Calculate distance from current pixel to this point on the wave
+ vec2 wavePoint = vec2(sampleX, waveY);
+ vec2 currentPoint = vec2(x, y);
+ float dist = distance(currentPoint, wavePoint);
+
+ minDist = min(minDist, dist);
+ }
+
+ // Solid line with smooth edges using minimum distance
+ float line = smoothstep(lineWidthUV + smoothingUV, lineWidthUV - smoothingUV, minDist);
+
+ // Calculate color position based on x position for gradient effect
+ float colorPos = x;
+ vec3 color = uColor;
+
+ // Apply line intensity
+ color *= line;
+
+ // Add dithering for smoother gradients
+ // color += (randFibo(fragCoord).x - 0.5) / 255.0;
+
+ // Calculate alpha based on line intensity
+ float alpha = line * uMix;
+
+ fragColor = vec4(color * uMix, alpha);
+}`;
+
+interface WaveShaderProps {
+ /**
+ * Class name
+ * @default ''
+ */
+ className?: string;
+ /**
+ * Speed of the oscilloscope
+ * @default 10
+ */
+ speed?: number;
+ /**
+ * Amplitude of the oscilloscope
+ * @default 0.02
+ */
+ amplitude?: number;
+ /**
+ * Frequency of the oscilloscope
+ * @default 20.0
+ */
+ frequency?: number;
+ /**
+ * Color of the oscilloscope
+ * @default '#1FD5F9'
+ */
+ color?: string;
+ /**
+ * Mix of the oscilloscope
+ * @default 1.0
+ */
+ mix?: number;
+ /**
+ * Line width of the oscilloscope in pixels
+ * @default 2.0
+ */
+ lineWidth?: number;
+ /**
+ * Blur of the oscilloscope in pixels
+ * @default 0.5
+ */
+ blur?: number;
+}
+
+function WaveShader({
+ speed = 10,
+ color = '#1FD5F9',
+ mix = 1.0,
+ amplitude = 0.02,
+ frequency = 20.0,
+ lineWidth = 2.0,
+ blur = 0.5,
+ ref,
+ className,
+ ...props
+}: WaveShaderProps & ComponentProps<'div'>) {
+ const rgbColor = useMemo(() => hexToRgb(color), [color]);
+
+ return (
+
+ {
+ console.error('Shader error:', error);
+ }}
+ onWarning={(warning) => {
+ console.warn('Shader warning:', warning);
+ }}
+ style={{ width: '100%', height: '100%' }}
+ />
+
+ );
+}
+
+WaveShader.displayName = 'WaveShader';
+
+export const AgentAudioVisualizerWaveVariants = cva(['aspect-square'], {
+ variants: {
+ size: {
+ icon: 'h-[24px]',
+ sm: 'h-[56px]',
+ md: 'h-[112px]',
+ lg: 'h-[224px]',
+ xl: 'h-[448px]',
+ },
+ },
+ defaultVariants: {
+ size: 'lg',
+ },
+});
+
+export interface AgentAudioVisualizerWaveProps {
+ /**
+ * The size of the visualizer.
+ * @defaultValue 'lg'
+ */
+ size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
+ /**
+ * The agent state.
+ * @defaultValue 'speaking'
+ */
+ state?: AgentState;
+ /**
+ * The color of the wave in hex format.
+ * @defaultValue '#1FD5F9'
+ */
+ color?: string;
+ /**
+ * The line width of the wave in pixels.
+ * @defaultValue 2.0
+ */
+ lineWidth?: number;
+ /**
+ * The blur of the wave in pixels.
+ * @defaultValue 0.5
+ */
+ blur?: number;
+ /**
+ * The audio track to visualize. Can be a local/remote audio track or a track reference.
+ */
+ audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
+ /**
+ * Additional CSS class names to apply to the container.
+ */
+ className?: string;
+}
+
+/**
+ * A wave-style audio visualizer that responds to agent state and audio levels.
+ * Displays an animated wave that reacts to the current agent state (connecting, thinking, speaking, etc.)
+ * and audio volume when speaking.
+ *
+ * @extends ComponentProps<'div'>
+ *
+ * @example ```tsx
+ *
+ * ```
+ */
+export function AgentAudioVisualizerWave({
+ size = 'lg',
+ state = 'speaking',
+ color,
+ lineWidth,
+ blur,
+ audioTrack,
+ className,
+ style,
+ ref,
+ ...props
+}: AgentAudioVisualizerWaveProps &
+ ComponentProps<'div'> &
+ VariantProps) {
+ const _lineWidth = useMemo(() => {
+ if (lineWidth !== undefined) {
+ return lineWidth;
+ }
+ switch (size) {
+ case 'icon':
+ case 'sm':
+ return 2;
+ default:
+ return 1;
+ }
+ }, [lineWidth, size]);
+
+ const { speed, amplitude, frequency, opacity } = useAgentAudioVisualizerWave({
+ state,
+ audioTrack,
+ });
+
+ return (
+
+ );
+}
diff --git a/components/agents-ui/agent-control-bar.tsx b/components/agents-ui/agent-control-bar.tsx
index 85dd2bde4..fc1702127 100644
--- a/components/agents-ui/agent-control-bar.tsx
+++ b/components/agents-ui/agent-control-bar.tsx
@@ -3,7 +3,7 @@
import { type ComponentProps, useEffect, useRef, useState } from 'react';
import { Track } from 'livekit-client';
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';
-import { motion } from 'motion/react';
+import { type MotionProps, motion } from 'motion/react';
import { useChat } from '@livekit/components-react';
import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button';
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control';
@@ -20,17 +20,17 @@ import {
} from '@/hooks/agents-ui/use-agent-control-bar';
import { cn } from '@/lib/shadcn/utils';
-const TOGGLE_VARIANT_1 = [
- '[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10',
- '[&_[data-state=off]_~_button]:bg-accent [&_[data-state=off]_~_button]:hover:bg-foreground/10',
- '[&_[data-state=off]]:border-border [&_[data-state=off]]:hover:border-foreground/12',
- '[&_[data-state=off]_~_button]:border-border [&_[data-state=off]_~_button]:hover:border-foreground/12',
- '[&_[data-state=off]]:text-destructive [&_[data-state=off]]:hover:text-destructive [&_[data-state=off]]:focus:text-destructive',
- '[&_[data-state=off]]:focus-visible:ring-foreground/12 [&_[data-state=off]]:focus-visible:border-ring',
- 'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10',
+const LK_TOGGLE_VARIANT_1 = [
+ 'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
+ 'data-[state=off]:[&_~_button]:bg-accent data-[state=off]:[&_~_button]:hover:bg-foreground/10',
+ 'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
+ 'data-[state=off]:[&_~_button]:border-border data-[state=off]:[&_~_button]:hover:border-foreground/12',
+ 'data-[state=off]:text-destructive data-[state=off]:hover:text-destructive data-[state=off]:focus:text-destructive',
+ 'data-[state=off]:focus-visible:ring-foreground/12 data-[state=off]:focus-visible:border-ring',
+ 'dark:data-[state=off]:[&_~_button]:bg-accent dark:data-[state=off]:[&_~_button]:hover:bg-foreground/10',
];
-const TOGGLE_VARIANT_2 = [
+const LK_TOGGLE_VARIANT_2 = [
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
@@ -41,7 +41,7 @@ const TOGGLE_VARIANT_2 = [
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
];
-const MOTION_PROPS = {
+const MOTION_PROPS: MotionProps = {
variants: {
hidden: {
height: 0,
@@ -71,13 +71,16 @@ function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentC
const inputRef = useRef(null);
const [isSending, setIsSending] = useState(false);
const [message, setMessage] = useState('');
+ const isDisabled = isSending || message.trim().length === 0;
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ const handleSend = async () => {
+ if (isDisabled) {
+ return;
+ }
try {
setIsSending(true);
- await onSend(message);
+ await onSend(message.trim());
setMessage('');
} catch (error) {
console.error(error);
@@ -86,7 +89,17 @@ function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentC
}
};
- const isDisabled = isSending || message.trim().length === 0;
+ const handleKeyDown = async (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ const handleButtonClick = async () => {
+ if (isDisabled) return;
+ await handleSend();
+ };
useEffect(() => {
if (chatOpen) return;
@@ -95,59 +108,61 @@ function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentC
}, [chatOpen]);
return (
-