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

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