shadimage
pipecat
componentaudio-visualizer-radialsource: ok · hit

Audio Visualizer Radial

Canvas-2D radial audio visualizer: a ring of spokes growing outward with mel-scale voice bands, sharing the kit visualizer lifecycle states (connecting, silent, speaking, thinking). Headless view for any MediaStreamTrack, plus a Pipecat-connected wrapper.

npx shadcn add "https://ui.pipecat.ai/r/audio-visualizer-radial.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@pipecat-ai/client-js@^1.13.1@pipecat-ai/client-react@^1.8.2

Files2 · source cached 10/8/2026, 12:21:33 PM

src/components/audio-visualizer-radial.tsx
"use client";

import { usePipecatClientMediaTrack } from "@pipecat-ai/client-react";
import { memo, useEffect, useRef } from "react";

import {
  createVisualizerAnalyser,
  createVoiceBands,
  readBand,
  resolveVisualizerColor,
  type VisualizerState,
} from "@/lib/visualizer";

export type { VisualizerState };

type ParticipantType = Parameters<typeof usePipecatClientMediaTrack>[1];

// Thinking's breath swells the ring by this fraction of its radius.
const BREATH = 0.1;

export interface AudioVisualizerRadialViewProps {
  /** Audio track to visualize. Renders the dim resting ring when null. */
  track?: MediaStreamTrack | null;
  /** Canvas background. Any CSS color, or "transparent". */
  backgroundColor?: string;
  /** Dot color. Supports "currentColor" (default) and "--css-var" names. */
  barColor?: string;
  /**
   * Color of the lit dots in the connecting and thinking animations; same
   * formats as barColor, which it defaults to.
   */
  accentColor?: string;
  /** Number of dots around the ring. */
  barCount?: number;
  /**
   * Dot diameter / bar thickness in px. Defaults to filling half the
   * ring's circumference (radius × π / barCount), so dots and gaps stay
   * balanced at any barCount or radius.
   */
  barWidth?: number;
  /** Max length a dot grows outward from the ring while speaking, in px. */
  barMaxLength?: number;
  /** Radius of the resting ring, in px. */
  radius?: number;
  /** Dot / bar end style. */
  barLineCap?: "round" | "square";
  /** Spectrum smoothing per frame (0–1, default 0.5); 1 disables smoothing. */
  barSpeed?: number;
  /** Opacity of unlit dots (default 0.1). Lit dots render at 1. */
  restingOpacity?: number;
  /**
   * Speed multiplier for both animated overrides (default 1): the
   * connecting pair and the thinking comet orbit at the same rate, one
   * lap every two seconds.
   */
  rotationSpeed?: number;
  /** Connecting takes precedence over thinking and ignores the audio track. */
  isConnecting?: boolean;
  /**
   * Override: the bot is working on a response — a comet (bright head,
   * fading tail) orbits the ring while the ring slowly breathes. The
   * track is ignored while set.
   */
  isThinking?: boolean;
  /**
   * Breaths per second for the thinking swell (default 0.2 — a
   * five-second cycle, ±10% of radius). 0 disables breathing.
   */
  breathingSpeed?: number;
  className?: string;
}

/** Mirrored mel bands avoid a seam around the ring. Connecting and thinking override track-derived state. */
export const AudioVisualizerRadialView = memo(
  function AudioVisualizerRadialView({
    track = null,
    backgroundColor = "transparent",
    barColor = "currentColor",
    accentColor,
    barCount = 24,
    barWidth,
    barMaxLength = 24,
    radius = 32,
    barLineCap = "round",
    barSpeed = 0.5,
    restingOpacity = 0.1,
    rotationSpeed = 1,
    isConnecting = false,
    isThinking = false,
    breathingSpeed = 0.2,
    className,
  }: AudioVisualizerRadialViewProps) {
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const resolvedBarColorRef = useRef<string>("black");
    const resolvedAccentColorRef = useRef<string>("black");
    const analyserRef = useRef<AnalyserNode | null>(null);
    const frequencyDataRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
    // Displayed length/alpha/accent-weight per dot, persisted across
    // renders so state changes ease from wherever the ring currently is.
    const displayRef = useRef<{ h: number; a: number; c: number }[]>([]);
    // Eased 0–1 breath weight, persisted the same way so the radius swell
    // ramps in and out instead of snapping.
    const breathRef = useRef(0);

    // Resolve CSS-flavored colors to concrete values the canvas can use.
    // className is a dependency because it can change what currentColor
    // resolves to.
    useEffect(() => {
      resolvedBarColorRef.current = resolveVisualizerColor(
        barColor,
        canvasRef.current,
      );
      resolvedAccentColorRef.current = resolveVisualizerColor(
        accentColor ?? barColor,
        canvasRef.current,
      );
    }, [barColor, accentColor, className]);

    // Audio pipeline — only recreated when the track changes. The
    // overrides are synthetic, so they skip the pipeline entirely.
    useEffect(() => {
      if (!track || isConnecting || isThinking) {
        analyserRef.current = null;
        frequencyDataRef.current = null;
        return;
      }

      const { analyser, dispose } = createVisualizerAnalyser(track);
      analyserRef.current = analyser;
      frequencyDataRef.current = new Uint8Array(analyser.frequencyBinCount);

      return () => {
        analyserRef.current = null;
        frequencyDataRef.current = null;
        dispose();
      };
    }, [track, isConnecting, isThinking]);

    useEffect(() => {
      if (!canvasRef.current) return;

      const canvas = canvasRef.current;
      const dotWidth = barWidth ?? (radius * Math.PI) / barCount;
      // Line caps extend segments by half the dot width; the canvas fits
      // whichever reaches further, grown bars or the breath's swell.
      const capRadius = dotWidth / 2;
      const growth = Math.max(barMaxLength, BREATH * radius);
      const side = 2 * (radius + growth + capRadius);
      const center = side / 2;
      const scaleFactor = 2;

      canvas.width = side * scaleFactor;
      canvas.height = side * scaleFactor;
      canvas.style.width = `${side}px`;
      canvas.style.height = `${side}px`;

      const canvasCtx = canvas.getContext("2d")!;
      canvasCtx.lineCap = barLineCap;
      canvasCtx.scale(scaleFactor, scaleFactor);

      // Half the bands, mirrored: dot i and dot barCount−i share a band,
      // so both directions around the ring run low→high and the wrap has
      // no seam. Low frequencies at the top, sibilance at the bottom.
      const sampleRate = analyserRef.current?.context.sampleRate ?? 48000;
      const frequencyBinCount = analyserRef.current?.frequencyBinCount ?? 512;
      const bands = createVoiceBands(
        Math.floor(barCount / 2) + 1,
        sampleRate,
        frequencyBinCount,
      );

      // Static per-dot geometry, clockwise from the top of the ring.
      const dots = Array.from({ length: barCount }, (_, i) => {
        const angle = (i / barCount) * Math.PI * 2;
        return {
          angle,
          ux: Math.cos(angle - Math.PI / 2),
          uy: Math.sin(angle - Math.PI / 2),
          band: bands[Math.min(i, barCount - i)]!,
        };
      });

      // Displayed values persist across effect restarts (mode/track
      // changes) so transitions ease from the current picture; a barCount
      // change (or a hot reload of the entry shape) resets them.
      const display = displayRef.current;
      if (display.length !== barCount || display[0]?.c === undefined) {
        display.length = 0;
        for (let i = 0; i < barCount; i++) display.push({ h: 0, a: 1, c: 0 });
      }

      // Displayed values ease toward per-frame targets, so mode changes
      // blend (~120ms) instead of snapping. Live audio instead chases at
      // barSpeed — the configurable speaking responsiveness.
      const EASE = 0.28;
      const chase = Math.min(1, Math.max(0.05, barSpeed));
      const rest = Math.min(1, Math.max(0, restingOpacity));
      // Both overrides orbit at the same angular rate, so switching
      // states never changes pace. Thinking drags a tail a third of the
      // ring long, fading quadratically; connecting glows over a tight
      // window around each of its two opposed dots.
      const revPerSec = 0.5 * rotationSpeed;
      const TAIL = (Math.PI * 2) / 3;
      const glowWindow = 1.5 * ((Math.PI * 2) / barCount);
      const breathsPerSec = Math.max(0, breathingSpeed);
      const circDist = (a: number, b: number) => {
        const d = Math.abs(a - b) % (Math.PI * 2);
        return d > Math.PI ? Math.PI * 2 - d : d;
      };

      let rafId: number;

      function drawSpectrum() {
        const analyser = analyserRef.current;
        const frequencyData = frequencyDataRef.current;
        const resolvedBarColor = resolvedBarColorRef.current;
        const time = performance.now() / 1000;

        canvasCtx.clearRect(0, 0, side, side);
        canvasCtx.fillStyle = backgroundColor;
        canvasCtx.fillRect(0, 0, side, side);

        let spectrum: Uint8Array<ArrayBuffer> | null = null;
        if (!isConnecting && !isThinking && analyser && frequencyData) {
          analyser.getByteFrequencyData(frequencyData);
          spectrum = frequencyData;
        }

        // One shared head position drives both overrides; connecting
        // mirrors it to the opposite side of the ring.
        const headAngle =
          isConnecting || isThinking ? time * revPerSec * Math.PI * 2 : 0;

        let settled = true;

        // Breath: a slow radial sine while thinking, weighted by an
        // eased ramp so the radius never snaps when the state flips.
        const breathTarget = isThinking ? 1 : 0;
        breathRef.current += (breathTarget - breathRef.current) * EASE;
        if (Math.abs(breathTarget - breathRef.current) > 0.01) settled = false;
        const ringRadius =
          radius +
          breathRef.current *
            BREATH *
            radius *
            Math.sin(time * breathsPerSec * Math.PI * 2);

        for (let i = 0; i < barCount; i++) {
          const { angle, ux, uy, band } = dots[i]!;
          let targetLength = 0;
          // Glow is the accent overlay's strength: 0 rests, 1 is fully
          // lit, fades in between.
          let glow = 0;

          if (isConnecting) {
            const toHead = circDist(angle, headAngle);
            const nearest = Math.min(toHead, Math.PI - toHead);
            const w = Math.max(0, 1 - nearest / glowWindow);
            glow = w * w;
          } else if (isThinking) {
            const behind =
              (((headAngle - angle) % (Math.PI * 2)) + Math.PI * 2) %
              (Math.PI * 2);
            const w = behind < TAIL ? 1 - behind / TAIL : 0;
            glow = w * w;
          } else if (spectrum) {
            targetLength = (readBand(spectrum, band) / 255) * barMaxLength;
          }
          // Silent (no overrides, no analyser): a dim resting ring.
          const targetAlpha = spectrum ? 1 : rest;

          const d = display[i]!;
          d.h += (targetLength - d.h) * (spectrum ? chase : EASE);
          d.a += (targetAlpha - d.a) * EASE;
          d.c += (glow - d.c) * EASE;
          if (
            Math.abs(targetLength - d.h) > 0.5 ||
            Math.abs(targetAlpha - d.a) > 0.02 ||
            Math.abs(glow - d.c) > 0.02
          ) {
            settled = false;
          }

          // One stroked segment covers every length — the line cap
          // renders the floored, near-zero length as the resting dot.
          // Base layer in barColor, accent overlaid at the glow's
          // strength, so accent fades to transparent rather than
          // blending toward the bar color.
          const len = Math.max(d.h, 0.1);
          canvasCtx.beginPath();
          canvasCtx.moveTo(center + ux * ringRadius, center + uy * ringRadius);
          canvasCtx.lineTo(
            center + ux * (ringRadius + len),
            center + uy * (ringRadius + len),
          );
          canvasCtx.lineWidth = dotWidth;
          canvasCtx.globalAlpha = d.a;
          canvasCtx.strokeStyle = resolvedBarColor;
          canvasCtx.stroke();
          if (d.c > 0.01) {
            canvasCtx.globalAlpha = d.c;
            canvasCtx.strokeStyle = resolvedAccentColorRef.current;
            canvasCtx.stroke();
          }
          canvasCtx.globalAlpha = 1;
        }

        // Animated modes and unfinished transitions keep the loop alive;
        // a settled silent frame stops it (the effect restarts on
        // changes).
        if (isConnecting || isThinking || spectrum || !settled) {
          rafId = requestAnimationFrame(drawSpectrum);
        }
      }

      rafId = requestAnimationFrame(drawSpectrum);

      return () => {
        cancelAnimationFrame(rafId);
      };
    }, [
      backgroundColor,
      // Colors are drawn from refs, but a settled silent frame stops the
      // loop — restart it when they change so it repaints. The resolution
      // effect above runs first (declaration order), so the refs are
      // fresh.
      barColor,
      accentColor,
      barCount,
      barLineCap,
      barMaxLength,
      barSpeed,
      barWidth,
      radius,
      restingOpacity,
      rotationSpeed,
      breathingSpeed,
      track,
      isConnecting,
      isThinking,
    ]);

    return (
      <canvas
        ref={canvasRef}
        data-slot="audio-visualizer-radial"
        data-state={
          (isConnecting
            ? "connecting"
            : isThinking
              ? "thinking"
              : track
                ? "speaking"
                : "silent") satisfies VisualizerState
        }
        style={{ display: "block" }}
        className={className}
      />
    );
  },
);

