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

@@ -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 {