intermediate · 23 minute study
Audio is a resource graph with a lifecycle
Silent music, stacked sound effects and noise after leaving a game often come from ownership and browser lifecycle mistakes, not from the volume setting.
Useful preparation · 1 prerequisite
Use this as a map, not a lock. If these ideas are familiar, continue.
- Turn a difficult playtest into a reproducible experiment — A bug reported as 'sometimes the game ends twice' is expensive until you can replay the exact inputs. Deterministic simulation turns a visual anecdote into a testable sequence.
First, build a mental model
An audio graph connects sources through controls to an output. A source has a lifetime, the context has a state, and the player has preferences. Treat all three as explicit state rather than a sound command that always succeeds.
- Start or resume audio from a user gesture according to browser autoplay policy. A suspended context can have a correctly connected graph and still produce no sound. Surface a failed resume instead of claiming playback began.
- Separate music and effects through gain nodes so a player's settings can control each channel. Changing a visual mute icon must update the actual audio graph as well as the saved preference.
- A scheduled oscillator or buffer source is not a reusable play button. Create the appropriate new source for a new sound; reuse shared decoded data or channel nodes when ownership allows it.
- Use an envelope to avoid sudden jumps in amplitude. Schedule against the audio context's clock for audio timing; do not assume an animation-frame callback is an accurate audio scheduler.
- Stop and disconnect owned sources when they end or their scene is disposed. Define what pause, blur and navigation mean for music and effects. Do not close a context still shared by another owner.
let audio;
async function playBeep() {
audio ??= new AudioContext();
if (audio.state === "suspended") await audio.resume();
const source = audio.createOscillator();
const gain = audio.createGain();
const start = audio.currentTime;
source.frequency.value = 440;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.08, start + 0.01);
gain.gain.linearRampToValueAtTime(0, start + 0.15);
source.connect(gain).connect(audio.destination);
source.addEventListener("ended", () => { source.disconnect(); gain.disconnect(); });
source.start(start);
source.stop(start + 0.16);
}
// Call playBeep from a labelled user-gesture control and handle rejection.
// The lesson does not execute or autoplay this audio.- Gesture
A player chooses to play. The function creates or resumes the context before scheduling a source.
- Envelope
The gain rises briefly, then falls to zero. Scheduling uses audio.currentTime rather than frame count.
- End
The source stops and its owned graph connections are released. Shared context ownership is preserved.
Production thinking
Take this beyond the example.
Provide accessible mute and volume controls, preserve preferences, and avoid autoplaying the lesson or game at full volume. Test first play, resume, navigation, repeated scene entry and failure to load. Listening is still required to assess balance; a source-code check cannot establish that music sounds right.
Sources and further reading
Your evidence
unseen is a local learning signal, not a credential or proof of production competence.
Explain the mental model, then explicitly acknowledge it. Opening a page is not evidence.
Supported retries can complete this lesson. Independent evidence and delayed review determine the skill stage.
Next lesson · A save is untrusted, versioned input →