All articles
16 min read

Audio Visualization: How to Create Stunning Sound Visualizations With Code

audio visualizationWeb Audio APIcreative codinggenerative artsound art

Sound is invisible — but it doesn't have to be. Audio visualization transforms music, speech, and ambient noise into moving shapes, colors, and patterns. It's the art of making the invisible visible. From Winamp's classic visualizers to Spotify's now-playing animations, from VJ performances to Lumitree's sound garden micro-worlds — audio visualization sits at the intersection of creative coding, signal processing, and pure aesthetic joy.

This guide covers the core techniques of audio visualization with 8 working code examples you can run in your browser. No libraries required — just HTML, Canvas, and the Web Audio API.

The Web Audio API: your sound toolkit

Every browser ships with a powerful audio analysis engine. The Web Audio API lets you load audio, route it through processing nodes, and extract real-time frequency and waveform data. The key node for visualization is the AnalyserNode — it performs a Fast Fourier Transform (FFT) on the audio signal, giving you both time-domain (waveform) and frequency-domain (spectrum) data.

Here's the minimal setup that powers every example in this guide:

// Create audio context and analyser
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048; // Higher = more detail, more CPU

// Connect a source (microphone, file, or oscillator)
async function connectMicrophone() {
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  const source = audioCtx.createMediaStreamSource(stream);
  source.connect(analyser);
}

// Get data every frame
const dataArray = new Uint8Array(analyser.frequencyBinCount);
function getData() {
  analyser.getByteFrequencyData(dataArray);    // 0-255 per frequency bin
  // or: analyser.getByteTimeDomainData(dataArray); // waveform
}

The frequencyBinCount is half of fftSize. With an FFT size of 2048, you get 1024 frequency bins — each representing a narrow band from 0 Hz to half the sample rate (typically 22,050 Hz). Low indices = bass. High indices = treble. That mapping is the foundation of everything that follows.

1. Classic waveform oscilloscope

The simplest audio visualization: draw the raw waveform as a line. Time-domain data shows the actual shape of the sound wave — smooth sine waves for pure tones, complex jagged shapes for music and speech.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 300;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
const bufLen = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufLen);

// Use oscillator as demo source (replace with mic or audio file)
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 120;
gain.gain.value = 0.3;
osc.connect(gain);
gain.connect(analyser);
gain.connect(audioCtx.destination);
osc.start();

// Slowly modulate frequency for visual interest
let t = 0;
function draw() {
  requestAnimationFrame(draw);
  t += 0.01;
  osc.frequency.value = 120 + Math.sin(t) * 80 + Math.sin(t * 2.7) * 40;

  analyser.getByteTimeDomainData(dataArray);

  ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.lineWidth = 2;
  ctx.strokeStyle = '#00ff88';
  ctx.beginPath();

  const sliceWidth = canvas.width / bufLen;
  let x = 0;
  for (let i = 0; i < bufLen; i++) {
    const v = dataArray[i] / 128.0;
    const y = (v * canvas.height) / 2;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
    x += sliceWidth;
  }
  ctx.lineTo(canvas.width, canvas.height / 2);
  ctx.stroke();
}
draw();

The trail effect (rgba(0, 0, 0, 0.1) instead of clearing) gives the waveform a retro oscilloscope glow. The frequency modulation creates evolving visual patterns even from a single oscillator.

2. Frequency spectrum bar chart

Frequency data splits sound into its component frequencies — bass, mids, treble. Drawing each frequency bin as a vertical bar creates the classic equalizer visualization. The trick is using a logarithmic scale for the x-axis, since human hearing is logarithmic (the difference between 100 Hz and 200 Hz sounds the same as 1000 Hz to 2000 Hz).

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 300;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.8;
const bufLen = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufLen);

// Multi-oscillator demo source
const oscs = [110, 220, 440, 660, 880].map(f => {
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.frequency.value = f;
  g.gain.value = 0.08;
  o.connect(g); g.connect(analyser); g.connect(audioCtx.destination);
  o.start();
  return { osc: o, gain: g };
});

