Files
libakgl/docs/18-audio.md
Andrew Kesterson bace818998 Renumber chapters 15 to 21 up by one, to open a slot for collision
A pure rename plus link rewrite and nothing else. Collision is a pluggable
subsystem with two implementations, shapes, a response hook and a tile binding;
that is a chapter, and burying it inside the physics chapter is the shape this
manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and
characters and actors are 10, 11 and 12.

Kept separate from writing the chapter because **nothing validates a
cross-reference target.** The example harness reads fenced blocks; it does not
follow links. A rename mixed into five hundred lines of new prose is not
reviewable, and a broken link would land silently. As its own commit it is
reviewable as a rename, and a grep for dangling `](NN-` targets is clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:15:25 -04:00

15 KiB
Raw Permalink Blame History

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 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:

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:

#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

#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:

  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:

#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:

   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:

#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

/** @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:

#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. 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:

/** @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 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.