Yo Odin ยท Chapter 11

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, and ctx.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 in game.odin are 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 AudioContext suspended and only let it start on a user gesture. If you create it on page load, it stays silent. We call getCtx() 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

  1. Give the fire blip a tiny random pitch each shot so rapid fire doesn't sound mechanical.
  2. Add a distinct "extra life" jingle and fire it from wherever you awarded the extra life in Chapter 10's exercises.
  3. Route every effect through one master GainNode and add a volume slider to the footer.

Further reading