---
name: audio-reactive-visualizer
description: Use when you want "Energetic, musical, immersive" - Animates visuals based on music, voice, or sound input.
---

# Audio Reactive Visualizer

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Energetic, musical, immersive
>
> **Best use:** Music sites, live visuals, demos
>
> **Ratings:** Professional 2/5 - Casual 5/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

An audio-reactive visualizer turns live sound into motion: an `AnalyserNode` runs a real-time FFT on an audio signal, and every animation frame you read its frequency (or time-domain) data and draw it to a `<canvas>` — frequency bars, a radial burst, an oscilloscope waveform. Use it on music sites, radio players, live-stream overlays and launch demos where the visuals should *feel* the beat. Because browsers block autoplay, the audio (and therefore the animation) must be kicked off by a user gesture — here a **▶ Play** button. The demo needs no media file at all: it synthesises a short techno loop from oscillators, so the analyser always has full-spectrum content to react to.

## Dependencies / CDN

**None — pure Web Audio API + Canvas 2D + vanilla JS.** The demo only loads two display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

```html
<div class="arv-stage">
  <!-- mode switch -->
  <div class="arv-modes">
    <button class="arv-mode is-active" data-mode="spectrum">Spectrum</button>
    <button class="arv-mode" data-mode="radial">Radial</button>
    <button class="arv-mode" data-mode="wave">Wave</button>
  </div>

  <!-- the visualizer surface -->
  <div class="arv-screen">
    <canvas class="arv-canvas" role="img" aria-label="Live audio spectrum"></canvas>
    <p class="arv-note" hidden>Reduced motion is on — showing a static spectrum.</p>
  </div>

  <!-- transport: the user gesture that starts audio -->
  <button class="arv-play" aria-pressed="false" aria-label="Play">▶</button>
</div>
```

## CSS

```css
.arv-stage{position:relative;display:flex;flex-direction:column;border-radius:26px;overflow:hidden;
  background:radial-gradient(125% 95% at 50% -10%,#1b1138,#0d0820 52%,#070411);color:#eef0ff;
  font-family:'Sora',system-ui,sans-serif}

/* the canvas fills a contained, rounded screen — never width:100vw */
.arv-screen{position:relative;flex:1;margin:0 16px;min-height:296px;border-radius:18px;overflow:hidden;
  background:linear-gradient(180deg,rgba(12,9,26,.55),rgba(7,5,16,.9));border:1px solid rgba(255,255,255,.07)}
.arv-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}

.arv-play{width:56px;height:56px;border:0;border-radius:50%;cursor:pointer;color:#0a0712;
  background:linear-gradient(135deg,#22e6c8,#7b2cff 52%,#ff2d8a);
  box-shadow:0 12px 30px -8px rgba(123,44,255,.75);transition:transform .18s,box-shadow .18s}
.arv-play:hover{transform:translateY(-2px) scale(1.05);box-shadow:0 18px 40px -8px rgba(255,45,138,.8)}
.arv-play:focus-visible{outline:2px solid #22e6c8;outline-offset:3px}
```

## JavaScript

The core: build an `AudioContext` on the Play gesture, route a synth → `master` gain → `analyser` → `destination`, then read the analyser every frame and draw it. (The live demo layers peak-hold caps, a reflection, mute and a progress scrubber on top of this same core.)

