Music and sound effects with no audio files: synthesising everything in Web Audio for browser games
DEV Community

Music and sound effects with no audio files: synthesising everything in Web Audio for browser games

We make free browser games at Ant Games (our own site: no ads, no purchases, no sign-up). None of them ships an audio file.

Every sound effect and every piece of music is built in code with the Web Audio API, which keeps each game a single self-contained page and lets the music react to play.

This post covers the techniques we actually use, with snippets trimmed from the audio code of three of our games: JETSUN (plain JavaScript), STAR AGAINST THE DARK and SALLY PORT (TypeScript). It is not a Web Audio tutorial; it assumes you know what an OscillatorNode is.

A tiny kit of voice helpers

Almost every sound effect is built from the same few blocks. JETSUN wraps them in four functions: a swept oscillator, filtered noise, two-operator FM and a "musical note".

The workhorse:

// percussive envelope: attack, decay to -30 dB over `dur`, 20 ms release
function perc ( a , param , t , att , dur , vol ) {
  a . env ( param , t , [ [ 0 , 0.0001 ] , [ att , vol ] , [ dur , vol * 0.03 ] , [ dur + 0.02 , 0.0001 ] ] ) ;
}

function tone ( a , t , out , type , f0 , f1 , dur , vol = 1 , att = 0.002 ) {
  const g = a . gain ( 0 , out ) ;
  perc ( a , g . gain , t , att , dur , vol ) ;
  const o = a . osc ( type , f0 , t , dur , g ) ;
  if ( f1 !== f0 ) a . env ( o . frequency , t , [ [ 0 , f0 ] , [ dur , f1 ] ] ) ; // pitch sweep
  return o ;
}

Two details matter. Envelopes ramp to 0.0001, never to 0, because exponential ramps cannot reach zero. And every helper takes the start time t explicitly instead of using "now", so a recipe can be placed anywhere on the audio clock.

a.osc() creates the oscillator, starts it and schedules its stop, so nothing leaks.

A rapid-fire machine gun is then a swept square wave plus a short noise burst:

defineSound ( 'p.vulcan' , {
  vol : 0.3 ,
  minGap : 0.045 ,
  play ( a , t , out , o ) {
    const p = ( o . pitch || 1 ) * R ( 0.96 , 1.04 ) ; // random pitch per shot
    tone ( a , t , a . filter ( 'lowpass' , 2600 , 0.7 , out ) , 'square' , 430 * p , 120 * p , 0.07 , 0.8 , 0.001 ) ;
    hiss ( a , t , out , 'bandpass' , 3200 , 1400 , 0.035 , 0.5 , 0.9 , 0.001 ) ;
  } ,
} ) ;

The pitch jitter stops repeated shots from sounding like one sample on loop, and minGap stops a sound from stacking up when it is triggered every frame.

FM is just as short. The modulator's gain is the modulation depth, and it decays with the note, so the timbre goes from bright to dull the way a struck bell or a zap does:

const c = a . osc ( type , fc , t , dur , g ) ; // carrier
const depth = a . gain ( fc * ratio * index ) ;
a . env ( depth . gain , t , [ [ 0 , fc * ratio * index ] , [ dur , 1 ] ] ) ;
a . osc ( 'sine' , fc * ratio , t , dur , depth ) ; // modulator
depth . connect ( c . frequency ) ; // audio-rate frequency modulation

One shared noise buffer

Noise is everywhere: explosions, hi-hats, wind, shots. Instead of a buffer per sound, create two seconds of white noise once and share it:

const nb = ctx . createBuffer ( 1 , ctx . sampleRate * 2 , ctx . sampleRate ) , nd = nb . getChannelData ( 0 ) ;
for ( let i = 0 ; i < nd . length ; i ++ ) nd [ i ] = Math . random ( ) * 2 - 1 ;

Each voice plays that buffer through its own filter. For endless wind or engine loops set loop = true and start at a random offset (s.start(t, Math.random())) so two loops never line up in phase.

SALLY PORT does the same but fills its buffer from a seeded PRNG (new Rng(99)), so the noise is identical on every run, which matters for section 7.

A reverb with no impulse-response file

A convolution reverb normally needs a recorded impulse response. You can generate one: noise multiplied by a decay curve is a perfectly decent small hall.

const len = Math . floor ( ctx . sampleRate * 2.2 ) , ir = ctx . createBuffer ( 2 , len , ctx . sampleRate ) ;
for ( let ch = 0 ; ch < 2 ; ch ++ ) {
  const d = ir . getChannelData ( ch ) ;
  for ( let i = 0 ; i < len ; i ++ ) d [ i ] = ( Math . random ( ) * 2 - 1 ) * Math . pow ( 1 - i / len , 2.6 ) ;
}
reverb = ctx . createConvolver ( ) ;
reverb . buffer = ir ;

Independent noise per channel gives a wide stereo tail for free. Each sound declares how much it sends (reverb: 0.3 for the railgun, nothing for the machine gun) and the engine wires a send gain to the shared convolver.

A music sequencer that schedules ahead on the audio clock

setInterval is far too jittery to time notes. The standard answer is a look-ahead scheduler: a timer wakes up often, and each time schedules every note that falls inside the next fraction of a second, using exact AudioContext times.

JETSUN's music kit does this:

tick ( now ) { // called ~40 times a second
  if ( this . t < now - 0.1 )
    while ( this . t < now + 0.02 ) this . _next ( ) ; // stalled: skip along the grid, no burst
  while ( this . t < now + LOOK ) {
    this . _play ( this . t ) ;
    this . _next ( ) ;
  } // LOOK = 0.25 s
}

_play ( t ) {
  if ( this . step & 1 ) t += this . swing ; // swing the off-beat sixteenths
  for ( const layer of this . layers ) { /* play this step's event if the layer is on */ }
}

