Downstroke Audio
Table of Contents
Downstroke's audio stack is a thin wrapper around SDL2's SDL_mixer library.
It gives you two things: short sound effects (one-shots triggered on input,
collisions, pickups, etc.) and a single streaming music track (a looping
background song). The friendly API lives in the (downstroke sound) module and
is the only thing most games ever need to touch.
Audio is deliberately kept outside the game record. The sound module
holds its own module-level registry of loaded chunks and a reference to the
currently-loaded music. You call into it imperatively from your lifecycle
hooks — there is no (game-audio game) accessor, and the engine does not
manage audio for you.
1. The minimum you need
(import (downstroke engine)
(downstroke sound))
(define *music-on?* #f)
(define my-game
(make-game
title: "Audio Example" width: 320 height: 240
preload: (lambda (game)
(init-audio!)
(load-sounds! '((jump . "assets/jump.wav")
(hit . "assets/hit.wav")))
(load-music! "assets/theme.ogg")
(play-music! 0.6)
(set! *music-on?* #t))
update: (lambda (game dt)
(let ((input (game-input game)))
(when (input-pressed? input 'a)
(play-sound 'jump))))))
(game-run! my-game)
Four calls cover ~95% of real usage:
(init-audio!)— open the device (once, at startup).(load-sounds! '((name . path) ...))— preload WAV effects.(play-sound name)— trigger a one-shot.(play-music! volume)— start the looping music track.
2. Core concepts
2.1. The two-layer audio API
Audio is split into two modules, and you pick the one that matches what you are doing:
(downstroke sound)— the friendly, high-level API. Symbolic sound names, an internal registry, volume given as0.0..1.0, music that just loops. This is what the rest of this document documents, and what every demo uses.(downstroke mixer)— raw FFI bindings toSDL_mixer(Mix_OpenAudio,Mix_LoadWAV,Mix_PlayChannel,Mix_LoadMUS,Mix_PlayMusic,Mix_VolumeMusic, …). No registry, no convenience, no type conversions. Values are raw C-level integers (volumes are0..128, channels are integers, loops is an integer count with-1for forever).
Reach for (downstroke mixer) only when you need something the high-level
wrapper does not expose — for example, playing more than one concurrent music
track via channel groups, or fading effects. In practice, (downstroke sound)
covers 99% of cases; you almost never import (downstroke mixer) directly in
game code.
2.1.1. Module-level state (be aware of this)
(downstroke sound) keeps two global variables inside the module:
*sound-registry*— an association list of(symbol . Mix_Chunk*)pairs populated byload-sounds!.*music*— the currently-loadedMix_Music*pointer (or#f).
This means:
- There is exactly one audio device, one music track, and one sound registry per process.
- Calling
load-sounds!replaces the registry (it does not append). If you need to add sounds after the initial load, pass the full combined alist. - Calling
load-music!replaces*music*without freeing the previous track — usecleanup-audio!if you need to swap tracks cleanly, or drop into(downstroke mixer)and callmix-free-music!yourself. - Two games in the same process would share this state. That is not a
supported configuration; one
game-run!per process is the expectation.
2.2. Initialization & cleanup
Audio is not managed by the engine. game-run! initializes SDL's video,
joystick, game-controller, ttf, and image subsystems, but it does
not call init-audio! or cleanup-audio!. You are responsible for both.
2.2.1. init-audio!
Opens the mixer device at 44.1 kHz, default format, stereo, with a 512-sample
buffer. Must be called before load-sounds!, load-music!, play-sound,
or play-music! — otherwise SDL_mixer has no device to play on and every
load/play call silently fails.
The canonical place to call it is the top of your preload: hook:
preload: (lambda (game) (init-audio!) (load-sounds! ...) (load-music! ...))
init-audio! returns the raw Mix_OpenAudio result (0 on success,
negative on failure). The high-level API does not check this; if you want to
surface an error, capture the return value yourself.
2.2.2. cleanup-audio!
Halts music, frees the loaded music pointer, frees every chunk in the
registry, clears the registry, and closes the mixer. After this call the
module globals are back to their empty state; you could call init-audio!
again to restart.
There is no teardown hook. game-run! ends by calling sdl2:quit!, but
it does not invoke any user code on exit. If you care about cleanly shutting
down the audio device (you usually don't — the OS reclaims everything when
the process exits), you have to arrange it yourself after game-run!
returns:
(game-run! my-game) (cleanup-audio!) ;; only runs after the game loop exits
In the common case of a game that runs until the user presses Escape, this
is harmless but optional. The audio demo, notably, does not call
cleanup-audio! at all.
2.3. Sound effects
Sound effects are short WAV chunks — jumps, hits, pickups, UI blips. They are loaded once up front, kept resident in memory, and triggered by name.
2.3.1. (load-sounds! alist)
Takes an association list of (symbol . path) pairs. Each path is loaded
via Mix_LoadWAV and stored under its symbol key. Replaces the existing
registry wholesale.
(load-sounds! '((jump . "assets/jump.wav")
(hit . "assets/hit.wav")
(coin . "assets/coin.wav")
(death . "assets/death.wav")))
There is no error handling — if a file is missing, Mix_LoadWAV returns
NULL and the entry's cdr will be a null pointer. play-sound checks
for null and becomes a no-op in that case, so a missing asset gives you
silence rather than a crash.
2.3.2. (play-sound symbol)
Looks up symbol in the registry and plays the chunk on the first available
channel (Mix_PlayChannel -1), zero extra loops (one-shot). Unknown symbols
and null chunks are silently ignored.
(when (input-pressed? (game-input game) 'a) (play-sound 'jump))
SDLmixer defaults to 8 simultaneous channels. If all channels are busy,
the new sound is dropped. For most 2D games this is plenty; if you need
more, use (downstroke mixer) directly and call Mix_AllocateChannels.
Volume on individual effects is not exposed by the high-level API — every
chunk plays at the mixer's current chunk volume. If you need per-sound
volume control, reach for the raw mix-play-channel and the SDL_mixer
Mix_VolumeChunk function.
2.4. Music
Exactly one music track is playable at a time. "Music" in this API means a
streamed file (OGG, MP3, etc. — whatever Mix_LoadMUS accepts on your
system), as opposed to a fully-loaded WAV chunk.
2.4.1. (load-music! path)
Loads the track via Mix_LoadMUS and stores it in *music*. Does not play
it. Replaces any previously-loaded track without freeing the previous one
(see the warning in Module-level state).
(load-music! "assets/theme.ogg")
2.4.2. (play-music! volume)
Starts the currently-loaded music with Mix_PlayMusic *music* -1 — the
-1 means loop forever — and sets the music volume.
volume is a real number in 0.0..1.0. It is mapped to SDL_mixer's
0..128 range via (round (* volume 128)). Values outside the range are
not clamped; 0.0 is silent, 1.0 is full volume.
(play-music! 0.6) ;; ~77 on SDL_mixer's 0..128 scale
If load-music! has not been called (or failed), play-music! is a
no-op.
2.4.3. (stop-music!)
Calls Mix_HaltMusic. The music track remains loaded — a subsequent
play-music! will start it again from the beginning. Pair with your own
*music-on?* flag if you want a toggle (see Mute / toggle music below).
2.4.4. (set-music-volume! volume)
Same mapping as play-music! (0.0..1.0 → 0..128) but only changes the
current volume; does not start, stop, or load anything. Safe to call while
music is playing, stopped, or not yet loaded — it just sets a mixer
property.
3. Common patterns
3.1. Minimal audio setup in preload:
Initialize, load two or three effects, load and start the music track:
preload: (lambda (game)
(init-audio!)
(load-sounds! '((jump . "assets/jump.wav")
(coin . "assets/coin.wav")
(death . "assets/death.wav")))
(load-music! "assets/theme.ogg")
(play-music! 0.6))
preload: is the right place because it runs once, before create: and
before the first frame of the game loop.
3.2. Play a jump sound on input press
Trigger a one-shot on the transition from "not held" to "held" (input-pressed?,
not input-held?), so holding the button does not spam the sound:
update: (lambda (game dt)
(let ((input (game-input game)))
(when (input-pressed? input 'a)
(play-sound 'jump))))
See input.org for the difference between input-pressed? and
input-held?.
3.3. Mute / toggle music
stop-music! halts playback; play-music! restarts from the top. If you
want a mute that preserves position, use the volume instead:
;; Mute (preserves position): (set-music-volume! 0.0) ;; Unmute: (set-music-volume! 0.6)
A full on/off toggle, as seen in the audio demo, looks like this:
(define *music-on?* #t)
;; In update:, on the B button:
(when (input-pressed? (game-input game) 'b)
(if *music-on?*
(begin (stop-music!) (set! *music-on?* #f))
(begin (play-music! 0.5) (set! *music-on?* #t))))
Audio demo — run with bin/demo-audio, source in demo/audio.scm. Press
j or z to fire a sound effect, k or x to toggle music on and off.
3.4. Swapping the music track between scenes
To change songs cleanly, halt the current one, free it, and load the new
one. The high-level API does not free for you, so either call
cleanup-audio! (which also closes the device — probably not what you want
mid-game) or drop to (downstroke mixer):
(import (downstroke mixer)) (define (swap-music! path volume) (stop-music!) ;; mix-free-music! is the raw FFI free; the high-level API doesn't expose it. ;; Skip this line and you'll leak the previous Mix_Music*. (load-music! path) (play-music! volume))
Most small games get away with loading one track up front and never swapping.
3.5. Cleanup at game end
There is no engine teardown hook. If you care about shutting audio down
cleanly (for example, in a test that constructs and tears down many games in
one process), call cleanup-audio! after game-run! returns:
(game-run! *game*) ;; blocks until the user quits (cleanup-audio!) ;; now safe to tear down
For a normal standalone game binary this is optional — the process exits
immediately after game-run! returns and the OS reclaims everything. The
audio demo omits it.
4. See also
- guide.org —
make-game, lifecycle hooks,preload:/create:/update:. - input.org — triggering sounds from button presses;
input-pressed?vsinput-held?. - animation.org — synchronizing sound effects with animation frame events is
a common pattern but is not built in; drive
play-soundfrom your ownupdate:code when animation state changes.