Web Audio · Frontend · KeyDo

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 exponentialRampToValueAtTime decay 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, setTimeout to string arpeggios;
  • Lazy load + resume on a user gesture: get around autoplay policy, riding the first sound on a keypress;
  • Degrade silently: a try/catch backstop, 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

  • Loading…

Comments are reviewed before publishing; email is visible only to me.