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

View File

@@ -58,6 +58,9 @@ static void reset_voices(void)
akgl_audio_voices[i].decay_frames = 0;
akgl_audio_voices[i].release_frames = 0;
akgl_audio_voices[i].sustain = 1.0f;
akgl_audio_voices[i].sweep_from_hz = 0.0f;
akgl_audio_voices[i].sweep_to_hz = 0.0f;
akgl_audio_voices[i].sweep_step_hz = 0.0f;
}
mastervolume = 1.0f;
voicesready = true;
@@ -176,6 +179,62 @@ static float32_t voice_envelope(akgl_AudioVoice *voice)
return gatelevel * (1.0f - ((float32_t)released / (float32_t)voice->release_frames));
}
/**
* @brief Pitch @p voice sounds at where its sweep currently stands.
*
* Recomputed from the frame counter each time rather than added to as it goes,
* so a sweep lands on exactly the frequencies its step size describes however
* the caller happens to have chopped up its calls to akgl_audio_mix().
*
* A voice that is not sweeping keeps the frequency it was given.
*/
static float32_t voice_sweep_hz(akgl_AudioVoice *voice)
{
uint32_t ticks = 0;
float32_t moved = 0.0f;
float32_t hz = 0.0f;
if ( voice->sweep_step_hz <= 0.0f ) {
return voice->hz;
}
ticks = voice->elapsed_frames / AKGL_AUDIO_SWEEP_TICK_FRAMES;
moved = voice->sweep_step_hz * (float32_t)ticks;
if ( voice->sweep_to_hz < voice->sweep_from_hz ) {
hz = voice->sweep_from_hz - moved;
if ( hz < voice->sweep_to_hz ) {
hz = voice->sweep_to_hz;
}
} else {
hz = voice->sweep_from_hz + moved;
if ( hz > voice->sweep_to_hz ) {
hz = voice->sweep_to_hz;
}
}
return hz;
}
/**
* @brief Start a note on @p voice, sweeping or held.
*
* The whole of what akgl_audio_tone() and akgl_audio_sweep() do once their
* arguments have been checked. A held note is a sweep with a step of 0, which
* is also what makes a voice reused for a plain tone stop sweeping.
*/
static void start_note(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)
{
ensure_voices();
lock_voices();
akgl_audio_voices[voice].hz = from_hz;
akgl_audio_voices[voice].phase = 0.0f;
akgl_audio_voices[voice].sweep_from_hz = from_hz;
akgl_audio_voices[voice].sweep_to_hz = to_hz;
akgl_audio_voices[voice].sweep_step_hz = step_hz;
akgl_audio_voices[voice].duration_frames = frames_for_ms(ms);
akgl_audio_voices[voice].elapsed_frames = 0;
akgl_audio_voices[voice].active = true;
unlock_voices();
}
/** @brief Refuse a voice index that is not in the table. */
static akerr_ErrorContext *check_voice(int voice)
{
@@ -285,14 +344,27 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint
FAIL_NONZERO_RETURN(errctx, (hz <= 0.0f), AKERR_OUTOFBOUNDS, "Frequency %f is not positive", hz);
FAIL_ZERO_RETURN(errctx, ms, AKERR_OUTOFBOUNDS, "A tone needs a duration; use akgl_audio_stop to silence a voice");
ensure_voices();
lock_voices();
akgl_audio_voices[voice].hz = hz;
akgl_audio_voices[voice].phase = 0.0f;
akgl_audio_voices[voice].duration_frames = frames_for_ms(ms);
akgl_audio_voices[voice].elapsed_frames = 0;
akgl_audio_voices[voice].active = true;
unlock_voices();
// A step of 0 is what says "one pitch, held", so this also clears a sweep
// left on the voice by an earlier akgl_audio_sweep().
start_note(voice, hz, hz, 0.0f, ms);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)
{
PREPARE_ERROR(errctx);
PASS(errctx, check_voice(voice));
FAIL_NONZERO_RETURN(errctx, (from_hz <= 0.0f), AKERR_OUTOFBOUNDS, "Start frequency %f is not positive", from_hz);
FAIL_NONZERO_RETURN(errctx, (to_hz <= 0.0f), AKERR_OUTOFBOUNDS, "Target frequency %f is not positive", to_hz);
FAIL_NONZERO_RETURN(
errctx,
(step_hz <= 0.0f),
AKERR_OUTOFBOUNDS,
"Step %f is not positive; the direction of a sweep comes from the two frequencies",
step_hz);
FAIL_ZERO_RETURN(errctx, ms, AKERR_OUTOFBOUNDS, "A tone needs a duration; use akgl_audio_stop to silence a voice");
start_note(voice, from_hz, to_hz, step_hz, ms);
SUCCEED_RETURN(errctx);
}
@@ -384,6 +456,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames)
{
akgl_AudioVoice *voice = NULL;
float32_t sum = 0.0f;
bool sweeping = false;
int i = 0;
int v = 0;
@@ -406,14 +479,28 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames)
voice->phase = 0.0f;
continue;
}
// Derived from the frame counter rather than accumulated one
// increment at a time. A float increment of hz/rate is not exact,
// and adding it 44100 times a second walks the pitch off over the
// length of a held note.
voice->phase = (float32_t)SDL_fmod(
((double)voice->elapsed_frames * (double)voice->hz) / (double)AKGL_AUDIO_SAMPLE_RATE,
1.0);
sweeping = (voice->sweep_step_hz > 0.0f);
if ( sweeping ) {
voice->hz = voice_sweep_hz(voice);
} else {
// Derived from the frame counter rather than accumulated one
// increment at a time. A float increment of hz/rate is not exact,
// and adding it 44100 times a second walks the pitch off over the
// length of a held note.
voice->phase = (float32_t)SDL_fmod(
((double)voice->elapsed_frames * (double)voice->hz) / (double)AKGL_AUDIO_SAMPLE_RATE,
1.0);
}
sum += voice_oscillator(voice) * voice_envelope(voice);
if ( sweeping ) {
// The derived form above assumes one frequency for the whole note.
// Under a sweep it would jump the waveform at every step -- an
// audible click -- so a swept voice accumulates instead and takes
// the drift the derived form exists to avoid.
voice->phase = (float32_t)SDL_fmod(
(double)voice->phase + ((double)voice->hz / (double)AKGL_AUDIO_SAMPLE_RATE),
1.0);
}
voice->elapsed_frames += 1;
}
sum = sum * mastervolume;