export interface AudioVisualizerRadialProps extends Omit<
  AudioVisualizerRadialViewProps,
  "track"
> {
  /** Which participant's audio to visualize. */
  participantType: ParticipantType;
}

/** Requires PipecatClientProvider; silence and speech follow the participant track. */
export function AudioVisualizerRadial({
  participantType,
  ...props
}: AudioVisualizerRadialProps) {
  const track = usePipecatClientMediaTrack("audio", participantType);
  return <AudioVisualizerRadialView track={track} {...props} />;
}
src/lib/visualizer.ts
/** Track-derived silent/speaking states; connecting/thinking are explicit overrides. */
export type VisualizerState = "connecting" | "silent" | "speaking" | "thinking";

/** One mel-spaced analyser band: its FFT bin range plus tilt boost. */
export interface VisualizerBand {
  startBin: number;
  endBin: number;
  /** dB-space boost countering speech's high-frequency rolloff. */
  tiltBoost: number;
}

/** Call dispose to release the AudioContext; smoothing here only reduces FFT jitter. */
export function createVisualizerAnalyser(track: MediaStreamTrack): {
  analyser: AnalyserNode;
  dispose: () => void;
} {
  const audioContext = new AudioContext();
  const source = audioContext.createMediaStreamSource(new MediaStream([track]));
  const analyser = audioContext.createAnalyser();
  analyser.fftSize = 1024;
  analyser.smoothingTimeConstant = 0.6;
  source.connect(analyser);
  return { analyser, dispose: () => void audioContext.close() };
}

