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:
117
src/audio.c
117
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;
|
||||
|
||||
Reference in New Issue
Block a user