let t = 0;
function draw() {
  requestAnimationFrame(draw);
  t += 0.02;
  // Animate oscillator volumes
  oscs.forEach((o, i) => {
    o.gain.gain.value = 0.05 + 0.06 * Math.sin(t + i * 1.3);
  });

  analyser.getByteFrequencyData(dataArray);

  ctx.fillStyle = '#0a0a0a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const barCount = 64;
  const barWidth = canvas.width / barCount - 2;

  for (let i = 0; i < barCount; i++) {
    // Map bar index logarithmically to frequency bin
    const binIndex = Math.floor(Math.pow(i / barCount, 2) * bufLen);
    const value = dataArray[binIndex] / 255;
    const barHeight = value * canvas.height * 0.9;

    // Color gradient: bass=magenta, mid=cyan, treble=yellow
    const hue = (i / barCount) * 280 + 300;
    ctx.fillStyle = `hsl(${hue % 360}, 80%, ${40 + value * 30}%)`;
    ctx.fillRect(
      i * (barWidth + 2),
      canvas.height - barHeight,
      barWidth,
      barHeight
    );
  }
}
draw();

The smoothingTimeConstant (0 to 1) controls how quickly bars respond. Higher values = smoother, more musical movement. Lower values = twitchy, more responsive. Try 0.85 for music, 0.5 for speech.

3. Circular frequency visualizer

Mapping frequency data to a circle creates mesmerizing radial patterns. Each frequency bin becomes a spoke radiating from the center, with amplitude controlling the length. The result looks like a living organism breathing with the music.

const canvas = document.createElement('canvas');
canvas.width = 500; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.82;
const bufLen = analyser.frequencyBinCount;
const freqData = new Uint8Array(bufLen);

// Chord oscillator
[130.81, 164.81, 196.00, 261.63].forEach(f => {
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.type = 'sine';
  o.frequency.value = f;
  g.gain.value = 0.06;
  o.connect(g); g.connect(analyser); g.connect(audioCtx.destination);
  o.start();
});

const cx = canvas.width / 2, cy = canvas.height / 2;
let rotation = 0;

