Media

Audio Visualizer

A canvas spectrum, waveform, ring or level meter driven by a real AnalyserNode — you own the mic, it only reads it.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type AudioVisualizerVariant = "bars" | "wave" | "ring" | "level"

const TAU = Math.PI * 2
/** 3x screens quadruple fill cost for no visible gain on 2px bars. */
const MAX_DPR = 2
/** Ink opacity of the idle baseline — visible while silent, never competing with a live value. */
const TRACK_ALPHA = 0.45
/**
 * Display gain for the oscilloscope trace, applied through `tanh`: at 1x,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/audio-visualizer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AudioVisualizer" component: a canvas
driven by a real Web Audio AnalyserNode, with four render variants.

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Input props, all optional and all owned by the *consumer*:
  stream?: MediaStream | null — a mic capture or WebRTC track the caller
  already opened; audioElement?: HTMLMediaElement | null — an `<audio>` or
  `<video>` node the caller already renders; analyser?: AnalyserNode | null —
  a node from the caller's own graph. Resolution order is analyser > stream >
  audioElement; with none of them the component paints its static baseline.
- Render props: variant ("bars" | "wave" | "ring" | "level", default "bars");
  barCount (default 40) — spectrum bars, radial spokes, or meter segments
  depending on the variant, ignored by "wave"; fftSize (1024), smoothing
  (0.75), minDecibels (-85), maxDecibels (-25) — these configure only an
  analyser the component built itself; height (96, canvas height in CSS px);
  label ("Input level"); showLevel (true) — render the readout visibly rather
  than sr-only; levelIntervalMs (1000); onLevelChange?(level: 0..1).
- THE COMPONENT MUST NEVER CALL getUserMedia. Permission is the consumer's
  job (pair it with a pre-permission explainer). A docs site or a card that
  pops a mic prompt just by rendering is a bug, not a feature.

Behavior
- Ownership, the single most important rule:
  - stream → build your own AudioContext + MediaStreamAudioSourceNode +
    AnalyserNode. Connect source→analyser ONLY; connecting a microphone to
    `destination` echoes the mic back out of the speakers. On unmount or input
    change: disconnect both nodes and close() the context — and never call
    track.stop(). The stream belongs to the caller; stopping it would kill
    their mic/camera everywhere else in the app.
  - audioElement → an element can be passed to createMediaElementSource
    exactly once in its life, and closing that context silences the element
    permanently. So cache { context, source } in a module-level WeakMap keyed
    by the element, connect source→destination once (keeping playback
    audible), and give each mounted visualizer its own AnalyserNode branch
    (source.connect(myAnalyser)). Teardown removes only that branch
    (source.disconnect(myAnalyser), wrapped in try/catch) and deliberately
    leaves the cached context open. Several visualizers can therefore share
    one element.
  - analyser → borrowed. Do not write fftSize/smoothing/decibels on it, do not
    disconnect it, do not close its context.
  - AudioContext resume(): a context created outside a user gesture starts
    suspended. Call resume() at setup and again on the element's "play" event.
- Clamp every numeric prop before it reaches Web Audio, because these setters
  throw: fftSize is rounded to a power of two in 32..32768; smoothing to
  0..0.95 (1 freezes the data); barCount to 4..128; height to 24..640;
  levelIntervalMs to 200..10000; maxDecibels to -110..0 and minDecibels to at
  most maxDecibels - 10. Assign maxDecibels before minDecibels when the new
  max clears the node's current min, and the other way round otherwise —
  the setter rejects a max that isn't above the current min.
- Draw loop: one requestAnimationFrame loop; cancelAnimationFrame on unmount.
  Size the canvas backing store to min(devicePixelRatio, 2) and re-apply
  ctx.setTransform(dpr,0,0,dpr,0,0) after every resize (resizing resets the
  context), so all drawing code works in CSS pixels. Re-measure through a
  ResizeObserver on the canvas — observe() fires once immediately, which
  doubles as the initial sizing — wrapped in try/catch with a clientWidth
  fallback. A MutationObserver on <html> (class/style/data-theme) re-reads the
  colors and repaints the still frame on a theme flip.
- Spectrum bars bucket getByteFrequencyData over exponentially widening bin
  slices (bin 1 → 72% of the bins) and take the max per bucket: linear slices
  spend three quarters of the display on frequencies speech never reaches and
  make a live mic look dead. "wave" reads getByteTimeDomainData and draws the
  trace through tanh(sample * 2.4) — a flat 1x trace is invisible for speech
  near -30 dBFS, and a hard clamp would fake a flat-topped ceiling on hot
  input. "ring" is the same bucketed data as radial spokes; "level" is a
  segmented meter with a peak-hold marker that decays ~0.4/s.
- Level readout: RMS of the time-domain data → dB → normalized over a fixed
  -60..0 dBFS window. It is published on an interval (levelIntervalMs), NOT
  per frame, quantized to 5% steps, and only when the value actually changed;
  onLevelChange rides the same tick. A role="status" region that changed 60
  times a second would make a screen reader unusable.
- Silent / no-input state: every variant paints a recognisable rest state —
  bar stubs along the baseline, a flat centre trace, an empty ring, unlit
  segments — never an empty box, and the readout says "no source connected".
- prefers-reduced-motion (read via useSyncExternalStore, never at render
  time): the rAF loop is never started. bars/wave/ring hold the baseline and
  the numeric readout — forced visible, refreshed on the publish tick —
  carries the level; "level" repaints on that same tick so its meter can never
  contradict the number. The readout appends "· motion reduced" so a still
  canvas reads as a choice, not a breakage.
- Failure paths are shown, never swallowed: a stream with no audio track, an
  element already bound to another audio graph, or a browser with no
  AudioContext each render a text reason in the status line (destructive
  token). The failure is surfaced through the publish tick, so nothing sets
  state inside the effect body.

Rendering & styling
- Semantic tokens only. The canvas cannot use Tailwind classes, so read the
  tokens off the canvas's own computed style — getPropertyValue("--primary")
  for live values and getPropertyValue("--muted-foreground") for the idle
  baseline, falling back to computed `color` — and let alpha (~0.45) do the
  contrast work. Light and dark come for free, and a MutationObserver keeps
  them current.
- Container: rounded-lg border bg-muted/30 overflow-hidden with the clamped
  height as an inline style; canvas is `block size-full` and aria-hidden
  (it is decoration — the meaning lives in the text).
- Readout: font-mono text-xs tabular-nums, text-muted-foreground (or
  text-destructive on failure), role="status" aria-live="polite", and sr-only
  when showLevel is false.
- Merge className with cn() and spread the remaining props onto the root.

Customization levers
- Palette: swap "--primary" for "--chart-2" (fills only, never text) or read a
  second token to color the top 10% of the meter differently. Everything is
  one getPropertyValue call.
- Shape: bar width/gap ratio (0.66 of the slot), corner radius, the ring's
  inner-radius ratio (0.5 of the box) and the level meter's segment height
  (0.42 of the box) are single constants.
- Dynamics: WAVE_GAIN (tanh drive) and the -60..0 dBFS window set how loud
  "loud" looks — narrow the window for quiet lapel mics; raise the peak-hold
  decay for a snappier meter.
- Cadence: levelIntervalMs and the 5% quantum trade readout smoothness for
  screen-reader calm; the rAF loop is untouched by both.
- New variant: add a case to the paint switch — the sampling, sizing,
  ownership and reduced-motion plumbing are variant-agnostic.
- Symmetry: bars are bottom-anchored; mirroring them around the centre line
  is a two-line change in drawBars if you want the voice-memo look.

Concepts

  • Consumer-owned input — the component takes a MediaStream, an HTMLMediaElement or an AnalyserNode and treats each as borrowed. It never requests permission, never plays audio, and never calls track.stop(): stopping a stream you were merely handed would switch off the caller's microphone across their whole app.
  • Ownership boundary in teardown — the rule is symmetrical: close exactly what you opened. A stream graph is disconnected and its AudioContext closed on unmount; a borrowed analyser is left untouched; an element's graph is cached in a WeakMap and deliberately never closed, because createMediaElementSource binds an element for life and closing that context would mute it permanently.
  • Silent baseline as a first-class state — silence and "nothing connected" are drawn, not blank: bar stubs, a flat trace, an empty ring, unlit segments. A visualizer that goes empty is indistinguishable from one that crashed.
  • Throttled live region — the picture updates at 60fps, the announcement does not. The level is published on a timer, quantized to 5%, and only when it changed, so role="status" stays useful instead of becoming a stream of noise.
  • Reduced-motion degradation without loss of function — the animation is the decoration and the number is the function. With prefers-reduced-motion the loop never starts, the readout is forced visible, and the one variant whose meter is the reading keeps refreshing on the publish tick so image and number can't disagree.
  • Log-spaced spectrum buckets — bins are bucketed exponentially rather than linearly, because a linear split hands most of the width to frequencies human speech never reaches, and a live mic ends up looking dead across the right-hand half.

On This Page