```js
const stage  = document.querySelector('.arv-stage');
const cvs    = stage.querySelector('.arv-canvas');
const g      = cvs.getContext('2d');
const playBtn= stage.querySelector('.arv-play');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

const BARS = 72;
let mode = 'spectrum', W = 0, H = 0, grad = null, angle = 0;

/* --- crisp, responsive canvas (devicePixelRatio aware) --- */
function resize(){
  const dpr = Math.min(devicePixelRatio || 1, 2), r = cvs.getBoundingClientRect();
  W = r.width; H = r.height;
  cvs.width = W * dpr; cvs.height = H * dpr;
  g.setTransform(dpr, 0, 0, dpr, 0, 0);
  grad = g.createLinearGradient(0, H, 0, 0);
  grad.addColorStop(0, '#5b2cff'); grad.addColorStop(.42, '#ff3d9a');
  grad.addColorStop(.78, '#22e6c8'); grad.addColorStop(1, '#eafcff');
  if (!playing) drawStatic();
}

/* --- audio graph + a tiny synthesised techno loop (no audio file) --- */
let actx, analyser, master, freq, timeArr, bins, noiseBuf;
let playing = false, raf = 0, timer = 0, next = 0, step = 0;
const SPB = 60 / 124;                                   // seconds per beat @124 BPM
const nf = s => 110 * Math.pow(2, s / 12);              // semitones from A2

function ensureCtx(){
  if (actx) return;
  actx = new (window.AudioContext || window.webkitAudioContext)();
  master = actx.createGain(); master.gain.value = 0.0001;
  analyser = actx.createAnalyser();
  analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.82;
  master.connect(analyser); analyser.connect(actx.destination);   // analyser passes audio through
  bins = analyser.frequencyBinCount;
  freq = new Uint8Array(bins);
  timeArr = new Uint8Array(analyser.fftSize);
  const n = actx.sampleRate * 0.4;                                // noise buffer for hi-hats
  noiseBuf = actx.createBuffer(1, n, actx.sampleRate);
  const d = noiseBuf.getChannelData(0);
  for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
}
function voice(f, t, dur, type, peak, cut){                       // pitched envelope, optional lowpass
  const o = actx.createOscillator(); o.type = type; o.frequency.setValueAtTime(f, t);
  const gn = actx.createGain();
  gn.gain.setValueAtTime(0.0001, t);
  gn.gain.exponentialRampToValueAtTime(peak, t + 0.012);
  gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
  if (cut){ const bq = actx.createBiquadFilter(); bq.type = 'lowpass'; bq.frequency.value = cut; o.connect(bq); bq.connect(gn); }
  else o.connect(gn);
  gn.connect(master); o.start(t); o.stop(t + dur + 0.03);
}
function kick(t){
  const o = actx.createOscillator(); o.type = 'sine';
  o.frequency.setValueAtTime(150, t); o.frequency.exponentialRampToValueAtTime(48, t + 0.12);
  const gn = actx.createGain(); gn.gain.setValueAtTime(0.85, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.2);
  o.connect(gn); gn.connect(master); o.start(t); o.stop(t + 0.22);
}
function hat(t, peak){
  const s = actx.createBufferSource(); s.buffer = noiseBuf;
  const bq = actx.createBiquadFilter(); bq.type = 'highpass'; bq.frequency.value = 7200;
  const gn = actx.createGain(); gn.gain.setValueAtTime(peak, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.05);
  s.connect(bq); bq.connect(gn); gn.connect(master); s.start(t); s.stop(t + 0.06);
}
const ROOTS = [0, 0, -4, -2], ARP = [12, 15, 19, 24];   // A A F G  +  minor arpeggio (octave up)
function playStep(s, t){
  const inBar = s % 16, root = ROOTS[Math.floor(s / 16) % 4];
  if (inBar % 4 === 0) kick(t);
  if (inBar % 4 === 2) hat(t, 0.30);
  if (inBar % 2 === 1) hat(t, 0.12);
  if (inBar % 2 === 0) voice(nf(root - 12), t, SPB * 0.42, 'sawtooth', 0.34, 900);   // bass
  voice(nf(root + ARP[inBar % ARP.length]), t, SPB * 0.30, 'square', 0.12, 4500);    // arp
}
function scheduler(){                                   // look-ahead scheduling = jitter-free timing
  while (next < actx.currentTime + 0.12){ playStep(step, next); next += SPB / 4; step++; }
}

function start(){
  ensureCtx();
  if (actx.state === 'suspended') actx.resume();
  const now = actx.currentTime; next = now + 0.06; step = 0;
  master.gain.setValueAtTime(0.0001, now);
  master.gain.exponentialRampToValueAtTime(0.22, now + 0.25);     // fade in (avoids a click)
  timer = setInterval(scheduler, 25);
  playing = true; playBtn.setAttribute('aria-pressed', 'true');
  cancelAnimationFrame(raf); raf = requestAnimationFrame(frame);
}
function stop(){
  const now = actx.currentTime;
  master.gain.cancelScheduledValues(now);
  master.gain.setValueAtTime(Math.max(master.gain.value, 0.0001), now);
  master.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);
  clearInterval(timer); playing = false; playBtn.setAttribute('aria-pressed', 'false');
  setTimeout(() => { if (!playing){ cancelAnimationFrame(raf); drawStatic(); } }, 230);
}

/* --- turn ~640 FFT bins into 72 perceptual (log-ish) bars --- */
function sampleBars(){
  const out = new Array(BARS), usable = Math.floor(bins * 0.62);
  for (let i = 0; i < BARS; i++){
    const lo = Math.floor(usable * Math.pow(i / BARS, 1.6));
    const hi = Math.max(lo + 1, Math.floor(usable * Math.pow((i + 1) / BARS, 1.6)));
    let m = 0; for (let j = lo; j < hi && j < usable; j++) if (freq[j] > m) m = freq[j];
    out[i] = Math.pow(m / 255, 0.92);
  }
  return out;
}

/* --- three renderers --- */
function drawSpectrum(v){
  g.clearRect(0, 0, W, H);
  const gap = Math.max(1.5, W / (BARS * 7)), bw = (W - (BARS - 1) * gap) / BARS, base = H - 1;
  g.beginPath();
  for (let i = 0; i < BARS; i++){
    const h = Math.max(2, v[i] * H * 0.84), x = i * (bw + gap);
    g.roundRect ? g.roundRect(x, base - h, bw, h, [4, 4, 1, 1]) : g.rect(x, base - h, bw, h);
  }
  g.fillStyle = grad; g.fill();
}
function drawRadial(v){
  g.clearRect(0, 0, W, H);
  const cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.16;
  angle += 0.0026;
  g.save(); g.globalCompositeOperation = 'lighter'; g.lineCap = 'round';
  g.lineWidth = Math.max(2, (2 * Math.PI * R) / BARS * 0.6);
  for (let i = 0; i < BARS; i++){
    const a = angle + (i / BARS) * Math.PI * 2, len = R * 0.18 + v[i] * Math.min(W, H) * 0.3;
    g.strokeStyle = `hsl(${282 - v[i] * 120},92%,${56 + v[i] * 16}%)`;
    g.beginPath();
    g.moveTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
    g.lineTo(cx + Math.cos(a) * (R + len), cy + Math.sin(a) * (R + len));
    g.stroke();
  }
  g.restore();
}
function drawWave(){
  g.clearRect(0, 0, W, H);
  const lg = g.createLinearGradient(0, 0, W, 0);
  lg.addColorStop(0, '#22e6c8'); lg.addColorStop(.5, '#7b2cff'); lg.addColorStop(1, '#ff3d9a');
  g.save(); g.globalCompositeOperation = 'lighter';
  g.strokeStyle = lg; g.lineWidth = 2.4; g.shadowBlur = 16; g.shadowColor = 'rgba(123,44,255,.7)';
  g.beginPath();
  const n = timeArr.length, st = Math.max(1, Math.floor(n / W));
  for (let i = 0; i < n; i += st){
    const x = i / n * W, y = H / 2 + ((timeArr[i] - 128) / 128) * H * 0.36;
    i === 0 ? g.moveTo(0, y) : g.lineTo(x, y);
  }
  g.stroke(); g.restore();
}
function drawStatic(){                                  // resting / reduced-motion frame
  const v = []; for (let i = 0; i < BARS; i++){ const t = i / (BARS - 1);
    v[i] = Math.max(0.04, (0.12 + Math.pow(Math.sin(t * Math.PI), 0.62) * 0.5) * (1 - t * 0.28)); }
  if (mode === 'radial') drawRadial(v); else if (mode === 'wave') return; else drawSpectrum(v);
}

function frame(){
  raf = requestAnimationFrame(frame);
  if (mode === 'wave'){ analyser.getByteTimeDomainData(timeArr); drawWave(); }
  else { analyser.getByteFrequencyData(freq); const v = sampleBars();
         mode === 'radial' ? drawRadial(v) : drawSpectrum(v); }
}

/* --- wiring + reduced-motion guard (no auto-run, static bars) --- */
playBtn.addEventListener('click', () => { if (reduce) return; playing ? stop() : start(); });
stage.querySelectorAll('.arv-mode').forEach(b => b.addEventListener('click', () => {
  mode = b.dataset.mode;
  stage.querySelectorAll('.arv-mode').forEach(x => x.classList.toggle('is-active', x === b));
  if (!playing) drawStatic();
}));
if (reduce){ playBtn.disabled = true; stage.querySelector('.arv-note').hidden = false; }
new ResizeObserver(resize).observe(stage.querySelector('.arv-screen'));
requestAnimationFrame(resize);
```

