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 as 0.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 to SDL_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 are 0..128, channels are integers, loops is an integer count with -1 for 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 by load-sounds!.
  • *music* — the currently-loaded Mix_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 — use cleanup-audio! if you need to swap tracks cleanly, or drop into (downstroke mixer) and call mix-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.00..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.orgmake-game, lifecycle hooks, preload: / create: / update:.
  • input.org — triggering sounds from button presses; input-pressed? vs input-held?.
  • animation.org — synchronizing sound effects with animation frame events is a common pattern but is not built in; drive play-sound from your own update: code when animation state changes.

Author: Gene Pasquet

Created: 2026-04-20 Mon 15:22

Validate