function draw() {
  requestAnimationFrame(draw);
  analyser.getByteFrequencyData(freqData);
  rotation += 0.003;

  ctx.fillStyle = 'rgba(5, 5, 15, 0.15)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const bars = 128;
  const baseRadius = 60;
  const maxSpoke = 140;

  for (let i = 0; i < bars; i++) {
    const angle = (i / bars) * Math.PI * 2 + rotation;
    const binIdx = Math.floor((i / bars) * bufLen * 0.7);
    const value = freqData[binIdx] / 255;
    const spokeLen = baseRadius + value * maxSpoke;

    const x1 = cx + Math.cos(angle) * baseRadius;
    const y1 = cy + Math.sin(angle) * baseRadius;
    const x2 = cx + Math.cos(angle) * spokeLen;
    const y2 = cy + Math.sin(angle) * spokeLen;

    const hue = (i / bars) * 360;
    ctx.strokeStyle = `hsla(${hue}, 90%, ${50 + value * 30}%, ${0.5 + value * 0.5})`;
    ctx.lineWidth = 1.5 + value * 2;
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x2, y2);
    ctx.stroke();

    // Add glow dots at tips
    if (value > 0.5) {
      ctx.fillStyle = `hsla(${hue}, 100%, 70%, ${value})`;
      ctx.beginPath();
      ctx.arc(x2, y2, 2 + value * 3, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Inner circle glow
  const avgBass = [...freqData.slice(0, 10)].reduce((a, b) => a + b, 0) / (10 * 255);
  const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, baseRadius);
  gradient.addColorStop(0, `hsla(260, 80%, 60%, ${avgBass * 0.4})`);
  gradient.addColorStop(1, 'transparent');
  ctx.fillStyle = gradient;
  ctx.beginPath();
  ctx.arc(cx, cy, baseRadius, 0, Math.PI * 2);
  ctx.fill();
}
draw();

The slow rotation (rotation += 0.003) prevents the visual from feeling static. The inner glow responding to bass frequencies adds depth. This is the foundation of most "music visualizer" apps you've seen.

4. Particle audio reactor

Particles that respond to audio create an organic, living visualization. Each particle has its own position and velocity, but audio data influences their behavior — bass makes them explode outward, treble makes them shimmer. The effect is like watching sound materialize into physical matter.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.75;
const freqData = new Uint8Array(analyser.frequencyBinCount);

// Rhythmic oscillator
const osc = audioCtx.createOscillator();
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
const mainGain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.value = 80;
lfo.frequency.value = 2; // 2 Hz pulse
lfoGain.gain.value = 60;
mainGain.gain.value = 0.15;
lfo.connect(lfoGain);
lfoGain.connect(osc.frequency);
osc.connect(mainGain);
mainGain.connect(analyser);
mainGain.connect(audioCtx.destination);
osc.start(); lfo.start();

class Particle {
  constructor() { this.reset(); }
  reset() {
    this.x = canvas.width / 2;
    this.y = canvas.height / 2;
    this.vx = (Math.random() - 0.5) * 2;
    this.vy = (Math.random() - 0.5) * 2;
    this.life = 1;
    this.decay = 0.003 + Math.random() * 0.008;
    this.hue = Math.random() * 360;
    this.size = 1 + Math.random() * 2;
  }
  update(bass, treble) {
    const force = bass * 3;
    this.vx += (Math.random() - 0.5) * force;
    this.vy += (Math.random() - 0.5) * force;
    this.vx *= 0.98; this.vy *= 0.98;
    this.x += this.vx;
    this.y += this.vy;
    this.life -= this.decay;
    this.size = (1 + treble * 4) * this.life;
    if (this.life <= 0) this.reset();
  }
  draw(ctx) {
    ctx.fillStyle = `hsla(${this.hue}, 90%, 60%, ${this.life * 0.8})`;
    ctx.beginPath();
    ctx.arc(this.x, this.y, Math.max(0.5, this.size), 0, Math.PI * 2);
    ctx.fill();
  }
}

const particles = Array.from({ length: 300 }, () => new Particle());

function draw() {
  requestAnimationFrame(draw);
  analyser.getByteFrequencyData(freqData);

  const bass = [...freqData.slice(0, 8)].reduce((a, b) => a + b, 0) / (8 * 255);
  const treble = [...freqData.slice(80, 120)].reduce((a, b) => a + b, 0) / (40 * 255);

  ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  particles.forEach(p => {
    p.update(bass, treble);
    p.draw(ctx);
  });
}
draw();

The LFO (Low Frequency Oscillator) modulating the main oscillator's frequency creates a rhythmic pumping effect — the particles explode outward on each "beat." Replace the oscillator with a real audio file or microphone input, and you have a concert-ready visualizer.

5. Spectrogram heatmap

A spectrogram shows how frequency content changes over time. Each column is one frame's frequency snapshot, painted as a vertical strip of colors. As time progresses, the strip scrolls left, creating a heat map of sound. It's how scientists visualize birdsong, whale calls, and speech patterns — and it makes stunning generative art.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 300;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.6;
const bufLen = analyser.frequencyBinCount;
const freqData = new Uint8Array(bufLen);

// Evolving drone source
const oscs = [];
for (let i = 0; i < 6; i++) {
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.type = i < 3 ? 'sine' : 'triangle';
  o.frequency.value = 100 + i * 150;
  g.gain.value = 0.04;
  o.connect(g); g.connect(analyser); g.connect(audioCtx.destination);
  o.start();
  oscs.push({ osc: o, baseFreq: 100 + i * 150 });
}

let t = 0;
// Offscreen buffer for scrolling
const buffer = document.createElement('canvas');
buffer.width = canvas.width; buffer.height = canvas.height;
const bufCtx = buffer.getContext('2d');

function draw() {
  requestAnimationFrame(draw);
  t += 0.005;

  // Evolve frequencies
  oscs.forEach((o, i) => {
    o.osc.frequency.value = o.baseFreq + Math.sin(t * (0.3 + i * 0.1)) * 80;
  });

  analyser.getByteFrequencyData(freqData);

  // Shift existing image left by 1 pixel
  bufCtx.drawImage(buffer, -1, 0);

  // Draw new column on the right edge
  const binHeight = canvas.height / 128;
  for (let i = 0; i < 128; i++) {
    const binIdx = Math.floor(Math.pow(i / 128, 1.5) * bufLen);
    const value = freqData[binIdx] / 255;

    // Heatmap: black → blue → purple → red → yellow → white
    let r, g, b;
    if (value < 0.2) {
      r = 0; g = 0; b = Math.floor(value * 5 * 180);
    } else if (value < 0.5) {
      const t2 = (value - 0.2) / 0.3;
      r = Math.floor(t2 * 200); g = 0; b = 180 - Math.floor(t2 * 80);
    } else if (value < 0.8) {
      const t2 = (value - 0.5) / 0.3;
      r = 200 + Math.floor(t2 * 55); g = Math.floor(t2 * 200); b = 0;
    } else {
      const t2 = (value - 0.8) / 0.2;
      r = 255; g = 200 + Math.floor(t2 * 55); b = Math.floor(t2 * 255);
    }

    bufCtx.fillStyle = `rgb(${r},${g},${b})`;
    bufCtx.fillRect(
      canvas.width - 1,
      canvas.height - (i + 1) * binHeight,
      1,
      binHeight + 0.5
    );
  }

  ctx.drawImage(buffer, 0, 0);
}
draw();

The scrolling technique (draw to offscreen buffer, shift left, draw new column) is the standard approach for real-time spectrograms. The logarithmic bin mapping (Math.pow(i/128, 1.5)) gives more visual space to the bass frequencies where most musical information lives.

6. 3D terrain from audio

Turn frequency data into a scrolling 3D terrain — each row of the terrain is one frame's frequency snapshot, creating mountain ridges that flow toward the viewer. The effect is reminiscent of the iconic Joy Division album cover, but animated and driven by real sound.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.7;
const bufLen = analyser.frequencyBinCount;
const freqData = new Uint8Array(bufLen);

// Harmonic series source
[110, 165, 220, 330].forEach(f => {
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.type = 'triangle';
  o.frequency.value = f;
  g.gain.value = 0.06;
  o.connect(g); g.connect(analyser); g.connect(audioCtx.destination);
  o.start();
  // Slow detune for evolving timbre
  setInterval(() => { o.frequency.value = f + (Math.random() - 0.5) * 20; }, 2000);
});

const history = [];
const maxRows = 50;
const cols = 80;

function draw() {
  requestAnimationFrame(draw);
  analyser.getByteFrequencyData(freqData);

  // Sample frequency data into columns
  const row = [];
  for (let i = 0; i < cols; i++) {
    const idx = Math.floor((i / cols) * bufLen * 0.8);
    row.push(freqData[idx] / 255);
  }
  history.unshift(row);
  if (history.length > maxRows) history.pop();

  ctx.fillStyle = '#08080c';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Draw terrain rows from back to front
  for (let z = history.length - 1; z >= 0; z--) {
    const rowData = history[z];
    const perspective = 1 - z / maxRows; // 1 = front, 0 = back
    const yBase = 100 + z * 5;
    const xScale = 0.5 + perspective * 0.5;
    const xOffset = canvas.width * (1 - xScale) / 2;

    ctx.beginPath();
    for (let i = 0; i <= cols; i++) {
      const val = i < cols ? rowData[i] : rowData[cols - 1];
      const x = xOffset + (i / cols) * canvas.width * xScale;
      const y = yBase - val * 80 * perspective;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
    }
    // Close path along bottom for fill
    ctx.lineTo(xOffset + canvas.width * xScale, yBase + 10);
    ctx.lineTo(xOffset, yBase + 10);
    ctx.closePath();

    const alpha = 0.3 + perspective * 0.7;
    ctx.fillStyle = `rgba(8, 8, 12, ${alpha})`;
    ctx.fill();
    ctx.strokeStyle = `hsla(200, 80%, ${40 + perspective * 40}%, ${alpha})`;
    ctx.lineWidth = 0.5 + perspective * 1.5;
    ctx.stroke();
  }
}
draw();

The perspective scaling (rows farther back are smaller and more transparent) creates the illusion of depth. Each new row of audio data pushes the previous rows backward, creating an endless scrolling landscape of sound. Try it with real music — different genres create wildly different terrain shapes.

7. Beat detection with visual pulses

Detecting beats (kicks, snares, transients) lets you trigger visual events at musically meaningful moments. The simplest approach: track the energy in the bass range and detect sudden spikes above a running average. When a beat fires, trigger a visual explosion.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 512;
analyser.smoothingTimeConstant = 0.5; // Lower = more responsive to transients
const freqData = new Uint8Array(analyser.frequencyBinCount);

// Percussive source: square wave with fast LFO
const osc = audioCtx.createOscillator();
const lfo = audioCtx.createOscillator();
const lfoGain = audioCtx.createGain();
const mainGain = audioCtx.createGain();
osc.type = 'square'; osc.frequency.value = 55;
lfo.type = 'square'; lfo.frequency.value = 3; // 3 beats per second
lfoGain.gain.value = 0.15;
mainGain.gain.value = 0.2;
lfo.connect(lfoGain); lfoGain.connect(mainGain.gain);
osc.connect(mainGain);
mainGain.connect(analyser); mainGain.connect(audioCtx.destination);
osc.start(); lfo.start();

// Beat detection state
let energyHistory = [];
const historyLen = 30;
let rings = [];

function detectBeat() {
  analyser.getByteFrequencyData(freqData);
  const bassEnergy = [...freqData.slice(0, 12)].reduce((a, b) => a + b, 0) / 12;
  energyHistory.push(bassEnergy);
  if (energyHistory.length > historyLen) energyHistory.shift();
  const avg = energyHistory.reduce((a, b) => a + b, 0) / energyHistory.length;
  return bassEnergy > avg * 1.4 && bassEnergy > 80; // Threshold
}

function draw() {
  requestAnimationFrame(draw);

  if (detectBeat()) {
    rings.push({
      x: canvas.width / 2 + (Math.random() - 0.5) * 100,
      y: canvas.height / 2 + (Math.random() - 0.5) * 60,
      radius: 10,
      hue: Math.random() * 360,
      life: 1,
    });
  }

  ctx.fillStyle = 'rgba(5, 5, 10, 0.12)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Update and draw rings
  rings = rings.filter(r => r.life > 0);
  rings.forEach(r => {
    r.radius += 4;
    r.life -= 0.02;
    ctx.strokeStyle = `hsla(${r.hue}, 90%, 60%, ${r.life})`;
    ctx.lineWidth = 2 + r.life * 4;
    ctx.beginPath();
    ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
    ctx.stroke();

    // Inner flash
    if (r.life > 0.8) {
      const grad = ctx.createRadialGradient(r.x, r.y, 0, r.x, r.y, r.radius * 0.5);
      grad.addColorStop(0, `hsla(${r.hue}, 90%, 80%, ${(r.life - 0.8) * 3})`);
      grad.addColorStop(1, 'transparent');
      ctx.fillStyle = grad;
      ctx.fill();
    }
  });
}
draw();

Real beat detection gets more sophisticated — you can track separate energy bands (kick drum in bass, snare in mids, hi-hat in treble) and use different visual effects for each. The running average approach works surprisingly well for most music without any machine learning.

8. Audio-reactive generative art

The ultimate audio visualization: generative art that breathes with sound. This example combines flow fields (see our Perlin noise guide) with audio reactivity — bass controls field strength, mids control color rotation, and treble controls particle speed. The result is an ever-changing abstract painting driven entirely by sound.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
const freqData = new Uint8Array(analyser.frequencyBinCount);

// Rich harmonic source
const notes = [65.41, 130.81, 196.00, 329.63, 523.25];
notes.forEach(f => {
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.type = 'sine';
  o.frequency.value = f;
  g.gain.value = 0.04;
  o.connect(g); g.connect(analyser); g.connect(audioCtx.destination);
  o.start();
  // Slow vibrato
  const lfo = audioCtx.createOscillator();
  const lg = audioCtx.createGain();
  lfo.frequency.value = 0.3 + Math.random() * 0.5;
  lg.gain.value = f * 0.02;
  lfo.connect(lg); lg.connect(o.frequency); lfo.start();
});

// Simple noise function
function noise(x, y) {
  const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
  return n - Math.floor(n);
}
function smoothNoise(x, y) {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = x - ix, fy = y - iy;
  const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);
  return noise(ix, iy) * (1 - sx) * (1 - sy) + noise(ix + 1, iy) * sx * (1 - sy)
       + noise(ix, iy + 1) * (1 - sx) * sy + noise(ix + 1, iy + 1) * sx * sy;
}

