diff options
| author | Gene Pasquet <dev@etenil.net> | 2026-04-18 02:47:10 +0100 |
|---|---|---|
| committer | Gene Pasquet <dev@etenil.net> | 2026-04-18 02:47:10 +0100 |
| commit | 38eee24832fe6da4f135cae455881ab97953b23a (patch) | |
| tree | cffc2bb3b45ac11d90f4a2de3e207f65862fb6fd /docs/audio.org | |
| parent | a02b892e2ad1e1605ff942c63afdd618daa48be4 (diff) | |
Refresh docs and re-indent
Diffstat (limited to 'docs/audio.org')
| -rw-r--r-- | docs/audio.org | 335 |
1 files changed, 335 insertions, 0 deletions
diff --git a/docs/audio.org b/docs/audio.org new file mode 100644 index 0000000..7d83e44 --- /dev/null +++ b/docs/audio.org @@ -0,0 +1,335 @@ +#+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. |
