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>
322 lines
16 KiB
C
322 lines
16 KiB
C
/**
|
|
* @file audio.h
|
|
* @brief Declares the public audio API.
|
|
*
|
|
* A small tone generator: a fixed set of voices, each with a waveform, a
|
|
* 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
|
|
* voices and pull samples itself with akgl_audio_mix(), which is how the test
|
|
* suite exercises the synthesis without depending on a sound card's timing.
|
|
* What a caller cannot do is set up voices and expect to hear them with no
|
|
* device open.
|
|
*/
|
|
|
|
#ifndef _AKGL_AUDIO_H_
|
|
#define _AKGL_AUDIO_H_
|
|
|
|
#include <stdint.h>
|
|
|
|
#include <SDL3/SDL.h>
|
|
|
|
#include <akerror.h>
|
|
|
|
#include <akgl/types.h>
|
|
|
|
/**
|
|
* @brief Voices that can sound at once.
|
|
*
|
|
* Three, because the machine this vocabulary comes from had three and its
|
|
* music is written for three. A voice is addressed here by a zero-based index;
|
|
* a language whose own voices are numbered from one maps them itself.
|
|
*/
|
|
#define AKGL_AUDIO_MAX_VOICES 3
|
|
|
|
/** @brief Sample rate of the generated stream, in frames per second. */
|
|
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
|
|
|
/** @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.
|
|
*
|
|
* The trailing comment on each is the waveform number the C128 SOUND statement
|
|
* uses for the same shape, for a caller translating one to the other.
|
|
*/
|
|
typedef enum {
|
|
AKGL_AUDIO_WAVE_TRIANGLE = 0, /**< SOUND waveform 0. Soft, flute-like. */
|
|
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /**< SOUND waveform 1. Bright, brassy. */
|
|
AKGL_AUDIO_WAVE_SQUARE = 2, /**< SOUND waveform 2. Hollow, reedy. The default. */
|
|
AKGL_AUDIO_WAVE_NOISE = 3, /**< SOUND waveform 3. Unpitched; percussion. */
|
|
AKGL_AUDIO_WAVE_SINE = 4 /**< No SOUND equivalent. A pure tone. */
|
|
} akgl_AudioWaveform;
|
|
|
|
/** @brief Holds one voice's oscillator, envelope, and how far through it is. */
|
|
typedef struct {
|
|
bool active; /**< Whether this voice is sounding. Cleared by the mixer once gate and release are both spent, so it goes quiet without being told to. */
|
|
akgl_AudioWaveform waveform; /**< Oscillator shape. Persists across notes; set once with akgl_audio_waveform(). */
|
|
float32_t hz; /**< Frequency of the current note. 0.0 when the voice has never sounded. */
|
|
/**
|
|
* @brief Position through one cycle, 0.0 to 1.0.
|
|
*
|
|
* Derived from `elapsed_frames` and `hz` each sample rather than
|
|
* accumulated, so a long note does not drift off pitch. Reading it is
|
|
* meaningful; writing it is not.
|
|
*/
|
|
float32_t phase;
|
|
/** @brief Frames the gate stays open, before the release begins. */
|
|
uint32_t duration_frames;
|
|
/** @brief Frames generated since the tone started, gate and release. */
|
|
uint32_t elapsed_frames;
|
|
/** @brief Frames to rise from silence to full level. 0 starts at full level. */
|
|
uint32_t attack_frames;
|
|
/** @brief Frames to fall from full level to `sustain`. 0 drops to it at once. */
|
|
uint32_t decay_frames;
|
|
/** @brief Frames to fall to silence after the gate closes. 0 cuts off at once. */
|
|
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. */
|
|
extern akgl_AudioVoice akgl_audio_voices[AKGL_AUDIO_MAX_VOICES];
|
|
|
|
/**
|
|
* @brief Open an audio device and start pulling samples from the voice table.
|
|
*
|
|
* Requires SDL's audio subsystem to be initialized. Repeating the call is a
|
|
* no-op, so a program that cannot order its initialization precisely may call
|
|
* it more than once. The voice table is reset only on the first call, so this
|
|
* does not silence a voice that is already sounding.
|
|
*
|
|
* The device is resumed immediately rather than left paused, which is SDL's
|
|
* default -- a caller who set up a voice and heard nothing would have no error
|
|
* to explain it.
|
|
*
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKGL_ERR_SDL If no playback device can be opened -- none present, none
|
|
* permitted, or SDL's audio subsystem never initialized -- or if the
|
|
* device cannot be resumed. The message carries `SDL_GetError()`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_init(void);
|
|
|
|
/**
|
|
* @brief Close the audio device and silence every voice.
|
|
*
|
|
* Safe to call when no device is open, and safe to call twice. It also puts the
|
|
* voice table back to its defaults, so a subsequent akgl_audio_init() starts
|
|
* from a known state rather than from whatever was left sounding.
|
|
*
|
|
* @return `NULL`. There is no failure path -- SDL's stream teardown reports
|
|
* nothing.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void);
|
|
|
|
/**
|
|
* @brief Sound a note on one voice for a fixed time.
|
|
*
|
|
* The voice's envelope and waveform are whatever akgl_audio_envelope() and
|
|
* 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, 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
|
|
* bound check, so a frequency above half the sample rate aliases
|
|
* rather than being refused.
|
|
* @param ms Gate length in milliseconds. Must be non-zero -- a zero-length
|
|
* tone is refused rather than treated as "stop", because
|
|
* akgl_audio_stop() already means that. Rounded down to a whole
|
|
* number of frames, so a duration under ~0.023 ms rounds to
|
|
* nothing.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, if @p hz is not
|
|
* positive, or if @p ms is 0. Each message says which.
|
|
*/
|
|
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.
|
|
*
|
|
* A hard cut, not a note-off: the release stage is not run, so the sound stops
|
|
* on the next sample. Stopping a voice that is already silent is a no-op.
|
|
*
|
|
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_stop(int voice);
|
|
|
|
/**
|
|
* @brief Choose the oscillator shape one voice will use.
|
|
*
|
|
* Takes effect on the next akgl_audio_tone(); it does not reshape a note that
|
|
* is already sounding.
|
|
*
|
|
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
|
* @param waveform One of the ::akgl_AudioWaveform shapes. The setting persists
|
|
* across notes until changed.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, or @p waveform is
|
|
* not one of the five defined shapes.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_waveform(int voice, akgl_AudioWaveform waveform);
|
|
|
|
/**
|
|
* @brief Set one voice's ADSR envelope.
|
|
*
|
|
* @p attack, @p decay and @p release are milliseconds; @p sustain is the level
|
|
* the envelope decays to and holds while the gate is open, from 0.0 to 1.0. A
|
|
* zero-length stage is skipped rather than divided by.
|
|
*
|
|
* Takes effect on the next akgl_audio_tone().
|
|
*
|
|
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
|
* @param attack Milliseconds to rise from silence to full level. 0 starts at
|
|
* full level.
|
|
* @param decay Milliseconds to fall from full level to @p sustain. 0 drops to
|
|
* it at once.
|
|
* @param sustain Held level while the gate is open, 0.0 to 1.0 inclusive. 0.0 is
|
|
* legal and means the note is audible only through its attack and
|
|
* decay -- a plucked sound.
|
|
* @param release Milliseconds to fall to silence once the gate closes. 0 cuts
|
|
* off at once. An attack plus decay longer than the note's gate
|
|
* is not an error: the release simply starts partway up the ramp.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, or @p sustain is
|
|
* outside 0.0 to 1.0. The three durations are unbounded.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_envelope(int voice, uint32_t attack, uint32_t decay, float32_t sustain, uint32_t release);
|
|
|
|
/**
|
|
* @brief Set the level every voice is scaled by.
|
|
*
|
|
* Applied after the voices are summed and before the mix is clamped, so it
|
|
* takes effect on notes already sounding. akgl_audio_shutdown() puts it back
|
|
* to 1.0.
|
|
*
|
|
* @param level Master level, 0.0 to 1.0 inclusive. 0.0 is silence.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p level is outside 0.0 to 1.0. The message
|
|
* reports it.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_volume(float32_t level);
|
|
|
|
/**
|
|
* @brief Report whether a voice is still sounding.
|
|
*
|
|
* A voice goes quiet on its own when its gate and release have both elapsed, so
|
|
* this is how a caller waits out a note without keeping its own clock.
|
|
*
|
|
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
|
* @param active Receives `true` while the voice is sounding, including during
|
|
* its release. Required -- the return value is the error context.
|
|
* The flag is only cleared by the mixer, so a voice whose time is
|
|
* up still reads active until samples are next generated; with no
|
|
* device open and nobody calling akgl_audio_mix(), it never
|
|
* changes.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table.
|
|
* @throws AKERR_NULLPOINTER If @p active is `NULL`.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_voice_active(int voice, bool *active);
|
|
|
|
/**
|
|
* @brief Generate the next @p frames mono samples from the voice table.
|
|
*
|
|
* The device callback installed by akgl_audio_init() is a loop around this. A
|
|
* host that owns its own audio pipeline can call it directly instead and never
|
|
* open a device here at all. Samples are single-precision, one channel, in the
|
|
* range -1.0 to 1.0, and every active voice is advanced by @p frames.
|
|
*
|
|
* @param dest Receives @p frames samples. Required, and must have room for all
|
|
* of them -- the count is trusted, not checked against anything.
|
|
* Overwritten, not accumulated into.
|
|
* @param frames How many samples to generate. 0 is a no-op, not an error.
|
|
* @return `NULL` on success, otherwise an error context owned by the caller.
|
|
* @throws AKERR_NULLPOINTER If @p dest is `NULL`.
|
|
* @throws AKERR_OUTOFBOUNDS If @p frames is negative.
|
|
*
|
|
* @warning This mutates the voice table -- it advances each voice's frame
|
|
* counter and clears `active` on voices that have finished. Calling it
|
|
* while a device opened by akgl_audio_init() is running means two
|
|
* threads advancing the same voices, and this path does not take the
|
|
* stream lock. Use one or the other, not both.
|
|
*/
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames);
|
|
|
|
#endif // _AKGL_AUDIO_H_
|