diff --git a/include/akgl/audio.h b/include/akgl/audio.h index 27ce481..7b95a3c 100644 --- a/include/akgl/audio.h +++ b/include/akgl/audio.h @@ -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. * diff --git a/src/audio.c b/src/audio.c index 70a0360..2850e8d 100644 --- a/src/audio.c +++ b/src/audio.c @@ -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; diff --git a/tests/audio.c b/tests/audio.c index 2f0e28b..4a415df 100644 --- a/tests/audio.c +++ b/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 {