Files
libakgl/docs/18-audio.md
Tachikoma eabb9ad376
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m7s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / memory_check (push) Has been cancelled
Move outstanding work from TODO.md into the issue tracker
TODO.md carried two records in one file: what had been done, with the
measurements behind it, and what was left. The second half is what a tracker
is for, and keeping it here has already cost something -- AGENTS.md records a
round where eleven entries described code that had already changed, and this
file admitted to three more.

Every open item is now an issue on source.starfort.tech/andrew/libakgl,
labelled by kind and blast radius and milestoned by what it can land in: 0.9.x
for anything that breaks no ABI, 0.10.0 for new or changed public symbols,
1.0.0 for the design work. Four are epics: the performance plan (#60),
coverage (#61), actor rotation (#62), and the false header comments (#63).

Verified against the tree before filing rather than transcribed. Three entries
were already fixed and were not filed: the akgl_path_relative context leak, the
akgl_draw_background test extension, and the SDL enumeration audit -- keyboards,
gamepads and mappings are all freed in CLEANUP today. Two were reworded because
the code had moved: the fonts item is a missing teardown entry point rather than
a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap
call is already bounded by numlayers, so only the per-layer actor rescan remains.

TODO.md keeps the part a tracker has no place for: why a decision went the way
it did, what the measurement was, and which arguments turned out to be wrong.

TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor
collision, actor-to-world collision, automatic facing, image layers -- and the
four that had not are #74 through #77, with the GPU renderer's research links
kept because that is the part that took the time.

Every reference that named an item number or a moved section is repointed, in
the manual, the headers, the tests and the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:47:34 -04:00

338 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 18. 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 02; a non-positive frequency or sweep step; a zero `ms`; a `sustain` or `volume` outside 0.01.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. Issue #16; 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.