The "stalled" line matters. If a tab was in the background and the timer was throttled, this.t is far in the past, and a naive loop would fire hundreds of notes at once. Skipping along the grid without playing keeps the music in time.

Our tracks are data, not audio. A JETSUN tune is a string notation per bar plus a layer table:

const HOOK = "3 - - 5 8 - - - | 7 - - 6 5 - - - | 6 - - 8 3' - 9 - | ..." ;
bpm : 108 ,
key : 50 ,
scale : 'major' ,
layers : {
  kick : { inst : 'drum' , kit : 'kick' , vol : 0.7 , at : 0.38 , pat : 'X.....x.X.......' } ,
  snare : { inst : 'drum' , kit : 'snare' , vol : 0.42 , at : 0.42 , pat : '....X.......X...' } ,
  lead : { inst : 'lead' , vol : 0.27 , wave : 'sawtooth' , wave2 : 'square' , det : 6 , vib : 7 , at : 0.33 } ,
}

Notes are scale degrees, not frequencies, so a track changes key by changing one number. The at field is the intensity at which a layer comes in: the game calls setIntensity(0..1) and the arrangement thickens (pad and bells when calm, drums and lead as danger rises) without restarting the track.

Custom waveforms with createPeriodicWave

The four built-in oscillator types are limiting for pads. STAR AGAINST THE DARK builds its own from harmonic recipes: amplitude falling as 1 / h^power is soft for a high power and bright for a low one, and dropping the even harmonics gives a hollow, reedy tone.

const wave = ( n : number , power : number , oddOnly : boolean ) : PeriodicWave => {
  const re = new Float32Array ( n + 1 ) , im = new Float32Array ( n + 1 ) ;
  for ( let h = 1 ; h <= n ; h ++ ) im [ h ] = oddOnly && h % 2 === 0 ? 0.12 / Math . pow ( h , power ) : 1 / Math . pow ( h , power ) ;
  return ctx . createPeriodicWave ( re , im ) ;
} ;
this . waves = [ wave ( 10 , 1.7 , false ) , wave ( 14 , 1.35 , false ) , wave ( 15 , 1.25 , true ) , wave ( 20 , 1.1 , false ) ] ;
// later: oscillator.setPeriodicWave(this.waves[st.wave]);

Four recipes from soft to bright, and the music state picks one. No sample files, a handful of lines.

The master chain: limiter, then a soft clip

With dozens of voices the output clips unless you plan for it. STAR AGAINST THE DARK ends its chain with a compressor used as a limiter, a trim gain and a WaveShaper soft clip:

master . connect ( limiter ) . connect ( trim ) . connect ( clip ) . connect ( out ) ;
out . connect ( ctx . destination ) ;

Two notes from that file. First, a DynamicsCompressorNode applies automatic makeup gain, so the trim value is computed from the compressor's settings rather than guessed. Second, a Blink quirk noted in the code: the compressor starts fully closed and only opens while it is being fed, so the first sound after start-up lost its attack. The fix is to feed it an inaudible 20 Hz tone for a moment:

const warm = ctx . createOscillator ( ) ;
warm . frequency . value = 20 ;
const warmGain = ctx . createGain ( ) ;
warmGain . gain . value = 1e-5 ;
warm . connect ( warmGain ) . connect ( limiter ) ;
warm . start ( ) ;
warm . stop ( ctx . currentTime + WARMUP ) ;

SALLY PORT and JETSUN use a simpler master (a compressor into the destination). Separate buses for music and effects, each with its own volume and a mute, are what make a game's settings screen work. SALLY PORT also has a duck gain on the music bus, eased with setTargetAtTime(on ? 0.3 : 1, t, 0.15), so music drops while a menu is open.

Test it offline, render it deterministically

Because the graph is built on a plain BaseAudioContext, the same code runs on an OfflineAudioContext. STAR AGAINST THE DARK has an audio checker that renders each sound and each music state to a buffer with four output channels: the final output, and the mix just before the limiter, so you can see what the limiter did.

const ctx = new OfflineAudioContext ( { numberOfChannels : 4 , length , sampleRate : 48000 } ) ;
const core = new AudioCore ( ctx , { seed , sink } ) ; // the same graph the realtime engine builds

Two ingredients make this useful: a seeded PRNG (the file uses mulberry32) in place of Math.random(), so a render is reproducible, and measurement of the result (peak, loudness, what the limiter removed), so a sound that is too quiet or clipping fails a check instead of waiting for a player to notice.

Tie the visuals to the audio clock

SALLY PORT has one more trick worth stealing. Its beat() method returns 1 on each beat, falling to 0 before the next, computed from ctx.currentTime rather than a frame counter:

const ph = ( ctx . currentTime - this . beatT ) / this . beatDur ;

Anything on screen that pulses (a light, a bar, a camera nudge) reads it and stays in time with what you hear, even if the frame rate stutters.

Practical notes

  • Unlock on a user gesture. Create or resume the context on the first pointerdown or keydown. JETSUN does both, and suspends the context when document.hidden.
  • Seed anything you want to test. Use a seeded PRNG for sound design that has to be reproducible.
  • Disconnect what you create. We track live sources and disconnect them when they end.
  • Rate-limit every sound. A per-sound minimum gap costs one comparison and saves your mix.

The trade-off is real: code-made audio costs design time, and it will not sound like a recorded orchestra. What it gives you is a game that is one file, loads instantly, runs offline, and whose music can respond to play in ways a fixed recording cannot.

To hear the results, JETSUN and STAR AGAINST THE DARK are playable in the browser, and the rest are on the Ant Games home page. The site and each game's page are available in English and Simplified Chinese. Questions about any of the techniques above are welcome in the comments.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.