shadimage
agents-ui
componentagent-audio-visualizer-wavesource: ok · hit

Agent Audio Visualizer Wave

A wave visualizer for audio tracks.

npx shadcn add "https://livekit.com/ui/r/agent-audio-visualizer-wave.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
livekit-client@^2.0.0@livekit/components-react@^2.0.0class-variance-authority

Registry dependencies

utils@agents-ui/react-shader-toy

Files2 · source cached 10/8/2026, 8:41:51 PM

components/agents-ui/agent-audio-visualizer-wave.tsx
'use client';

import { useMemo, type ComponentProps } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
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/utils';
import type { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';

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 {
    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));
}

// 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);
}

// 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);

  vec3 color = uColor;
  if(abs(uColorShift) > 0.01) {
    // Keep the center 50% at base color, then ramp shift across outer 25% on each side.
    float centerBandHalfWidth = 0.2;
    float edgeBandWidth = 0.5;
    float distanceFromCenter = abs(x - centerX);
    float edgeFactor = clamp((distanceFromCenter - centerBandHalfWidth) / edgeBandWidth, 0.0, 1.0);
    vec3 hsv = rgb2hsv(color);
    // Hue shift is zero in the center band and strongest at far edges.
    hsv.x = fract(hsv.x + edgeFactor * uColorShift * 0.3);
    color = hsv2rgb(hsv);
  }

  // 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 in hexidecimal format.
   * @default '#1FD5F9'
   */
  color?: `#${string}`;
  /**
   * Hue shift amount applied toward the outside of the wave. Center remains at the base color.
   * @default 0.05
   */
  colorShift?: number;
  /**
   * 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',
  colorShift = 0.05,
  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 (
    <div ref={ref} className={className} {...props}>
      <ReactShaderToy
        fs={shaderSource}
        devicePixelRatio={globalThis.devicePixelRatio ?? 1}
        uniforms={{
          uSpeed: { type: '1f', value: speed },
          uAmplitude: { type: '1f', value: amplitude },
          uFrequency: { type: '1f', value: frequency },
          uMix: { type: '1f', value: mix },
          uLineWidth: { type: '1f', value: lineWidth },
          uSmoothing: { type: '1f', value: blur },
          uColor: { type: '3fv', value: rgbColor },
          uColorShift: { type: '1f', value: colorShift },
        }}
        onError={(error) => {
          console.error('Shader error:', error);
        }}
        onWarning={(warning) => {
          console.warn('Shader warning:', warning);
        }}
        style={{ width: '100%', height: '100%' }}
      />
    </div>
  );
}

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 hexidecimal format.
   * @defaultValue '#1FD5F9'
   */
  color?: `#${string}`;
  /**
   * The color shift of the wave. Higher values increase hue variation toward the edges.
   * @defaultValue 0.05
   */
  colorShift?: number;
  /**
   * 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;
  /**
   * Volume value (0-1) to use instead of the value computed from the audioTrack.
   */
  volume?: number;
  /**
   * 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
 * <AgentAudioVisualizerWave
 *   size="lg"
 *   state="speaking"
 *   color="#1FD5F9"
 *   colorShift={0.3}
 *   lineWidth={2}
 *   blur={0.5}
 *   audioTrack={audioTrack}
 * />
 * ```
 */