## How it works

- **User gesture unlocks audio.** Browsers refuse to start an `AudioContext` (or autoplay sound) without a real interaction, so everything is created inside the Play button's `click` handler.
- **Signal graph:** `synth voices → master gain → AnalyserNode → destination`. The `AnalyserNode` is a pass-through — it analyses *and* forwards the audio — so a single node both feeds the visuals and the speakers.
- **Synthesised source:** instead of a media file, scheduled oscillators (kick, sawtooth bass, square arpeggio) and a noise-buffer hi-hat play a 124 BPM loop. A 25 ms `setInterval` *look-ahead scheduler* queues notes ~120 ms early against `actx.currentTime`, which keeps timing rock-solid even when the main thread is busy.
- **Reading the sound:** each frame, `getByteFrequencyData` fills a `Uint8Array` (0–255) with the FFT magnitude per bin; `getByteTimeDomainData` gives the raw waveform. `smoothingTimeConstant: 0.82` blends frames so the bars glide instead of strobing.
- **Drawing:** ~640 usable bins are folded into 72 bars on a near-logarithmic curve (`pow(i/BARS, 1.6)`) so bass and treble both get visible room. Spectrum draws rounded bars filled with one vertical gradient; Radial draws bars around a circle with `globalCompositeOperation = 'lighter'` for additive glow; Wave plots the time-domain array as a glowing oscilloscope line.

