Typing Sound Effects Without a Single Audio File
A KeyDo postmortem: hit, miss, kill, level-up, game-over — a full set of 8-bit sound effects, zero audio assets, all synthesized live by Web Audio oscillators.
KeyDo is a retro-arcade typing game: a “tick” on a correct key, a “buzz” on a miss, a “ding-dong” when you destroy a word, a rising arpeggio on level-up, a downward drop on game over. The whole set of effects — not one .mp3, .wav, or .ogg. The entire sound.js is 45 lines, and every sound is computed on the spot with an oscillator.
A blip is one oscillation
In Web Audio, the most basic sound unit is an OscillatorNode — give it a frequency and waveform, and it emits the corresponding pure tone. Add a GainNode to shape the volume envelope, wire them to the speakers, and you get a “beep”:
function blip(freq, duration = 0.06, type = 'square', gain = 0.04) {
if (muted) return
const a = ac()
const osc = a.createOscillator()
const g = a.createGain()
osc.type = type // square / sawtooth …
osc.frequency.value = freq
g.gain.setValueAtTime(gain, a.currentTime)
g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + duration)
osc.connect(g).connect(a.destination)
osc.start()
osc.stop(a.currentTime + duration)
}
The key is those two gain lines: set a small starting volume, then use exponentialRampToValueAtTime to decay exponentially toward 0 over duration. That “start-and-decay” envelope is exactly where the short, punchy “beep” of 8-bit sound comes from — a pure tone with no envelope sounds as dry and grating as a buzzer; the decay is what makes it a note. Exponential decay is closer to a real musical tone’s natural fade than a linear one.
The waveform matters too: square is bright, like an NES lead voice; sawtooth is grittier, noisier, fitting negative feedback like “miss” or “life lost.” Same oscillator, swap the waveform and frequency, and the timbre changes.
Composing a “phrase” from frequency and timing
A single blip is one note. Several blips offset by setTimeout become a little melody:
export const sfx = {
hit: () => blip(880, 0.04), // hit: high and short
miss: () => blip(160, 0.12, 'sawtooth', 0.05), // miss: low sawtooth
kill: () => { blip(660, 0.05); setTimeout(() => blip(990, 0.07), 50) }, // kill: two notes rising
levelUp:() => { blip(523, 0.08); setTimeout(() => blip(659, 0.08), 90);
setTimeout(() => blip(784, 0.12), 180) }, // level-up: do-mi-so arpeggio
gameOver:() => { blip(330, 0.15, 'sawtooth');
setTimeout(() => blip(220, 0.3, 'sawtooth'), 160) }, // over: two notes falling
}
The pattern is plain: high frequency = positive (hit, kill), low frequency = negative (miss, life lost); a rising scale = progress (level-up uses 523/659/784, exactly do-mi-so), falling = failure. That levelUp run is a major-triad arpeggio, and it just sounds like “you leveled up.” These numbers aren’t random — they’re tuned to pitch relationships — but there isn’t a single audio asset in the whole file, just numbers.
Two unavoidable browser traps
One: the AudioContext must be lazily created. Browser autoplay policy won’t let a page make sound the instant it loads, so an AudioContext gets created in a suspended state. So don’t new it at module top level — create it on the first sound, and resume() while you’re at it:
function ac() {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)()
if (ctx.state === 'suspended') ctx.resume()
return ctx
}
The first sound is guaranteed to come after a keypress (it’s a typing game), so resume() rides a user gesture and the browser allows it. The window.AudioContext || window.webkitAudioContext also covers older prefixed Safari.
Two: degrade silently when audio is unavailable. The whole blip is wrapped in a try/catch that does nothing. In some environments Web Audio may throw (a restricted iframe, a policy block), but sound is a nice-to-have and must never crash the game just because it can’t play. The mute flag muted lives in localStorage, and blip short-circuits with an if (muted) return at the top — muted, it doesn’t even create the oscillator.
Why skipping audio files is worth it
- Zero assets, zero requests: no audio files to bundle, download, or wait on. The whole game’s sound weighs exactly these 45 lines of code, and the first paint makes no audio network request;
- Tweakable on the fly: want the hit sound a bit higher? Change a number. Want a new effect? Compose a few
blips. No opening audio software, exporting, compressing, replacing files; - A natural fit for CRT arcade character: 8-bit chiptune was oscillator-synthesized to begin with, so recreating that timbre with Web Audio is more authentic than hunting down a “pixel-art SFX pack.”
Takeaways
Giving a typing game a full set of sound effects with Web Audio, without one audio file:
- A blip = oscillator + gain envelope: the
exponentialRampToValueAtTimedecay is the key to the “beep” feel; without an envelope it’s a grating buzz; - Waveform picks timbre: square is bright for positive feedback, sawtooth gritty for negative;
- Compose melodies from frequency and timing: high/rising for positive, low/falling for negative,
setTimeoutto string arpeggios; - Lazy load + resume on a user gesture: get around autoplay policy, riding the first sound on a keypress;
- Degrade silently: a
try/catchbackstop, so failing to play never drags down the game.
In one line: sound effects don’t have to be “assets” — they can be “code”: 45 lines of numbers is a whole arcade’s worth of sound.
Comments