export function AgentAudioVisualizerWave({
  size = 'lg',
  state = 'speaking',
  color,
  colorShift = 0.05,
  lineWidth,
  blur,
  audioTrack,
  volume,
  className,
  ref,
  ...props
}: AgentAudioVisualizerWaveProps &
  ComponentProps<'div'> &
  VariantProps<typeof AgentAudioVisualizerWaveVariants>) {
  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,
    volume,
  });

  return (
    <WaveShader
      ref={ref}
      data-lk-state={state}
      speed={speed}
      color={color}
      colorShift={colorShift}
      mix={opacity}
      amplitude={amplitude}
      frequency={frequency}
      lineWidth={_lineWidth}
      blur={blur}
      className={cn(
        AgentAudioVisualizerWaveVariants({ size }),
        'mask-[linear-gradient(90deg,transparent_0%,black_20%,black_80%,transparent_100%)]',
        className,
      )}
      {...props}
    />
  );
}
hooks/agents-ui/use-agent-audio-visualizer-wave.ts
import { useRef, useState, useEffect, useCallback } from 'react';
import {
  type AnimationPlaybackControlsWithThen,
  type ValueAnimationTransition,
  animate,
  useMotionValue,
  useMotionValueEvent,
} from 'motion/react';
import {
  type AgentState,
  type TrackReference,
  type TrackReferenceOrPlaceholder,
  useTrackVolume,
} from '@livekit/components-react';
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';

const DEFAULT_SPEED = 5;
const DEFAULT_AMPLITUDE = 0.025;
const DEFAULT_FREQUENCY = 10;
const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.2, ease: 'easeOut' };

function useAnimatedValue<T>(initialValue: T) {
  const [value, setValue] = useState(initialValue);
  const motionValue = useMotionValue(initialValue);
  const controlsRef = useRef<AnimationPlaybackControlsWithThen | null>(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, controls: controlsRef, animate: animateFn };
}

interface UseAgentAudioVisualizerWaveAnimatorArgs {
  state?: AgentState;
  audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
  volume?: number;
}

export function useAgentAudioVisualizerWave({
  state,
  audioTrack,
  volume: volumeProp,
}: UseAgentAudioVisualizerWaveAnimatorArgs) {
  const [speed, setSpeed] = useState(DEFAULT_SPEED);
  const { value: amplitude, animate: animateAmplitude } = useAnimatedValue(DEFAULT_AMPLITUDE);
  const { value: frequency, animate: animateFrequency } = useAnimatedValue(DEFAULT_FREQUENCY);
  const { value: opacity, animate: animateOpacity } = useAnimatedValue(1.0);

  const trackVolume = useTrackVolume(audioTrack as TrackReference, {
    fftSize: 512,
    smoothingTimeConstant: 0.55,
  });
  const volume = volumeProp ?? trackVolume;

  useEffect(() => {
    switch (state) {
      case 'disconnected':
        setSpeed(DEFAULT_SPEED);
        animateAmplitude(0, DEFAULT_TRANSITION);
        animateFrequency(0, DEFAULT_TRANSITION);
        animateOpacity(1.0, DEFAULT_TRANSITION);
        return;
      case 'listening':
        setSpeed(DEFAULT_SPEED);
        animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
        animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
        animateOpacity([1.0, 0.3], {
          duration: 0.75,
          repeat: Infinity,
          repeatType: 'mirror',
        });
        return;
      case 'thinking':
      case 'connecting':
      case 'initializing':
        setSpeed(DEFAULT_SPEED * 4);
        animateAmplitude(DEFAULT_AMPLITUDE / 4, DEFAULT_TRANSITION);
        animateFrequency(DEFAULT_FREQUENCY * 4, DEFAULT_TRANSITION);
        animateOpacity([1.0, 0.3], {
          duration: 0.4,
          repeat: Infinity,
          repeatType: 'mirror',
        });
        return;
      case 'speaking':
      default:
        setSpeed(DEFAULT_SPEED * 2);
        animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION);
        animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION);
        animateOpacity(1.0, DEFAULT_TRANSITION);
        return;
    }
  }, [state, setSpeed, animateAmplitude, animateFrequency, animateOpacity]);

  useEffect(() => {
    if (state === 'speaking') {
      animateAmplitude(0.015 + 0.4 * volume, { duration: 0 });
      animateFrequency(20 + 60 * volume, { duration: 0 });
    }
  }, [state, volume, animateAmplitude, animateFrequency]);

  return {
    speed,
    amplitude,
    frequency,
    opacity,
  };
}

More from agents-ui

Browse all components in this library →