#+TITLE: Downstroke Audio 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 [[file:guide.org][=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. * The minimum you need #+begin_src scheme (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) #+end_src 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. * Core concepts ** 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. *** 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. ** 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. *** =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: #+begin_src scheme preload: (lambda (game) (init-audio!) (load-sounds! ...) (load-music! ...)) #+end_src =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. *** =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: #+begin_src scheme (game-run! my-game) (cleanup-audio!) ;; only runs after the game loop exits #+end_src 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. ** 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. *** =(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. #+begin_src scheme (load-sounds! '((jump . "assets/jump.wav") (hit . "assets/hit.wav") (coin . "assets/coin.wav") (death . "assets/death.wav"))) #+end_src 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. *** =(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. #+begin_src scheme (when (input-pressed? (game-input game) 'a) (play-sound 'jump)) #+end_src SDL_mixer 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. ** 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. *** =(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 (be aware of this)][Module-level state]]). #+begin_src scheme (load-music! "assets/theme.ogg") #+end_src *** =(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. #+begin_src scheme (play-music! 0.6) ;; ~77 on SDL_mixer's 0..128 scale #+end_src If =load-music!= has not been called (or failed), =play-music!= is a no-op. *** =(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][Mute / toggle music]] below). *** =(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. * Common patterns ** Minimal audio setup in =preload:= Initialize, load two or three effects, load and start the music track: #+begin_src scheme 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)) #+end_src =preload:= is the right place because it runs once, before =create:= and before the first frame of the game loop. ** 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: #+begin_src scheme update: (lambda (game dt) (let ((input (game-input game))) (when (input-pressed? input 'a) (play-sound 'jump)))) #+end_src See [[file:input.org][input.org]] for the difference between =input-pressed?= and =input-held?=. ** 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: #+begin_src scheme ;; Mute (preserves position): (set-music-volume! 0.0) ;; Unmute: (set-music-volume! 0.6) #+end_src A full on/off toggle, as seen in the audio demo, looks like this: #+begin_src scheme (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)))) #+end_src *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. ** 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=: #+begin_src scheme (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)) #+end_src Most small games get away with loading one track up front and never swapping. ** 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: #+begin_src scheme (game-run! *game*) ;; blocks until the user quits (cleanup-audio!) ;; now safe to tear down #+end_src 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. * See also - [[file:guide.org][guide.org]] — =make-game=, lifecycle hooks, =preload:= / =create:= / =update:=. - [[file:input.org][input.org]] — triggering sounds from button presses; =input-pressed?= vs =input-held?=. - [[file:animation.org][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.