class FlowParticle {
  constructor() { this.reset(); }
  reset() {
    this.x = Math.random() * canvas.width;
    this.y = Math.random() * canvas.height;
    this.life = 0.5 + Math.random() * 0.5;
  }
  update(bass, mid, treble, time) {
    const scale = 0.005;
    const n = smoothNoise(this.x * scale + time * 0.1, this.y * scale);
    const angle = n * Math.PI * 4 + bass * Math.PI * 2;
    const speed = 1 + treble * 5;
    this.x += Math.cos(angle) * speed;
    this.y += Math.sin(angle) * speed;
    this.life -= 0.005;

    if (this.life <= 0 || this.x < 0 || this.x > canvas.width ||
        this.y < 0 || this.y > canvas.height) {
      this.reset();
    }
  }
}

const particles = Array.from({ length: 500 }, () => new FlowParticle());
let time = 0;

function draw() {
  requestAnimationFrame(draw);
  time += 0.01;
  analyser.getByteFrequencyData(freqData);

  const bass = [...freqData.slice(0, 8)].reduce((a, b) => a + b, 0) / (8 * 255);
  const mid = [...freqData.slice(20, 60)].reduce((a, b) => a + b, 0) / (40 * 255);
  const treble = [...freqData.slice(80, 128)].reduce((a, b) => a + b, 0) / (48 * 255);

  // Slow fade for trails
  ctx.fillStyle = `rgba(5, 5, 10, ${0.03 + bass * 0.05})`;
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Hue shifts with mid-range audio
  const baseHue = time * 20 + mid * 200;

  particles.forEach(p => {
    p.update(bass, mid, treble, time);
    const hue = (baseHue + p.x * 0.2 + p.y * 0.1) % 360;
    ctx.fillStyle = `hsla(${hue}, 80%, 55%, ${p.life * 0.6})`;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 1 + bass * 3, 0, Math.PI * 2);
    ctx.fill();
  });
}
draw();

