Web Audio for Games — Low-Latency SFX & Music Mixing

Unlock audio on tap, pool your sounds, mix like a DJ, duck like a pro.

Playing sounds in browsers is easy to start and easy to mess up. This guide shows the game-ready patterns: unlocking AudioContext, decoding buffers once, reusing nodes (pools), mixing with master/music/SFX buses, ducking music under SFX, and avoiding mobile lag. Each step includes a tiny demo + a Try This card to tweak.

Step 1 — Unlock the AudioContext

Browsers require a user gesture before audio can play. Create once; resume on the first tap/click.

// audio/core.js
export let ctx;
export function ensureAudio(){
  if (ctx) return ctx;
  const AC = window.AudioContext || window.webkitAudioContext;
  ctx = new AC({ latencyHint: 'interactive' });
  return ctx;
}

// call on first user input:
document.addEventListener('pointerdown', () => ensureAudio()?.resume(), { once:true });
Try This: Change latencyHint to 'playback' and notice how sustained music sounds smooth but input SFX feel a hair slower. Keep 'interactive' for twitchy games.

Step 2 — Load & Decode Sounds (once)

Fetch → ArrayBuffer → decodeAudioData. Store in a cache by key.

// audio/loader.js
import { ensureAudio } from './core.js';
const cache = new Map();

export async function loadBuffer(key, url){
  if (cache.has(key)) return cache.get(key);
  const ctx = ensureAudio();
  const res = await fetch(url);
  const arr = await res.arrayBuffer();
  const buf = await ctx.decodeAudioData(arr);
  cache.set(key, buf);
  return buf;
}
export function getBuffer(key){ return cache.get(key); }
Try This: Preload all UI bleeps on the title screen. Log decode time per file to keep startup snappy.

Step 3 — Play Sounds with a Pool

Creating a new AudioBufferSourceNode is cheap, but gain nodes and filters add up. Pool them.

// audio/pool.js
import { ensureAudio } from './core.js';

export class SfxPool {
  constructor(size=16, bus){
    this.ctx = ensureAudio();
    this.bus = bus || this.ctx.destination;
    this.pool = new Array(size).fill(null).map(() => this._makeChannel());
    this.next = 0;
  }
  _makeChannel(){
    const gain = this.ctx.createGain(); gain.gain.value = 1.0;
    gain.connect(this.bus);
    return { gain };
  }
  play(buffer, { volume=1, rate=1, detune=0, when=0 } = {}){
    const ch = this.pool[this.next = (this.next+1) % this.pool.length];
    const src = this.ctx.createBufferSource();
    src.buffer = buffer; src.playbackRate.value = rate; src.detune.value = detune;
    ch.gain.gain.setValueAtTime(volume, this.ctx.currentTime);
    src.connect(ch.gain);
    src.start(this.ctx.currentTime + when);
  }
}
Try This: Call pool.play(buffer, { rate: 1 + Math.random()*0.1 }) on footsteps so they don’t sound identical.

Step 4 — Mix Bus: master / music / sfx

Route everything through a simple three-bus mixer for global volume and effects.

// audio/mix.js
import { ensureAudio } from './core.js';

export class Mixer {
  constructor(){
    this.ctx = ensureAudio();
    this.master = this.ctx.createGain();
    this.music  = this.ctx.createGain();
    this.sfx    = this.ctx.createGain();

    this.music.connect(this.master);
    this.sfx.connect(this.master);
    this.master.connect(this.ctx.destination);

    this.setMaster(1); this.setMusic(0.8); this.setSfx(1.0);
  }
  setMaster(v){ this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.01); }
  setMusic(v){  this.music.gain.setTargetAtTime(v,  this.ctx.currentTime, 0.01); }
  setSfx(v){    this.sfx.gain.setTargetAtTime(v,    this.ctx.currentTime, 0.01); }
}
Try This: Add a low-pass filter on the music bus during pause to sell the “underwater” vibe.

