Director’s commentary
No audio files
Every tune, sound effect and background bed in the game is synthesised in the browser as it plays: a tracker-style sequencer, recipes for every sound, ambience beds, ducking under narration, and unlocking on the first tap.
There isn't a single audio file in this game. Every bird, key click, disk-drive seek, crowd roar and tune is made in your browser at the moment it plays, out of oscillators, filtered noise and envelopes. It started as a stopgap and turned into something I'm fond of: the whole soundtrack costs nothing to download and needs no licences.
Why there are no files yet
The spec imagines something grander: tracker music played through a WebAssembly module player for the Amiga chapters, sound effects from licensed libraries or recorded for the site, and maybe my own voice for the narrator. None of that exists yet. The choice was between shipping silence, shipping borrowed sounds (and nothing is ripped from real games, ever), or synthesising everything. So the engine synthesises everything, behind one interface, the AudioBus, which is also where recorded narration and real module playback will plug in later without a chapter noticing.
A tracker in a string
The music is written the way tracker music was: a grid of steps, one channel per instrument, a note or nothing on each step. Here a channel is just a string. A cell is a note, a dot to let the previous note ring, a dash for note off, or drum letters on the drum channel. Chords use a tracker's arpeggio notation, a root plus hex semitone offsets, and by default they cycle 50 times a second, the Amiga's screen refresh rate, which is where that shimmering chiptune chord sound comes from.
/**
* Tracker-style song data and the pure logic that turns it into timed events.
* No WebAudio in here, so it runs (and is tested) in node.
*
* A track is a string of whitespace-separated cells, one per step:
* C4 F#3 Bb2 a note (scientific pitch, C4 = middle C)
* C4+47 A3+37a a chord: tracker 0xy arpeggio offsets in hex semitones
* . empty: the previous note keeps sounding
* - note off
* k s h o t c x r drum hits (drums channel only), combinable ("kh"); upper case = accent
* tok*4 repeat a cell four times
* | ignored, handy as a bar line
*/Every song is original and small: eight to sixteen bars in four channels, lead, bass, drums and arpeggio. Disk 1's tune runs at 125 beats a minute, the default tempo of the trackers of the time, and a test validates every song's structure, so a typo in a pattern fails CI rather than a chapter.
// Disk 1: a chirpy Amiga-era tracker tune, 125 bpm like a tracker at speed 6.
"type-in": {
title: "Ten PRINT",
bpm: 125, beat: 4, bar: 16,
echo: { steps: 3, feedback: 0.25 },
voices: {
lead: { duty: 0.125, gain: 0.11, send: 0.25, decay: 0.12, sustain: 0.5 },
bass: { wave: "pulse", duty: 0.5, cutoff: 900, gain: 0.2, decay: 0.12, sustain: 0.4 },
arp: { duty: 0.25, gain: 0.05, decay: 0.2, sustain: 0.2 },
drums: { kit: "chip", gain: 0.45 },
},
patterns: {
a: {
lead: "D5 . G5 . B5 . A5 G5 A5 . B5 . G5 . . . | E5 . G5 . B5 . A5 G5 F#5 . E5 . D5 . . .",
bass: "G2 . G3 . D3 . G3 . G2 . G3 . B2 . D3 . | E2 . E3 . B2 . E3 . E2 . E3 . G2 . B2 .",
arp: "G4+47 . . - . . G4+47 . . - . . G4+47 . . . | E4+37 . . - . . E4+37 . . - . . E4+37 . . .",
drums: "k . h . s . h k . k h . s . h h | k . h . s . h k . k h . s . h h",
},Songs are scheduled ahead rather than played in real time. A clock ticks every 25 milliseconds and schedules every note due in the next 150 on the audio hardware's own timeline, so the music stays in time even when the page is busy. The clock only runs while audio is actually running and the tab is visible, so nothing piles up while you're away.
Sound effects as recipes
Each sound effect is a short recipe rather than a recording. A bite is eight tiny grains of band-passed noise, randomly spaced a few milliseconds apart, over a soft low thump, which is a surprisingly convincing crunch. Because the grains are randomised every time, ten bites never sound like the same sample played ten times.
bite: (s) => {
// crunch: a cluster of short bandpassed noise grains over a soft thump
for (let i = 0, at = 0; i < 8; i++, at += rand(0.008, 0.025)) {
s.hiss(at, { dur: rand(0.015, 0.04), f: rand(1800, 3800), q: 1.6, gain: rand(0.2, 0.42), attack: 0.001 });
}
s.hiss(0, { kind: "pink", dur: 0.14, band: "lowpass", f: 900, gain: 0.3 });
s.tone(0, { f: [170, 90], dur: 0.07, gain: 0.15 });
},
// …
/** A distant electric school bell: inharmonic partials struck ~17 times a second. */
export const schoolBell = (s: Shot, at: number, gain = 1) => {
const base = rand(1150, 1300), ring = rand(2, 3);
for (const [p, a] of [[1, 1], [2.76, 0.35], [5.4, 0.12]] as const) {
s.tone(at, { f: base * p, dur: ring, env: "hold", attack: 0.02, gain: 0.05 * a * gain, am: [17, 0.85], filter: ["lowpass", 2600] });
// the ring-out crossfades in under the last strikes rather than starting cold
s.tone(at + ring - 0.04, { f: base * p, dur: 1.4, attack: 0.06, gain: 0.03 * a * gain, filter: ["lowpass", 2600] });
s.tone(at + 0.19, { f: base * p, dur: ring, env: "hold", attack: 0.05, gain: 0.015 * a * gain, am: [17, 0.85], filter: ["lowpass", 1800] });
}
};The school bell does the most work for its size. It's three inharmonic partials, the slightly clashing overtones a real bell has, struck about seventeen times a second by amplitude modulation, which is roughly how an electric bell's hammer behaves. In the prologue it rings far away every minute or so. My brother and sister are at school, and that bell is the only sign of them.
Beds, ducking and the first tap
Each place has an ambience bed: a few continuous layers plus sparse, randomised events. The doorstep is a breeze and a low road rumble, birds every few seconds, a car passing now and then, and the distant bell. The school IT suite has a CRT whine at 15,625 Hz, the line rate of a PAL television, which younger visitors will hear and older ones won't, exactly as it was.
doorstep: {
layers(r) {
const breeze = r.noise("pink", 0.03, ["lowpass", 700]);
r.drift(breeze.gain.gain, 0.3, 0.02);
const road = r.noise("brown", 0.05, ["lowpass", 260]);
r.drift(road.gain.gain, 0.2, 0.03);
},
events: [
birds([0.6, 3], 0.06),
{ every: [8, 16], play: (s) => { for (let i = 0; i < 3; i++) birdCall(s, rand(0, 1), rand(-0.8, 0.8), 0.04); } },
cars([6, 15], 0.45),
{ every: [45, 90], first: [10, 18], play: (s) => schoolBell(s, 0, 0.3) },
],
},Music and ambience run through a ducking stage that drops them to 40 percent while a line of narration is on screen and brings them back when you move on. Sound effects skip the duck, so your own actions stay crisp.
Browsers won't play sound until you've touched the page, and iOS is stricter still: it only unlocks audio output if something actually plays inside the gesture. So the first tap or key press plays one sample of silence, then asks the audio context to resume. That request can hang without a gesture behind it, so it races a half-second timeout rather than stalling the game.
async unlock() {
if (typeof AudioContext === "undefined") return;
const ac = this.ensureContext();
if (ac.state !== "running" && !this.paused) {
// iOS only unlocks output once something plays inside the gesture
const src = ac.createBufferSource();
src.buffer = ac.createBuffer(1, 1, ac.sampleRate);
src.connect(ac.destination);
src.start();
// resume() can stay pending without a gesture; don't hang the caller on it
await Promise.race([ac.resume().catch(() => {}), new Promise((r) => setTimeout(r, 500))]);
}
if (ac.state === "running") this.activate();
}What's next
An ear pass. The first round of tuning was done by rendering every sound offline and reading its levels and spectrogram on the audio dev page, which catches clipping and harshness but not whether a crunch sounds like bread. Beyond that, the Amiga chapters deserve real module files, and if I do record the narration myself, it'll arrive as the first actual audio files in the game. Panning is left and right only for now; nothing in the game is placed in 3D space by sound, and a doorstep with a car passing behind you would be nicer if it were.