This is where audio visualization becomes true generative art. The flow field creates structure. The audio data injects life. Every moment is unique, driven by the exact frequencies present in the sound at that instant. Connect it to a microphone and watch the room's ambient sound become a painting.

Techniques for better audio visualizations

Beyond the examples above, here are proven techniques that separate amateur visualizations from professional ones:

  • Logarithmic frequency mapping — Human hearing is logarithmic. Map frequency bins with Math.pow(index/total, 2) to give bass more visual space.
  • Exponential smoothing — Apply your own smoothing (displayValue = displayValue * 0.9 + newValue * 0.1) on top of the analyser's smoothing for buttery animation.
  • Frequency band separation — Don't just use raw bins. Group them into bass (20-250 Hz), low-mid (250-500 Hz), mid (500-2000 Hz), upper-mid (2-4 kHz), and treble (4-20 kHz) for meaningful visual mapping.
  • Decay with memory — Track peak values per bin and let them decay slowly. This creates the "falling bars" effect and prevents jittery visuals.
  • Audio-to-color mapping — Use HSL color space. Map frequency position to hue (bass=warm, treble=cool), amplitude to lightness, and harmonic content to saturation.
  • Frame blending — Instead of clearing the canvas, use a semi-transparent black overlay. The alpha value controls trail length. Lower alpha = longer trails = dreamier effect.
  • WebGL for performance — For complex visualizations with thousands of elements, port to WebGL. The frequency data can be uploaded as a texture and processed entirely on the GPU.