## Customization

- **Bar count / feel:** change `BARS` (32 = chunky, 128 = fine). Raise `fftSize` (1024–8192) for more resolution; lower it for speed.
- **Responsiveness vs. smoothness:** lower `smoothingTimeConstant` (→0.6) for a punchy, twitchy reaction; raise it (→0.9) for a lazy, fluid one.
- **Palette:** edit the four `grad` stops (spectrum), the `hsl()` formula (radial hue sweep), or the wave gradient. Map low→high frequency to a hue ramp for a classic "heat" look.
- **Use a real source instead of the synth:** swap the oscillators for `actx.createMediaElementSource(audioEl)` (a file/stream) or `getUserMedia` → `createMediaStreamSource` (a live mic) and connect it to `analyser`. The whole draw layer is unchanged.
- **Loudness:** the `master` gain ramps to `0.22`; lower it for a gentler demo, or add a mute toggle that ramps it to `0.0001`.

## Accessibility & performance

- **Respect reduced motion.** `matchMedia('(prefers-reduced-motion: reduce)')` disables Play, shows a note, and renders a single static bar frame — no `requestAnimationFrame` loop ever starts.
- **Never autoplay sound.** Audio only begins on the explicit Play click; provide an obvious pause/mute. Keep default volume modest.
- **Stop work when hidden.** Pause on `visibilitychange` (and on Pause) — `clearInterval` the scheduler and `cancelAnimationFrame` the draw loop so a backgrounded tab spends nothing.
- **Canvas, not DOM.** One `<canvas>` redrawn per frame is far cheaper than animating 72+ elements. Scale the backing store by `devicePixelRatio` (capped at 2) for crisp bars without over-drawing on 3x phones. Use `shadowBlur` sparingly — it is the most expensive call here, so it is limited to the single waveform stroke.
- Give the canvas an `aria-label`; expose state through the real `<button>` (`aria-pressed`), since the visuals themselves are decorative.

## Gotchas

- **`AudioContext` starts *suspended*** on most browsers. Call `actx.resume()` inside the gesture, or you get silence with no error.
- **`exponentialRampToValueAtTime` can never target 0** (it is undefined for 0). Ramp to a tiny value like `0.0001`, and make sure the *current* value is also > 0 before ramping, or the ramp is ignored / throws.
- **Re-creating the context every click leaks.** Browsers cap how many `AudioContext`s a page may open. Build it once (`ensureCtx`) and reuse it.
- **A flat line / dead bars** usually means you connected the source straight to `destination` and forgot to route it *through* the analyser — the analyser must be in the signal path.
- **DPR blur:** setting only the CSS size leaves the canvas fuzzy. You must also set `canvas.width/height` to `cssSize * dpr` and `setTransform(dpr,…)`, and redo it on every resize.
- **`canvas.roundRect`** is recent (Chrome 99+/Safari 16+); the code falls back to `rect()` where it is missing.
