Audio Visualizer
A canvas spectrum, waveform, ring or level meter driven by a real AnalyserNode — you own the mic, it only reads it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/audio-visualizer.jsonPrompt
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, anHTMLMediaElementor anAnalyserNodeand treats each as borrowed. It never requests permission, never plays audio, and never callstrack.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
AudioContextclosed on unmount; a borrowed analyser is left untouched; an element's graph is cached in aWeakMapand deliberately never closed, becausecreateMediaElementSourcebinds 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-motionthe 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.
Media Grid Selectable
A multi-select photo and video grid — Shift range, cmd/ctrl toggle, two-dimensional arrow keys, a selection cap, lazy thumbnails and type-icon fallbacks.
File Preview
A per-file preview card — image/video thumbnails, a working audio play bar, type icons with metadata, card/row/tile variants and leak-free object URLs.