aboutsummaryrefslogtreecommitdiff
path: root/docs/audio.org
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-18 02:47:10 +0100
committerGene Pasquet <dev@etenil.net>2026-04-18 02:47:10 +0100
commit38eee24832fe6da4f135cae455881ab97953b23a (patch)
treecffc2bb3b45ac11d90f4a2de3e207f65862fb6fd /docs/audio.org
parenta02b892e2ad1e1605ff942c63afdd618daa48be4 (diff)
Refresh docs and re-indent
Diffstat (limited to 'docs/audio.org')
-rw-r--r--docs/audio.org335
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.