View File

@@ -380,6 +380,139 @@ akerr_ErrorContext *test_audio_duration_and_release(void)
SUCCEED_RETURN(errctx);
}
/** @brief Mix @p ticks whole sweep steps, one step per call. */
static akerr_ErrorContext *mix_sweep_ticks(int ticks)
{
PREPARE_ERROR(errctx);
int i = 0;
ATTEMPT {
for ( i = 0; i < ticks; i++ ) {
CATCH(errctx, akgl_audio_mix(samples, AKGL_AUDIO_SWEEP_TICK_FRAMES));
}
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_sweep(void)
{
PREPARE_ERROR(errctx);
bool active = false;
ATTEMPT {
CATCH(errctx, reset_audio());
// 400 Hz climbing to 500 in 25 Hz steps: four steps, at 60 steps per
// second. The pitch is advanced by the mixer's own frame counter, so
// these assertions are about exact frames rather than about how often
// the caller happened to ask for samples.
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 400.0f, 500.0f, 25.0f, 1000),
"sounding a rising sweep");
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
"a sweep starts at %f, expected its start frequency",
akgl_audio_voices[0].hz);
// One tick's worth of frames is not yet a step: the last frame of that
// window is still the first tick.
CATCH(errctx, mix_sweep_ticks(1));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
"the pitch moved at %f before the first step was due",
akgl_audio_voices[0].hz);
TEST_ASSERT(errctx, peak_of(AKGL_AUDIO_SWEEP_TICK_FRAMES) > 0.5f,
"a sweep is not making a sound (peak %f)",
peak_of(AKGL_AUDIO_SWEEP_TICK_FRAMES));
// The frame after it is.
CATCH(errctx, akgl_audio_mix(samples, 1));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 425.0f,
"one step in, the pitch is %f, expected 425",
akgl_audio_voices[0].hz);
// It stops when it arrives rather than running past the target.
CATCH(errctx, mix_sweep_ticks(8));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 500.0f,
"a finished sweep sits at %f, expected its target 500",
akgl_audio_voices[0].hz);
// Downward is the same sweep with the frequencies the other way round.
// The step stays positive; the direction is not its sign.
CATCH(errctx, reset_audio());
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 500.0f, 400.0f, 25.0f, 1000),
"sounding a falling sweep");
CATCH(errctx, mix_sweep_ticks(1));
CATCH(errctx, akgl_audio_mix(samples, 1));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 475.0f,
"one step down, the pitch is %f, expected 475",
akgl_audio_voices[0].hz);
CATCH(errctx, mix_sweep_ticks(8));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
"a finished falling sweep sits at %f, expected its target 400",
akgl_audio_voices[0].hz);
// Equal frequencies are a held tone, not an error: a caller translating
// a statement that computes its own limits does not have to special
// case them.
CATCH(errctx, reset_audio());
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 440.0f, 440.0f, 10.0f, 1000),
"sweeping between two identical frequencies");
CATCH(errctx, mix_sweep_ticks(4));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 440.0f,
"a sweep with nowhere to go moved to %f", akgl_audio_voices[0].hz);
// The gate still governs: a sweep with further to go than the note lasts
// ends with the note, part way up. 30 ms is 1323 frames, which is one
// whole step and most of a second.
CATCH(errctx, reset_audio());
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 400.0f, 4000.0f, 25.0f, 30),
"sounding a sweep longer than its gate");
CATCH(errctx, mix_sweep_ticks(3));
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == false, "a swept voice outlived its gate");
TEST_ASSERT(errctx, akgl_audio_voices[0].hz > 400.0f,
"a sweep cut off by its gate never stepped at all (%f)",
akgl_audio_voices[0].hz);
TEST_ASSERT(errctx, akgl_audio_voices[0].hz < 4000.0f,
"a sweep cut off by its gate reached %f anyway",
akgl_audio_voices[0].hz);
// A voice reused for a plain tone stops sweeping.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_sweep(0, 400.0f, 800.0f, 25.0f, 1000));
CATCH(errctx, mix_sweep_ticks(2));
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000),
"sounding a plain tone on a voice that was sweeping");
CATCH(errctx, mix_sweep_ticks(4));
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, TEST_TONE_HZ,
"a plain tone on a swept voice drifted to %f",
akgl_audio_voices[0].hz);
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_sweep(AKGL_AUDIO_MAX_VOICES, 400.0f, 500.0f, 25.0f, 100),
"sweeping a voice past the last one");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_sweep(0, 0.0f, 500.0f, 25.0f, 100),
"sweeping from zero hertz");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_sweep(0, 400.0f, -1.0f, 25.0f, 100),
"sweeping to a negative frequency");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_sweep(0, 400.0f, 500.0f, 0.0f, 100),
"sweeping with no step");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_sweep(0, 500.0f, 400.0f, -25.0f, 100),
"sweeping down with a negative step");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_sweep(0, 400.0f, 500.0f, 25.0f, 0),
"sweeping with no duration");
} CLEANUP {
IGNORE(reset_audio());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_volume_and_mixing(void)
{
PREPARE_ERROR(errctx);
@@ -482,6 +615,7 @@ int main(void)
CATCH(errctx, test_audio_waveforms());
CATCH(errctx, test_audio_envelope());
CATCH(errctx, test_audio_duration_and_release());
CATCH(errctx, test_audio_sweep());
CATCH(errctx, test_audio_volume_and_mixing());
CATCH(errctx, test_audio_device());
} CLEANUP {