1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
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.
|