/** Mel bands cover 200 Hz–8 kHz. A per-octave boost compensates speech rolloff so low bands do not dominate. */
export function createVoiceBands(
  bandCount: number,
  sampleRate: number,
  frequencyBinCount: number,
): VisualizerBand[] {
  const nyquist = sampleRate / 2;
  const MIN_FREQ = 200;
  const MAX_FREQ = 8000;
  const toMel = (hz: number) => 2595 * Math.log10(1 + hz / 700);
  const toHz = (mel: number) => 700 * (Math.pow(10, mel / 2595) - 1);
  const melMin = toMel(MIN_FREQ);
  const melStep = (toMel(MAX_FREQ) - melMin) / bandCount;

  const TILT_DB_PER_OCTAVE = 4;
  const BYTES_PER_DB = 255 / 70;
  const refCenter = Math.sqrt(toHz(melMin) * toHz(melMin + melStep));

  return Array.from({ length: bandCount }, (_, i) => {
    const startFreq = toHz(melMin + i * melStep);
    const endFreq = toHz(melMin + (i + 1) * melStep);
    const startBin = Math.round(
      (startFreq / nyquist) * (frequencyBinCount - 1),
    );
    // Every band covers at least one bin, so high band counts can't
    // round a band into permanent silence.
    const endBin = Math.max(
      Math.round((endFreq / nyquist) * (frequencyBinCount - 1)),
      startBin + 1,
    );
    const center = Math.sqrt(startFreq * endFreq);
    const tiltBoost =
      TILT_DB_PER_OCTAVE * BYTES_PER_DB * Math.log2(center / refCenter);
    return { startBin, endBin, tiltBoost };
  });
}

/**
 * Averages a band's FFT bins and applies its tilt boost, returning 0–255.
 * The boost fades in with signal strength so the noise floor near
 * minDecibels isn't amplified into standing bars.
 */
export function readBand(spectrum: Uint8Array, band: VisualizerBand): number {
  let sum = 0;
  for (let j = band.startBin; j < band.endBin; j++) sum += spectrum[j] ?? 0;
  const value = sum / (band.endBin - band.startBin);
  return Math.min(255, value + band.tiltBoost * Math.min(1, value / 32));
}

/**
 * Resolves a visualizer color prop to a concrete canvas color:
 * "currentColor" resolves from the given element, "--css-var" names from
 * the document root (where the kit's theme tokens live).
 */
export function resolveVisualizerColor(
  color: string,
  element: Element | null,
): string {
  if (!color) return "black";
  if (color === "currentColor") {
    return element ? getComputedStyle(element).color || "black" : "black";
  }
  if (color.startsWith("--")) {
    return (
      getComputedStyle(document.documentElement)
        .getPropertyValue(color)
        .trim() || "black"
    );
  }
  return color;
}

More from pipecat

Browse all components in this library →