Step 5 — Duck Music under Loud SFX

When explosions hit, dip the music briefly so SFX punch through.

// audio/duck.js
export function duck(mixer, amount=0.4, attack=0.005, release=0.25){
  const ctx = mixer.ctx;
  const g = mixer.music.gain;
  const t = ctx.currentTime;
  const target = Math.max(0, g.value * amount);
  g.cancelScheduledValues(t);
  g.setTargetAtTime(target, t, attack);
  g.setTargetAtTime(0.8, t + attack + 0.05, release); // return to ~0.8
}
// usage with pool
pool.play(explosionBuf, { volume: 1.0 });
duck(mixer, 0.35);
Try This: Different ducking per sound class: small hits (amount 0.8), big booms (0.4), UI blips (no duck).

Step 6 — Sprite Sheets (one file, many blips)

Bundle micro-SFX in one WAV/OGG and slice by offsets to reduce requests.

// audio/sprite.js
export class SpritePlayer {
  constructor(buffer, bus){
    this.ctx = (bus?.context) || (window.AudioContext && new AudioContext());
    this.bus = bus || this.ctx.destination;
    this.buffer = buffer;
    this.map = {}; // { 'click': [startSec, durationSec], ... }
  }
  add(name, start, dur){ this.map[name] = [start, dur]; }
  play(name, { volume=1, rate=1 } = {}){
    const [start, dur] = this.map[name] || [];
    if(start == null) return;
    const gain = this.ctx.createGain(); gain.gain.value = volume; gain.connect(this.bus);
    const src = this.ctx.createBufferSource(); src.buffer = this.buffer; src.playbackRate.value = rate;
    src.connect(gain); src.start(this.ctx.currentTime, start, dur);
  }
}
Try This: Randomize between clickA/clickB/clickC slices for UI variety.

Step 7 — Latency & Mobile Quirks

  • Unlock once: resume context on first gesture (tap/click). No gesture, no sound.
  • Prefer Web Audio: avoid <audio> for SFX; it’s buffered and laggy.
  • Keep buffers small: trim silence; mono SFX load/play faster.
  • Pool nodes: don’t allocate gains/filters every frame.
  • Safari iOS: must start in response to a user event; sample rates vary—test on device.
Try This: Log ctx.baseLatency and display a “Low/Med/High” badge in your debug HUD.

Step 8 — Looping Music with Seamless Restarts

Use buffer source loop points or schedule musical sections on the clock to avoid clicks.

// audio/music.js
export class Music {
  constructor(mixer){ this.mixer = mixer; this.ctx = mixer.ctx; this.src = null; }
  play(buffer, { loop=true, gain=0.8, loopStart=0, loopEnd=null } = {}){
    this.stop();
    const g = this.ctx.createGain(); g.gain.value = gain; g.connect(this.mixer.music);
    const s = this.ctx.createBufferSource(); s.buffer = buffer; s.loop = loop;
    if(loopEnd != null){ s.loopStart = loopStart; s.loopEnd = loopEnd; }
    s.connect(g); s.start();
    this.src = { s, g };
  }
  stop(){ if(this.src){ try{ this.src.s.stop(); }catch{} this.src = null; } }
}
Try This: Split your track intro (0–6.2s) and loop section (6.2–45.0s). Pass those as loopStart/loopEnd.

Checklist + Next Moves

  • [ ] One global AudioContext unlocked on first input
  • [ ] Decode buffers once; cache by key
  • [ ] SFX pool + randomized pitch for variation
  • [ ] Mixer with master/music/sfx buses
  • [ ] Duck music on high-energy SFX
  • [ ] Optional sprite sheet for micro-SFX
  • [ ] Latency badge + device testing (iOS/Android/desktop)

Level up further: dynamic reverb per biome, distance-based panning with PannerNode, and sidechain ducking via DynamicsCompressorNode.

Leave a Comment

Scroll to Top