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:
134
tests/audio.c
134
tests/audio.c
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user