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
Listquestion). - 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.