procedural-audio · notes from production

A game that ships no audio files

Every note, every sound effect and even the reverb is generated at runtime. Nothing to download, nothing to decode, nothing to 404. The interesting part is not the synthesis — it is the two ways this arrangement goes quiet without raising an error.

A browser game embedded on someone else's page pays for every byte twice: once in load time before anyone can play, and again in risk, because every asset is a request that can fail on a network you do not control. Audio is usually the heaviest folder in the build, and the one nobody notices is broken until a player mentions it.

So this game has no audio folder. No .mp3, no .ogg, no decodeAudioData, not a single fetch. Music and effects are oscillators and filters, assembled when the first sound plays.

Including the reverb, which surprises people

Convolution reverb normally means shipping an impulse response — a recording of a real space, often a megabyte or two. You do not need the real room. What a convolver wants is a buffer of noise that decays, and you can write that in nine lines:

const buf = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
  const d = buf.getChannelData(ch);
  for (let i = pre; i < len; i++) {
    const k = (i - pre) / (len - pre);
    d[i] = (Math.random() * 2 - 1) * Math.pow(1 - k, decay);
  }
}

Two channels of independent noise give you a stereo image for free. The pre offset is a 12 ms gap of silence before the tail starts — the pre-delay — and it is what keeps the reverb from smearing the attack of the sound that fed it. A 1.5 second tail with a decay exponent of 3 lands somewhere near a small hall. It is not the Concertgebouw. Underneath gameplay, nobody can tell.

The bug that turns the sound off and never turns it back on

Weak devices cannot render unlimited voices, so there is a budget: refuse to start a new one when too many are already running. Count up when a voice starts, count down when it ends.

_reap(src, nodes) {
  this._voices++;
  src.onended = () => {
    // disconnect src and everything only it referenced
    this._voices--;
  };
}

This is correct right up until an onended does not arrive. It happens: a context suspended at the wrong moment, a tab throttled hard enough that scheduled stops never run, a node torn down along a path that skips the callback.

The counter never comes back down. It sits above the limit, so every future sound is refused, and the game is silent for the rest of the session. Nothing throws. There is no error in the console, no failed request in the network tab, no dropped frame. Sound simply stopped, and the code that stopped it is a counter in a file the symptom gives you no reason to open.

A budget that can only be paid down by an event you do not control is not a budget. It is a slow leak with a mute switch on the end of it.

The fix is to make the counter prove it is still alive. Every completed voice stamps the clock. If the budget says "full" and nothing has completed for three seconds, the count is not real — it is stale — so throw it away rather than keep enforcing it:

_budget(limit) {
  if (this._voices <= limit) return true;
  if (this.ctx.currentTime - this._lastReap > 3) {
    this._voices = 0;   // something never reported ended - recover rather than go silent
    return true;
  }
  return false;
}

Worst case, this briefly allows more voices than intended, which costs a few frames on a slow device. The alternative costs all of the audio, permanently. Those are not comparable failures.

The tab that comes back playing a burst of late notes

Music timing cannot come from requestAnimationFrame — it stops when the tab is hidden, and it jitters with the render loop. The standard answer is a lookahead scheduler: a coarse timer wakes up often, looks a little way into the future, and books every note falling inside that window against the audio clock, which is sample-accurate and does not care about frames.

this._musicTimer = setInterval(() => this._schedule(), 45);
// ...
const horizon = now + 0.32;
while (this._nextNoteTime < horizon) { /* book each note at its exact time */ }

Now background the tab. The timer is throttled to roughly once a second, so the scheduler wakes up several seconds behind where the music should be. Its loop is written to catch up, and it does — by booking every missed note at once. The player switches back and gets a machine-gun burst of a bar and a half, and then the groove carries on offbeat, because it never realigned.

Two guards. First, if the context is not running, return immediately and hold position rather than queueing into a context that is not consuming anything. Second, when the scheduler notices it is more than 250 ms behind, it does not catch up. It abandons the missed notes and jumps forward to the next bar line:

if (this._nextNoteTime < now - 0.25) {
  this._step = Math.ceil(this._step / 16) * 16;   // next bar, not next note
  this._nextNoteTime = now + 0.06;
}

Rounding to the bar rather than to the beat is the whole trick. Resuming on a bar line sounds like the music kept playing while you were away. Resuming on the next sixteenth sounds like a skip.

What is actually reusable

Being honest about the boundary, because this file came out of a shipped game rather than being designed as a library:

Reusable as-isBelongs to the game
The signal chain, the voice budget and reaper, the lookahead scheduler, the impulse and noise generators, event ducking, mute and suspend handling, and the gesture unlock. The eleven named sound effects, and the section definitions — tempo, chords, patterns. Both are plain data, in one switch and one object. Swap them.

Two smaller decisions that saved trouble later. Everything is in C major, so cross-fading between the menu, play and win sections can never clash. And the effect that fires most often — the pickup — walks a pentatonic scale upward as a combo builds, which means a fast player is playing a consonant run rather than hearing the same blip forty times.

Take it

One file, one class, zero dependencies, no build step, and no audio assets to host:

github.com/kentog751/procedural-audio · MIT

It runs in production in the browser games at Free Games Online, where a school Chromebook on a filtered network is a normal Tuesday, and every request that does not happen is a request that cannot fail.