Chapter 11: A voice: synthesised sound with Web Audio
This chapter: give the game sound with pure Web Audio, no audio files. Every effect is generated from oscillators and noise, plus a looping thrust rumble and the browser's audio-gesture rule.
Where we left off: the game plays in silence; play_sound and set_thrust are already called from the simulation.
11.1 The Odin side is already done
The game triggers sound by id. The constants live in game.odin and must match the JS switch:
SND_FIRE :: 0
SND_BANG_LARGE :: 1
SND_BANG_MED :: 2
SND_BANG_SMALL :: 3
SND_SHIP_DEATH :: 4
SND_LEVEL :: 5
SND_HYPER :: 6
SND_THUD :: 7
They're already fired from gameplay: play_sound(SND_FIRE) in fire_bullet, play_sound(SND_BANG_*) in split_asteroid, and so on. All the sound design happens on the page, in JavaScript, because that's where Web Audio lives. This chapter is mostly JS.
11.2 Three building blocks
Web Audio is a graph of nodes. We need three kinds:
AudioContext: the graph itself, andctx.destination(the speakers).OscillatorNode: a tone (sine/square/triangle/sawtooth) at a frequency.GainNode: a volume you can ramp over time, how we shape a note's envelope (the quick swell and decay that turns a raw tone into a "blip").
A pitch-swept blip, and a filtered-noise burst, cover every effect in the game:
// A tone that slides f0 -> f1 and fades out: the gun blip, the wave chime, hyperspace.
function blip(ctx, f0, f1, dur, peak, type) {
const o = ctx.createOscillator(); o.type = type;
const g = ctx.createGain();
const t = ctx.currentTime;
o.frequency.setValueAtTime(f0, t);
o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(peak, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur); // decay envelope
o.connect(g).connect(ctx.destination);
o.start(t); o.stop(t + dur);
}
// Low-passed white noise with a decay: the "bang" of an exploding rock.
function explosion(ctx, dur, cutoff, peak) {
const src = ctx.createBufferSource(); src.buffer = noiseBuffer(ctx, dur);
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = cutoff;
const g = ctx.createGain();
const t = ctx.currentTime;
g.gain.setValueAtTime(peak, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(lp).connect(g).connect(ctx.destination);
src.start(t); src.stop(t + dur);
}
function noiseBuffer(ctx, seconds) {
const n = Math.max(1, Math.floor(ctx.sampleRate * seconds));
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return buf;
}
11.3 The effect bank
playSound is a switch on the id, the mirror of the Odin SND_* constants. Bigger rocks get a lower, longer boom; smaller ones a shorter, brighter pop:
function playSound(id) {
if (sfxMuted) return;
const ctx = getCtx();
if (!ctx) return;
switch (id) {
case 0: blip(ctx, 660, 120, 0.12, 0.05, "square"); break; // fire
case 1: explosion(ctx, 0.55, 380, 0.50); break; // bang: large
case 2: explosion(ctx, 0.38, 700, 0.40); break; // bang: medium
case 3: explosion(ctx, 0.22, 1300, 0.30); break; // bang: small
case 4: explosion(ctx, 0.80, 300, 0.55); // ship death
blip(ctx, 300, 40, 0.70, 0.18, "sawtooth"); break;
case 5: blip(ctx, 440, 720, 0.16, 0.06, "triangle"); break; // wave clear
case 6: blip(ctx, 1400, 180, 0.18, 0.05, "sine"); break; // hyperspace
case 7: explosion(ctx, 0.12, 220, 0.22); break; // asteroid thud
}
}
Gotcha. The ids here and the
SND_*constants ingame.odinare one list in two places: the same lockstep discipline as the renderer's opcodes. Reorder one side and the gun starts sounding like an explosion.
11.4 The looping thrust rumble
The thruster is a sustained sound, so it can't be a one-shot. We start a looping noise source when thrust turns on and ramp it down when it turns off:
function setThrust(on) {
if (on) {
if (sfxMuted) return;
const ctx = getCtx();
if (!ctx || thrustSrc) return; // already running
const src = ctx.createBufferSource(); src.buffer = noiseBuffer(ctx, 1.0); src.loop = true;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 220;
const g = ctx.createGain();
const t = ctx.currentTime;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.05, t + 0.05);
src.connect(lp).connect(g).connect(ctx.destination);
src.start(t);
thrustSrc = src; thrustGain = g;
} else if (thrustSrc) {
const t = audioCtx.currentTime;
thrustGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.08);
thrustSrc.stop(t + 0.12);
thrustSrc = null; thrustGain = null;
}
}
The game only calls set_thrust when the state changes (recall update_thrust_sound from Chapter 7's file), so JS is never asked to start an already-running rumble:
update_thrust_sound :: proc() {
want := ship.alive && ship.thrust && !game_over
if want != thrust_snd_on {
thrust_snd_on = want
set_thrust(want ? 1 : 0)
}
}
11.5 The gesture rule, and muting
let audioCtx = null;
function getCtx() {
if (!audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
audioCtx = new AC();
}
if (audioCtx.state === "suspended") audioCtx.resume();
return audioCtx;
}
Gotcha. Browsers start an
AudioContextsuspended and only let it start on a user gesture. If you create it on page load, it stays silent. We callgetCtx()on the first keypress to unlock it:window.addEventListener("keydown", (e) => { getCtx(); /* ...then handle the key... */ });
A mute toggle just sets a flag every effect checks (and cuts the thrust loop):
function setSfxMuted(m) { sfxMuted = m; if (sfxMuted) setThrust(false); /* update button */ }
11.6 A note on background music
The production version of this game layers a spacey ambient pad on a dedicated AudioWorklet thread so it can't disturb the frame rate. It's a lovely addition and entirely optional; we keep the core game to on-demand oscillators to stay simple and self-contained. Appendix C sketches the worklet approach if you want to add it.
Checkpoint
Every shot, hit, death, wave and thrust has a synthesised voice, no audio files anywhere, with a mute toggle and correct gesture-gated startup. The SND_* ids stay in lockstep across Odin and JS.
Exercises
- Give the fire blip a tiny random pitch each shot so rapid fire doesn't sound mechanical.
- Add a distinct "extra life" jingle and fire it from wherever you awarded the extra life in Chapter 10's exercises.
- Route every effect through one master
GainNodeand add a volume slider to the footer.
Further reading
- MDN: Web Audio API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
- MDN: OscillatorNode / GainNode: https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode
- MDN: AudioContext & the autoplay policy: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext