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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user