From demo to real audio

The examples above use oscillators as sound sources for instant, no-setup demos. To use real audio:

// Option 1: Microphone
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);

// Option 2: Audio file
const audio = new Audio('song.mp3');
audio.crossOrigin = 'anonymous';
const source = audioCtx.createMediaElementSource(audio);
source.connect(analyser);
source.connect(audioCtx.destination);
audio.play();

// Option 3: Drag-and-drop
canvas.addEventListener('drop', async (e) => {
  e.preventDefault();
  const file = e.dataTransfer.files[0];
  const arrayBuffer = await file.arrayBuffer();
  const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
  const source = audioCtx.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(analyser);
  source.connect(audioCtx.destination);
  source.start();
});

Note: AudioContext requires a user gesture to start in most browsers. Wrap your setup in a click handler: canvas.addEventListener('click', () => { audioCtx.resume(); setup(); }).

Go further

Audio visualization is one of the most rewarding areas of creative coding. It's immediate — you hear the input and see the output simultaneously. It's infinite — no two moments of music look the same. And it connects to everything else in generative art: noise fields, canvas drawing, particle systems, shaders, and even procedural generation.

On Lumitree, several sound garden branches use exactly these techniques — visitors plant a seed, and what grows is a unique audio-reactive micro-world. Each one turns ambient sound into living art, all in under 50KB.

The code examples in this guide use vanilla JavaScript and the Web Audio API — no frameworks, no build tools. That's intentional. Understanding the raw signal data (waveform, spectrum, energy bands) gives you the foundation to build visualizations in any environment: p5.js, Three.js, TouchDesigner, even hardware LEDs via WebMIDI.

Start with the oscilloscope. Then try the circular visualizer with your microphone. Then combine techniques — beat detection driving particle explosions over a flow field. That's how audio visualizations evolve from demos into art.

Related articles