338 lines
15 KiB
Markdown
338 lines
15 KiB
Markdown
|
|
# 17. Audio
|
|||
|
|
|
|||
|
|
**There are two audio subsystems in libakgl and they have nothing to do with each other.**
|
|||
|
|
Not two layers, not two APIs over one engine — two unrelated things that happen to make
|
|||
|
|
sound. Knowing which one you are in is most of using either.
|
|||
|
|
|
|||
|
|
| | Three-voice synthesizer | The asset path |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Header | `audio.h` | `assets.h` |
|
|||
|
|
| Functions | 10 | 1 |
|
|||
|
|
| Whose code | **libakgl's own, in full** | SDL3_mixer's, wrapped |
|
|||
|
|
| Reads a file | Never | Always |
|
|||
|
|
| Device | Its own `SDL_AudioStream`, opened by `akgl_audio_init` | The `MIX_Mixer` created by `akgl_game_init` |
|
|||
|
|
| Globals | `akgl_audio_voices[3]` | `akgl_mixer`, `akgl_bgm`, `akgl_tracks[64]` |
|
|||
|
|
| Vocabulary | Notes: waveform, frequency, envelope | Recordings: load, track, play |
|
|||
|
|
|
|||
|
|
The first half of this chapter documents the synthesizer completely, because it is ours. The
|
|||
|
|
second half is one function and a link, because the decoding, the formats, the mixers and
|
|||
|
|
the tracks all belong to
|
|||
|
|
[SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) and are documented there.
|
|||
|
|
|
|||
|
|
## Part 1 — the three-voice synthesizer
|
|||
|
|
|
|||
|
|
### Where the design comes from
|
|||
|
|
|
|||
|
|
The vocabulary is not invented. Every entry point maps onto a Commodore 128 BASIC 7.0
|
|||
|
|
statement, and **the waveform enum values are literally the numbers `SOUND` takes**:
|
|||
|
|
|
|||
|
|
```c excerpt=include/akgl/audio.h
|
|||
|
|
typedef enum {
|
|||
|
|
AKGL_AUDIO_WAVE_TRIANGLE = 0, /**< SOUND waveform 0. Soft, flute-like. */
|
|||
|
|
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /**< SOUND waveform 1. Bright, brassy. */
|
|||
|
|
AKGL_AUDIO_WAVE_SQUARE = 2, /**< SOUND waveform 2. Hollow, reedy. The default. */
|
|||
|
|
AKGL_AUDIO_WAVE_NOISE = 3, /**< SOUND waveform 3. Unpitched; percussion. */
|
|||
|
|
AKGL_AUDIO_WAVE_SINE = 4 /**< No SOUND equivalent. A pure tone. */
|
|||
|
|
} akgl_AudioWaveform;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| BASIC 7.0 | libakgl | Notes |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `SOUND v, f, d` | `akgl_audio_tone(voice, hz, ms)` | One pitch, held for a gate length |
|
|||
|
|
| `SOUND v, f, d, dir, min, step` | `akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms)` | Direction comes from the two frequencies, not from a sign |
|
|||
|
|
| `SOUND v, f, d, , , , wf` | `akgl_audio_waveform(voice, waveform)` | Same five shapes, same first four numbers |
|
|||
|
|
| `ENVELOPE n, a, d, s, r` | `akgl_audio_envelope(voice, attack, decay, sustain, release)` | Set per voice rather than per numbered preset |
|
|||
|
|
| `VOL n` | `akgl_audio_volume(level)` | Master level, 0.0 to 1.0 |
|
|||
|
|
| — | `akgl_audio_stop(voice)` | Hard cut; skips the release |
|
|||
|
|
| — | `akgl_audio_voice_active(voice, &active)` | Wait out a note without a clock |
|
|||
|
|
|
|||
|
|
Three voices, because the machine this comes from had three and its music is written for
|
|||
|
|
three:
|
|||
|
|
|
|||
|
|
```c excerpt=include/akgl/audio.h
|
|||
|
|
#define AKGL_AUDIO_MAX_VOICES 3
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Voices are addressed from **zero** here. A language whose own voices are numbered from one
|
|||
|
|
maps them itself.
|
|||
|
|
|
|||
|
|
### The vocabulary in practice
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <akerror.h>
|
|||
|
|
#include <akgl/audio.h>
|
|||
|
|
|
|||
|
|
/* A coin pickup: a short square blip on voice 0. */
|
|||
|
|
akerr_ErrorContext *sfx_coin(void)
|
|||
|
|
{
|
|||
|
|
PREPARE_ERROR(errctx);
|
|||
|
|
PASS(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_SQUARE));
|
|||
|
|
PASS(errctx, akgl_audio_envelope(0, 0, 20, 0.6f, 60));
|
|||
|
|
PASS(errctx, akgl_audio_tone(0, 1046.5f, 80));
|
|||
|
|
SUCCEED_RETURN(errctx);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* A laser: pitch walks down from 1200 Hz to 200 Hz, 40 Hz every 1/60 s. */
|
|||
|
|
akerr_ErrorContext *sfx_laser(void)
|
|||
|
|
{
|
|||
|
|
PREPARE_ERROR(errctx);
|
|||
|
|
PASS(errctx, akgl_audio_waveform(1, AKGL_AUDIO_WAVE_SAWTOOTH));
|
|||
|
|
PASS(errctx, akgl_audio_envelope(1, 0, 0, 1.0f, 30));
|
|||
|
|
PASS(errctx, akgl_audio_sweep(1, 1200.0f, 200.0f, 40.0f, 400));
|
|||
|
|
SUCCEED_RETURN(errctx);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Wait for a note to finish without keeping a clock. */
|
|||
|
|
akerr_ErrorContext *sfx_still_sounding(int voice, bool *sounding)
|
|||
|
|
{
|
|||
|
|
PREPARE_ERROR(errctx);
|
|||
|
|
PASS(errctx, akgl_audio_voice_active(voice, sounding));
|
|||
|
|
SUCCEED_RETURN(errctx);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`akgl_audio_waveform` and `akgl_audio_envelope` are **settings**: they persist on the voice
|
|||
|
|
across notes and take effect on the next `akgl_audio_tone`. They do not reshape a note that
|
|||
|
|
is already sounding. `akgl_audio_volume` is the exception — it is applied after the voices
|
|||
|
|
are summed, so it does take effect on notes already playing.
|
|||
|
|
|
|||
|
|
**A voice you never described still makes a noise.** The table's defaults are a square wave,
|
|||
|
|
sustain 1.0, and no attack, decay or release — held at full level for as long as the gate is
|
|||
|
|
open. A *zeroed* voice would have a sustain of 0.0, which is silence, so a caller who sounded
|
|||
|
|
a note without first setting an envelope would get nothing and no error saying why. The first
|
|||
|
|
entry point to touch the table installs those defaults, whether or not a device is open.
|
|||
|
|
|
|||
|
|
### The envelope and the gate
|
|||
|
|
|
|||
|
|
`ms` is the length of the **gate**, not of the sound. The release runs after it, so a voice
|
|||
|
|
with a release stays audible slightly longer than `ms`:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
level
|
|||
|
|
1.0 | /\
|
|||
|
|
| / \____________
|
|||
|
|
| / : \sustain release
|
|||
|
|
sus |___/ : \________________.
|
|||
|
|
| / : :\
|
|||
|
|
0.0 |_/______:______________________________:_\______ time
|
|||
|
|
|<-A-><-D->|<------- gate (ms) -------->|<-R->|
|
|||
|
|
| |
|
|||
|
|
gate opens gate closes
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Zero-length stages are skipped rather than divided by. A zero attack starts at full level; a
|
|||
|
|
zero decay drops to `sustain` at once; a zero release cuts off at once. `sustain` of 0.0 is
|
|||
|
|
legal and gives a plucked sound — audible only through its attack and decay. An attack plus
|
|||
|
|
decay longer than the gate is not an error either: the release simply starts partway up the
|
|||
|
|
ramp.
|
|||
|
|
|
|||
|
|
**A voice goes quiet on its own** once gate and release are both spent. The mixer clears
|
|||
|
|
`active`, which is why `akgl_audio_voice_active` is how you wait out a note.
|
|||
|
|
|
|||
|
|
### Sweeps
|
|||
|
|
|
|||
|
|
`akgl_audio_sweep` steps the pitch by `step_hz` every 1/60 of a second toward `to_hz`,
|
|||
|
|
stopping when it arrives and holding there for whatever is left of the gate:
|
|||
|
|
|
|||
|
|
```c excerpt=include/akgl/audio.h
|
|||
|
|
#define AKGL_AUDIO_SWEEP_TICK_HZ 60
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Sixty, because the machine whose `SOUND` statement this serves advanced its sweep on a 60 Hz
|
|||
|
|
interrupt and its tunes are written for that rate. It divides the 44100 Hz sample rate
|
|||
|
|
exactly, so a step boundary always lands on a whole frame.
|
|||
|
|
|
|||
|
|
**Direction comes from the two frequencies, not from the sign of `step_hz`.** A downward
|
|||
|
|
sweep is `to_hz` below `from_hz` with `step_hz` still positive; a negative or zero `step_hz`
|
|||
|
|
raises `AKERR_OUTOFBOUNDS`. Equal frequencies are legal and are simply a held tone, so a
|
|||
|
|
caller translating a statement that computes its own limits does not have to special-case
|
|||
|
|
them.
|
|||
|
|
|
|||
|
|
**The step is taken on the mixer's own frame counter, and that is the whole reason this
|
|||
|
|
function exists.** A caller re-issuing tones ties audible pitch to how often it happens to
|
|||
|
|
run, so the same siren changes shape with the frame rate. Nothing outside the library can
|
|||
|
|
see that counter.
|
|||
|
|
|
|||
|
|
`akgl_audio_tone` clears any sweep left on the voice — a step of 0 is what says "one pitch,
|
|||
|
|
held" — so you never have to undo one.
|
|||
|
|
|
|||
|
|
### The voice table exists without a device
|
|||
|
|
|
|||
|
|
`akgl_audio_voices[3]` is process-wide static storage. It is reachable, settable and
|
|||
|
|
readable before `akgl_audio_init` has ever run, and every entry point works. What you cannot
|
|||
|
|
do is set up voices with no device open and expect to *hear* them.
|
|||
|
|
|
|||
|
|
That leaves two ways to get sound out, and **you must pick one**:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
Path A -- libakgl owns the device
|
|||
|
|
---------------------------------
|
|||
|
|
akgl_audio_init()
|
|||
|
|
| opens an SDL_AudioStream, F32, mono, 44100 Hz, and resumes it
|
|||
|
|
| (SDL opens devices paused; this one does not, because a caller
|
|||
|
|
| who set up a voice and heard nothing would have no error to
|
|||
|
|
| explain it)
|
|||
|
|
v
|
|||
|
|
SDL's callback thread -> akgl_audio_mix(internal buffer, <= 512 frames)
|
|||
|
|
|
|||
|
|
|
|||
|
|
Path B -- the host owns the device
|
|||
|
|
----------------------------------
|
|||
|
|
your own audio pipeline -> akgl_audio_mix(your buffer, frames)
|
|||
|
|
| no device is ever opened here
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Do not use both.** `akgl_audio_mix` mutates the voice table — it advances each voice's
|
|||
|
|
frame counter and clears `active` on voices that have finished — and **it does not take the
|
|||
|
|
stream lock.** Every other entry point does, when there is a stream to lock: the internal
|
|||
|
|
`lock_voices` is a no-op with no device open, which is exactly right for path B and exactly
|
|||
|
|
wrong if you mix the two. Calling `akgl_audio_mix` yourself while a device opened by
|
|||
|
|
`akgl_audio_init` is running is two threads advancing the same voices.
|
|||
|
|
|
|||
|
|
Path B is what a host with its own audio pipeline wants, and it is how `tests/audio.c`
|
|||
|
|
exercises the synthesis without depending on a sound card's timing:
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <akerror.h>
|
|||
|
|
#include <akgl/audio.h>
|
|||
|
|
|
|||
|
|
#define HOST_FRAMES 512
|
|||
|
|
|
|||
|
|
/* A host that owns its own audio pipeline pulls samples itself and never
|
|||
|
|
calls akgl_audio_init. Do not do both: this path does not take the
|
|||
|
|
stream lock. */
|
|||
|
|
akerr_ErrorContext *host_fill(float32_t *dest)
|
|||
|
|
{
|
|||
|
|
PREPARE_ERROR(errctx);
|
|||
|
|
PASS(errctx, akgl_audio_mix(dest, HOST_FRAMES));
|
|||
|
|
SUCCEED_RETURN(errctx);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Samples are single-precision, one channel, in the range -1.0 to 1.0. `dest` is **overwritten,
|
|||
|
|
not accumulated into**, and the frame count is trusted rather than checked against anything —
|
|||
|
|
`akgl_audio_mix` raises `AKERR_NULLPOINTER` for a `NULL` destination and `AKERR_OUTOFBOUNDS`
|
|||
|
|
for a negative count, and that is the extent of the validation. A count of 0 is a no-op.
|
|||
|
|
|
|||
|
|
Three voices at full level can sum past full scale. The mix is **clamped** rather than scaled
|
|||
|
|
by the voice count, which keeps a single voice at the level it was asked for instead of a
|
|||
|
|
third of it.
|
|||
|
|
|
|||
|
|
### Sample rate, mix size, and the numbers
|
|||
|
|
|
|||
|
|
```c excerpt=include/akgl/audio.h
|
|||
|
|
/** @brief Sample rate of the generated stream, in frames per second. */
|
|||
|
|
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
|||
|
|
|
|||
|
|
/** @brief Frames the device callback generates per pass through the mixer. */
|
|||
|
|
#define AKGL_AUDIO_MIX_FRAMES 512
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Durations are milliseconds everywhere in the API and frames everywhere inside it. The
|
|||
|
|
conversion truncates, so a duration under about 0.023 ms rounds to nothing. There is **no
|
|||
|
|
upper bound on frequency**: a frequency above half the sample rate aliases rather than being
|
|||
|
|
refused.
|
|||
|
|
|
|||
|
|
`akgl_audio_shutdown` closes the device and resets the voice table to its defaults —
|
|||
|
|
including the master volume, back to 1.0 — so a later `akgl_audio_init` starts from a known
|
|||
|
|
state. It is safe with no device open and safe to call twice. `akgl_audio_init` is a no-op
|
|||
|
|
when a device is already open, and resets the voice table **only on the first call**, so it
|
|||
|
|
does not silence a voice that is already sounding.
|
|||
|
|
|
|||
|
|
### What raises what
|
|||
|
|
|
|||
|
|
| Status | When |
|
|||
|
|
|---|---|
|
|||
|
|
| `AKERR_OUTOFBOUNDS` | A voice index outside 0–2; a non-positive frequency or sweep step; a zero `ms`; a `sustain` or `volume` outside 0.0–1.0; a waveform outside the five; a negative frame count |
|
|||
|
|
| `AKERR_NULLPOINTER` | `NULL` passed to `akgl_audio_voice_active`'s `active` or `akgl_audio_mix`'s `dest` |
|
|||
|
|
| `AKGL_ERR_SDL` | `akgl_audio_init` cannot open or resume a playback device. The message carries `SDL_GetError()` |
|
|||
|
|
|
|||
|
|
A zero-length tone is **refused** rather than treated as "stop", because `akgl_audio_stop`
|
|||
|
|
already means that.
|
|||
|
|
|
|||
|
|
## Part 2 — the asset path
|
|||
|
|
|
|||
|
|
One function:
|
|||
|
|
|
|||
|
|
```c
|
|||
|
|
#include <akerror.h>
|
|||
|
|
#include <akgl/assets.h>
|
|||
|
|
#include <akgl/game.h>
|
|||
|
|
|
|||
|
|
/* akgl_game_init creates akgl_mixer; this needs it. Nothing else in the
|
|||
|
|
library calls akgl_load_start_bgm, and there is no music registry entry. */
|
|||
|
|
akerr_ErrorContext *start_music(char *path)
|
|||
|
|
{
|
|||
|
|
PREPARE_ERROR(errctx);
|
|||
|
|
PASS(errctx, akgl_load_start_bgm(path));
|
|||
|
|
SUCCEED_RETURN(errctx);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
It loads `fname` through `akgl_mixer`, creates a `MIX_Track` for it, stores that track in
|
|||
|
|
`akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]`, publishes the audio as `akgl_bgm`, and starts
|
|||
|
|
playback.
|
|||
|
|
|
|||
|
|
**Which formats work is SDL3_mixer's question, not libakgl's** — see the
|
|||
|
|
[SDL3_mixer documentation](https://wiki.libsdl.org/SDL3_mixer/). libakgl passes the path
|
|||
|
|
through verbatim; it is not resolved against `SDL_GetBasePath()`, so a relative path is
|
|||
|
|
relative to the process working directory. Loading is not deferred: `MIX_LoadAudio(..., true)`
|
|||
|
|
predecodes, so a large file costs its decode time here rather than at first play.
|
|||
|
|
|
|||
|
|
`akgl_game.h` reserves slot 1 of a 64-slot track table for the music and leaves the rest to
|
|||
|
|
you:
|
|||
|
|
|
|||
|
|
```c excerpt=include/akgl/game.h
|
|||
|
|
/** @brief Slot in ::akgl_tracks reserved for background music. Note that slot 0 is unused. */
|
|||
|
|
#define AKGL_GAME_AUDIO_TRACK_BGM 1
|
|||
|
|
/** @brief Size of the ::akgl_tracks table. Every simultaneous sound needs its own slot. */
|
|||
|
|
#define AKGL_GAME_AUDIO_MAX_TRACKS 64
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Four honest statements about this half
|
|||
|
|
|
|||
|
|
**It requires `akgl_game_init`, not `akgl_audio_init`.** `akgl_mixer` is created by
|
|||
|
|
`akgl_game_init` at `src/game.c:250`, and it is the only place in the tree that creates it.
|
|||
|
|
`assets.h:17-18` says "`akgl_game_init` (or a bare `akgl_audio_init`) has to have run first";
|
|||
|
|
that is wrong. `akgl_audio_init` opens the *synthesizer's* `SDL_AudioStream` and never
|
|||
|
|
touches `akgl_mixer`, so calling only that leaves `akgl_load_start_bgm` handing `NULL` to
|
|||
|
|
`MIX_LoadAudio`.
|
|||
|
|
|
|||
|
|
**The music does not loop.** `src/assets.c:20` initialises `bgmprops` to 0,
|
|||
|
|
`src/assets.c:43` sets `MIX_PROP_PLAY_LOOPS_NUMBER` on it, and `src/assets.c:45` plays the
|
|||
|
|
track with it. **0 is SDL's "no property set" sentinel**, not a set this function owns, so the
|
|||
|
|
write is rejected — unchecked — and the play call is given no options. The music plays once
|
|||
|
|
and stops, and `akgl_load_start_bgm` reports success either way. `TODO.md` item 19; the fix
|
|||
|
|
is `SDL_CreateProperties()` into `bgmprops`, checked, and destroyed in `CLEANUP`.
|
|||
|
|
|
|||
|
|
**`AKGL_REGISTRY_MUSIC` exists and nothing populates it.** `akgl_registry_init_music()`
|
|||
|
|
creates the property set and `akgl_game_init` calls it, but no function in the library ever
|
|||
|
|
writes an entry. If you want music by name, that registry is yours to fill and yours to read
|
|||
|
|
— through `SDL_SetPointerProperty` and `SDL_GetPointerProperty` directly, as
|
|||
|
|
[Chapter 6](06-the-registry.md) describes. There is no `akgl_music_*` API.
|
|||
|
|
|
|||
|
|
**There are no audio fixtures anywhere in this repository.** Not one `.wav`, `.ogg`, `.mp3`,
|
|||
|
|
`.flac` or tracker module, in `tests/assets/` or `util/assets/` or anywhere else. Nothing in
|
|||
|
|
the tree calls `akgl_load_start_bgm` either. **This function has no test coverage at all**,
|
|||
|
|
which is worth knowing before you build on it: the loop defect above was found by reading
|
|||
|
|
the code, and a test would have caught it.
|
|||
|
|
|
|||
|
|
### The overwrite and the leak on the way out
|
|||
|
|
|
|||
|
|
`akgl_load_start_bgm` overwrites `akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]` without releasing
|
|||
|
|
whatever was there, so calling it twice leaks a `MIX_Track`. On a failure *after* the audio
|
|||
|
|
loads, the audio is destroyed again before the error is returned — but the track, if one was
|
|||
|
|
created, is not. Both are startup-helper behaviour and both are visible in the twelve lines
|
|||
|
|
of `src/assets.c`; treat this as a "call it once at startup" function, which is what its
|
|||
|
|
`@brief` says it is.
|
|||
|
|
|
|||
|
|
## Which one do I want?
|
|||
|
|
|
|||
|
|
**Sound effects for a game you are writing from scratch: the synthesizer.** No files, no
|
|||
|
|
formats, no decode cost, no licensing question about the samples, and a laser is four lines.
|
|||
|
|
|
|||
|
|
**Music, or anything recorded: SDL3_mixer.** Either through `akgl_load_start_bgm` for the one
|
|||
|
|
background track, or by using `akgl_mixer` directly with SDL3_mixer's own API — `akgl_mixer`
|
|||
|
|
and `akgl_tracks` are public globals for exactly that reason, and there is nothing libakgl
|
|||
|
|
adds that you would be going around.
|