Journal Entry

Painting With Frequency: Real-Time Audio Reactivity in the Browser

Leon Nduati
Painting With Frequency: Real-Time Audio Reactivity in the Browser

Building an audio player in the browser is easy. You drop an <audio> tag into the DOM, give it a src attribute, and call .play().

Building an audio player that breathes with the track—where the visual artwork pulses to the sub-bass, the perimeter glows in sync with the snare transient, and the particle field scatters with the high-hats—is an exercise in browser threading warfare.

When I started engineering the real-time visualizers for this portfolio's audio player, my goal was simple: I wanted the user to feel the frequency separation before their brain even processed the melody.

Here is what nobody tells you about making the Web Audio API dance with an HTML5 Canvas at sixty frames per second.


1. The Clock Drift Problem: Audio Thread vs Render Thread

The first architectural trap you encounter in web audio is assuming the audio clock and the visual render loop care about each other.

They don't.

AudioContext.currentTime runs on a high-priority, real-time hardware audio thread. Your visual render loop runs inside window.requestAnimationFrame(), which is tied to the main browser thread and gets demoted whenever the user scrolls or the garbage collector decides to run a sweep.

// The pipeline: Audio Hardware Thread -> AnalyserNode -> Uint8Array -> rAF -> Canvas 2D / GPU
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
const analyser = audioCtx.createAnalyser();

// fftSize determines the frequency resolution: 256 gives 128 frequency bins
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.82; // Crucial: prevents jittery, nervous visual flickers

If you poll the AnalyserNode on every single frame without a smoothing constant, your visualizer looks like a seismograph during an earthquake. The human eye cannot parse raw, instantaneous decibel spikes; it needs intentional kinetic decay. Setting smoothingTimeConstant between 0.8 and 0.85 introduces exponential averaging across frames, giving the bars a natural acoustic inertia.


2. Isolating the Kick from the Mud

A common mistake in audio visualizers is mapping the entire frequency spectrum to a single visual parameter (like "scale" or "brightness"). When you do that, the whole screen just throbs chaotically whenever any loud sound happens.

To make an artwork feel truly alive, you have to split the frequency buffer into distinct anatomical zones:

const bufferLength = analyser.frequencyBinCount; // 128 bins for fftSize 256
const dataArray = new Uint8Array(bufferLength);

function getBandAverages() {
  analyser.getByteFrequencyData(dataArray);

  // Sub-bass & Kick (bins 0 to 4: roughly 20Hz - 150Hz)
  let bassSum = 0;
  for (let i = 0; i < 4; i++) bassSum += dataArray[i];
  const bassEnergy = bassSum / 4;

  // Midrange / Vocals (bins 8 to 24: roughly 300Hz - 1kHz)
  let midSum = 0;
  for (let i = 8; i < 24; i++) midSum += dataArray[i];
  const midEnergy = midSum / 16;

  // Air / Treble (bins 32 to 64: roughly 2kHz - 8kHz)
  let trebleSum = 0;
  for (let i = 32; i < 64; i++) trebleSum += dataArray[i];
  const trebleEnergy = trebleSum / 32;

  return { bassEnergy, midEnergy, trebleEnergy };
}

Now you have three distinct control voltages:

  • Use bassEnergy to drive heavy, low-frequency displacements (like the expansion scale of an album sleeve or the bass-reflex shockwave).
  • Use midEnergy to modulate line thickness or saturation, tracking the vocal presence.
  • Use trebleEnergy to spawn erratic micro-particles or high-frequency edge jitters.

3. Stopping Mobile Batteries from Melting

If you draw 500 individual rectangle paths with ctx.fillRect() inside a requestAnimationFrame loop, your MacBook will spin its fans, and an iPhone 13 will throttle its CPU within forty-five seconds.

To keep the framerate locked at a solid 60 FPS:

  1. Never allocate inside the loop: Pre-allocate your Uint8Array outside the animation function. Instantiating arrays inside requestAnimationFrame forces garbage collection spikes that manifest as ugly frame drops.
  2. Use trails instead of full clears: Instead of calling ctx.clearRect(0, 0, width, height) every frame, draw a semi-transparent black rectangle over the canvas: ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'. This creates an analog phosphor decay trail (like an old CRT oscilloscope) for practically zero performance cost.
  3. Offscreen Canvas: If you are compositing complex layered particle fields or radial blur rings, render the static texture to an OffscreenCanvas once, then blit it onto the main canvas with drawImage().

Sound is physical pressure in air; when you render it digitally, it needs that same feeling of mass, resistance, and momentum. Treat the canvas like a canvas, not a spreadsheet.

End