Add akgl_audio_sweep for a note that changes pitch

BASIC's SOUND ramps a tone's pitch toward a limit, and nothing here could
do it. Faking it from the caller means re-issuing tones from its own step
loop, which ties audible pitch to how often that loop runs -- a siren that
changes shape with the frame rate. The step has to be taken on the mixer's
frame counter, which only this library can see.

akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms) steps once every
1/60 second, the rate the machine this vocabulary comes from used, which
divides 44100 exactly so a step always lands on a whole frame. Direction
comes from the two frequencies rather than the sign of the step, the last
step clamps to the target, and equal frequencies are a held tone rather
than an error. akgl_audio_tone() is the same start path with a step of 0,
so a voice reused for a plain tone stops sweeping.

A swept voice accumulates its phase instead of deriving it from the frame
counter: deriving assumes a constant frequency and would jump the waveform
at every step. tests/audio.c drives the mixer by hand and asserts the exact
frame the pitch moves on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:52:21 -04:00
parent 42b53dcb20
commit 34a076b851
3 changed files with 308 additions and 21 deletions

View File

@@ -3,11 +3,12 @@
* @brief Declares the public audio API.
*
* A small tone generator: a fixed set of voices, each with a waveform, a
* frequency, a gate length and an ADSR envelope, mixed to one stream of float
* samples. This is what a synthesised-voice vocabulary needs -- SOUND, PLAY,
* ENVELOPE and VOL all describe a note rather than a recording -- and it is
* deliberately separate from the SDL3_mixer side of the library, which loads
* and plays audio *assets*. Nothing here reads a file.
* frequency -- held, or walking toward another one -- a gate length and an ADSR
* envelope, mixed to one stream of float samples. This is what a synthesised-
* voice vocabulary needs -- SOUND, PLAY, ENVELOPE and VOL all describe a note
* rather than a recording -- and it is deliberately separate from the SDL3_mixer
* side of the library, which loads and plays audio *assets*. Nothing here reads
* a file.
*
* The voice table exists whether or not an audio device is open.
* akgl_audio_init() connects it to one; without that a caller can still set up
@@ -43,6 +44,19 @@
/** @brief Frames the device callback generates per pass through the mixer. */
#define AKGL_AUDIO_MIX_FRAMES 512
/**
* @brief How often a frequency sweep takes one step, in steps per second.
*
* 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 #AKGL_AUDIO_SAMPLE_RATE exactly, so a step boundary always lands on a
* whole frame.
*/
#define AKGL_AUDIO_SWEEP_TICK_HZ 60
/** @brief Frames between two steps of a sweep. #AKGL_AUDIO_SAMPLE_RATE / #AKGL_AUDIO_SWEEP_TICK_HZ. */
#define AKGL_AUDIO_SWEEP_TICK_FRAMES (AKGL_AUDIO_SAMPLE_RATE / AKGL_AUDIO_SWEEP_TICK_HZ)
/**
* @brief Shape of one voice's oscillator.
*
@@ -82,6 +96,12 @@ typedef struct {
uint32_t release_frames;
/** @brief Level the envelope decays to and holds, 0.0 to 1.0. */
float32_t sustain;
/** @brief Pitch the current sweep started from. Equal to `sweep_to_hz` when the voice is not sweeping. */
float32_t sweep_from_hz;
/** @brief Pitch the sweep is heading for. The pitch holds there once it arrives. */
float32_t sweep_to_hz;
/** @brief Hertz added or subtracted per sweep step. 0.0 means this voice holds one pitch. */
float32_t sweep_step_hz;
} akgl_AudioVoice;
/** @brief The process-wide voice table. */
@@ -125,7 +145,8 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void);
* akgl_audio_waveform() last set them to. @p ms is the length of the gate: the
* voice's release runs *after* it, so a voice with a release stays audible
* slightly longer than @p ms. Sounding a voice that is already sounding
* restarts it from the beginning of its envelope.
* restarts it from the beginning of its envelope, and clears any sweep
* akgl_audio_sweep() left on it -- this is one pitch, held.
*
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
* @param hz Frequency in hertz. Must be greater than 0. There is no upper
@@ -142,6 +163,51 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void);
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint32_t ms);
/**
* @brief Sound a note whose pitch walks from one frequency toward another.
*
* A siren, a laser, a falling bomb: akgl_audio_tone() with the pitch moving.
* The note starts at @p from_hz and steps by @p step_hz every
* 1/#AKGL_AUDIO_SWEEP_TICK_HZ of a second toward @p to_hz, stopping when it
* arrives and holding there for whatever is left of @p ms. Direction comes from
* the two frequencies, not from the sign of @p step_hz -- a sweep to a lower
* pitch is @p to_hz below @p from_hz, and @p step_hz stays positive.
*
* The step is taken on the mixer's own frame counter, which is the whole reason
* this is here rather than in a caller's step loop: 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 this library can see that counter.
*
* Everything else matches akgl_audio_tone(): the envelope, the waveform, the
* gate, and the release that follows it. 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.
*
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
* @param from_hz Pitch the note starts at. Must be greater than 0.
* @param to_hz Pitch the sweep walks toward and then holds. Must be greater
* than 0. Below @p from_hz sweeps down, above it sweeps up,
* equal to it holds.
* @param step_hz Hertz per step. Must be greater than 0; the last step is
* clamped to @p to_hz rather than overshooting it. A step larger
* than the whole interval arrives in one tick.
* @param ms Gate length in milliseconds. Must be non-zero. A gate shorter
* than the sweep needs is not an error -- the note ends partway
* through, which is what a short siren sounds like.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, if @p from_hz,
* @p to_hz or @p step_hz is not positive, or if @p ms is 0. Each
* message says which.
*
* @note A swept voice accumulates its phase one frame at a time instead of
* deriving it from the frame counter the way a held note does. Deriving
* it assumes a constant frequency, and using it here would jump the
* waveform -- an audible click -- at every step. The cost is the small
* drift the derived form exists to avoid, which a note that is changing
* pitch anyway cannot be said to suffer from.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms);
/**
* @brief Silence one voice immediately, skipping its release.
*