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 });
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); }
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);
}
}
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); }
}
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);
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);
}
}
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.
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; } }
}
loopStart/loopEnd.
Checklist + Next Moves
- [ ] One global
AudioContextunlocked 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.