Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A Synthesizer in the Box

Mar does not play audio files. It ships a chip-tune synthesizer: you describe a sound as a value, the runtime synthesizes it, on the web through the Web Audio API and on iOS through a native audio engine. The same philosophy as the canvas, applied to air.

If you have never designed a sound effect, good news: the entire craft of 8-bit sound fits in one chapter, because the machines it imitates had exactly these knobs and no others.

Voices

The atom is a tone: a waveform at a frequency for a duration.

Sound.tone Sound.Square 440 120     -- wave, Hz, milliseconds

Four waveforms, four personalities:

  • Sound.Square: bright and buzzy. Leads, beeps, coins, UI.
  • Sound.Triangle: soft and round. Bass lines, thumps, gentle chimes.
  • Sound.Sawtooth: harsh and brassy. Alarms, engines, aggression.
  • Sound.Noise: pitched static. Drums, explosions, wind, impacts.

Modifier functions wrap a tone and shape it (each patches the most recent voice, so they chain naturally):

Sound.volume 40 s          -- 0..100 (default 60)
Sound.duty 25 s            -- Square only: pulse width %, 12 / 25 / 50 are the classic three
Sound.sweep 880 s          -- glide the pitch to 880 Hz across the duration
Sound.hold 60 s            -- sit at the base pitch for 60 ms, then glide (needs a sweep)
Sound.vibrato 12 6 s       -- wobble: depth in cents, rate in Hz
Sound.arp [ 659, 784 ] s   -- cycle base + these pitches fast: a chord from one voice

sweep is the soul of retro sound effects: pitch going up reads as positive (jump, powerup, spawn), pitch going down reads as negative or physical (hit, fall, engine). duty is timbre for free: 50 is hollow, 25 is classic NES lead, 12 is thin and reedy.

Pitch helpers spare you the frequency table: Sound.a 4 is 440, Sound.c 5, Sound.fs 3 and friends cover the scale (naturals plus sharps, cs ds fs gs; spell flats as sharps).

Words and chords

Two combinators build everything else:

Sound.sequence [ s1, Sound.rest 80, s2 ]   -- one after another (rest = silence)
Sound.chord [ s1, s2, s3 ]                 -- at the same time

A sound effect is usually a short sequence; a musical hit is a small chord; a song (next chapter) is a chord of long sequences. Since these are ordinary values, name them at the top level and your whole sound design is a readable catalog.

Playing: cues from the world

Sound.play : Sound -> Cmd Msg fires a one-shot. The naive wiring plays sounds directly wherever something happens in update, and it works until your step function (pure, called by the tick) is the thing that knows a foe just died.

The pattern every action game converges on: step collects cues; update spends them. Give the world a list of cue values, have the simulation append to it, and have the tick handler turn them into commands and clear the list:

type Cue
    = CJump | CPickup | CHit | CBoom


Tick ->
    let
        next = step model
    in
    ( { next | cues = [] }
    , Cmd.batch (List.map (\c -> Sound.play (cueSound c)) next.cues)
    )

This keeps the simulation pure and testable (a test can assert “wave 3 emits exactly two CBoom”), survives replay and time travel cleanly, and gives you one place to implement muting, cue deduplication (“at most one hit sound per tick”, so a shotgun blast is not eleven overlapping clicks), and volume ducking. The card game and the shmup both ship this exact shape.

Muting is a command too: Sound.setMuted True. Keep the flag in your Model, flip both together, and put the button on the title screen; phones without a mute toggle are antisocial.

A starter cookbook

Six recipes tuned by ear in the example games; steal, then season. Volumes are deliberately modest: frequent sounds in the 15–40 range, rare payoffs louder.

sfxJump : Sound
sfxJump =
    Sound.volume 35 (Sound.sweep 620 (Sound.duty 25 (Sound.tone Sound.Square 250 90)))


sfxPickup : Sound
sfxPickup =
    Sound.sequence
        [ Sound.volume 30 (Sound.duty 25 (Sound.tone Sound.Square (Sound.e 5) 55))
        , Sound.volume 30 (Sound.duty 25 (Sound.tone Sound.Square (Sound.a 5) 90))
        ]


sfxHit : Sound
sfxHit =
    Sound.chord
        [ Sound.volume 45 (Sound.tone Sound.Noise 400 70)
        , Sound.volume 30 (Sound.sweep 60 (Sound.tone Sound.Triangle 160 90))
        ]


sfxBoom : Sound
sfxBoom =
    Sound.chord
        [ Sound.volume 60 (Sound.sweep 45 (Sound.tone Sound.Noise 900 320))
        , Sound.volume 35 (Sound.sweep 30 (Sound.tone Sound.Triangle 110 300))
        ]


sfxLaser : Sound
sfxLaser =
    Sound.volume 30 (Sound.sweep 140 (Sound.duty 12 (Sound.tone Sound.Square 980 110)))


sfxUiMove : Sound
sfxUiMove =
    Sound.volume 18 (Sound.duty 12 (Sound.tone Sound.Square (Sound.c 6) 35))

The design grammar behind them: direction of sweep = emotional sign; noise = matter; triangle underneath = weight; two quick notes up = reward. Almost every effect you need is a combination of those four sentences.

Mixing discipline

The synthesizer will happily let twenty voices shout. Rules of thumb from the shipped games:

  • One sound per event, not per entity: gate duplicate cues per tick (the cue list makes this a List question).
  • Leave headroom: if everything is at volume 80, nothing is loud. HUD blips at 15–20, gameplay at 30–45, payoffs at 60+.
  • Duck, do not stack: when the big boom plays, skip the small hits that tick.
  • Respect silence. A sound on every tick reads as noise; sounds on meaningful edges read as music.

Speaking of music: it gets its own chapter, because loops have one rule that will save you a mystifying afternoon.