There was no audio API at all: SDL3_mixer was vendored and AKGL_REGISTRY_MUSIC was declared, but nothing opened a mixer, and a mixer plays recordings anyway. BASIC 7.0's SOUND, PLAY, ENVELOPE and VOL describe notes, so this adds a tone generator -- three voices, five waveforms, an ADSR envelope each, mixed to one float stream and fed to an SDL_AudioStream. The voice table works whether or not a device is open. akgl_audio_init connects it to one; a host that owns its own audio pipeline can call akgl_audio_mix instead. That is also what makes the tests deterministic: a device pulls samples on SDL's audio thread whenever it likes, so tests/audio.c mixes by hand and only opens a device in its last test. Phase is computed from the frame counter rather than accumulated, because a float increment of hz/44100 added 44100 times a second walks a held note off pitch. A voice that has never been configured defaults to a square wave at full level, since a zeroed voice would have a sustain of 0.0 and make no sound with no error to explain why. Voices summing past full scale are clamped rather than scaled, so one voice plays at the level it was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
432 lines
13 KiB
C
432 lines
13 KiB
C
/**
|
|
* @file audio.c
|
|
* @brief Implements the audio subsystem.
|
|
*/
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <akerror.h>
|
|
#include <akgl/audio.h>
|
|
#include <akgl/error.h>
|
|
|
|
akgl_AudioVoice akgl_audio_voices[AKGL_AUDIO_MAX_VOICES];
|
|
|
|
/*
|
|
* The device akgl_audio_init() opened, or NULL when the voice table is not
|
|
* connected to one. Everything that mutates a voice locks this stream when it
|
|
* is open, because the device callback reads the same table on SDL's audio
|
|
* thread. When it is NULL there is no other thread to race with.
|
|
*/
|
|
static SDL_AudioStream *audiostream = NULL;
|
|
|
|
/** @brief Level every voice is scaled by before the mix is clamped. */
|
|
static float32_t mastervolume = 1.0f;
|
|
|
|
/** @brief Scratch the device callback mixes into before handing it to SDL. */
|
|
static float32_t mixbuffer[AKGL_AUDIO_MIX_FRAMES];
|
|
|
|
/*
|
|
* State for the noise waveform. A 32-bit xorshift rather than rand(): it needs
|
|
* no allocation, no locking beyond what already guards the voice table, and it
|
|
* produces the same sequence every run, so a test can assert on noise output.
|
|
*/
|
|
static uint32_t noisestate = 0x13579bdfu;
|
|
|
|
/** @brief Whether the voice table has been given its defaults yet. */
|
|
static bool voicesready = false;
|
|
|
|
/**
|
|
* @brief Put every voice back to a flat, audible default.
|
|
*
|
|
* A zeroed voice has a sustain of 0.0, which is silence -- so a caller who
|
|
* sounded a note without first describing an envelope would get nothing and no
|
|
* error saying why. The default is instead the simplest thing that makes noise:
|
|
* a square wave with no attack, no decay and no release, held at full level for
|
|
* as long as the gate is open.
|
|
*/
|
|
static void reset_voices(void)
|
|
{
|
|
int i = 0;
|
|
|
|
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
|
akgl_audio_voices[i].active = false;
|
|
akgl_audio_voices[i].waveform = AKGL_AUDIO_WAVE_SQUARE;
|
|
akgl_audio_voices[i].hz = 0.0f;
|
|
akgl_audio_voices[i].phase = 0.0f;
|
|
akgl_audio_voices[i].duration_frames = 0;
|
|
akgl_audio_voices[i].elapsed_frames = 0;
|
|
akgl_audio_voices[i].attack_frames = 0;
|
|
akgl_audio_voices[i].decay_frames = 0;
|
|
akgl_audio_voices[i].release_frames = 0;
|
|
akgl_audio_voices[i].sustain = 1.0f;
|
|
}
|
|
mastervolume = 1.0f;
|
|
voicesready = true;
|
|
}
|
|
|
|
/**
|
|
* @brief Give the voice table its defaults the first time anything touches it.
|
|
*
|
|
* The table is process-wide static storage, so it is reachable before
|
|
* akgl_audio_init() has run. Rather than make every entry point document an
|
|
* ordering requirement, the first one to arrive sets the defaults.
|
|
*/
|
|
static void ensure_voices(void)
|
|
{
|
|
if ( voicesready == false ) {
|
|
reset_voices();
|
|
}
|
|
}
|
|
|
|
/** @brief Lock the voice table against the device callback, if one is running. */
|
|
static void lock_voices(void)
|
|
{
|
|
if ( audiostream != NULL ) {
|
|
SDL_LockAudioStream(audiostream);
|
|
}
|
|
}
|
|
|
|
/** @brief Release the lock taken by lock_voices(). */
|
|
static void unlock_voices(void)
|
|
{
|
|
if ( audiostream != NULL ) {
|
|
SDL_UnlockAudioStream(audiostream);
|
|
}
|
|
}
|
|
|
|
/** @brief Convert a duration in milliseconds to a whole number of frames. */
|
|
static uint32_t frames_for_ms(uint32_t ms)
|
|
{
|
|
return (uint32_t)(((uint64_t)ms * AKGL_AUDIO_SAMPLE_RATE) / 1000);
|
|
}
|
|
|
|
/** @brief Next value of the noise oscillator, in the range -1.0 to 1.0. */
|
|
static float32_t noise_sample(void)
|
|
{
|
|
noisestate ^= noisestate << 13;
|
|
noisestate ^= noisestate >> 17;
|
|
noisestate ^= noisestate << 5;
|
|
// The top 24 bits are the well-mixed ones; scale them to -1..1.
|
|
return (((float32_t)(noisestate >> 8) / 8388607.5f) - 1.0f);
|
|
}
|
|
|
|
/** @brief One sample of @p voice's oscillator at its current phase. */
|
|
static float32_t voice_oscillator(akgl_AudioVoice *voice)
|
|
{
|
|
float32_t value = 0.0f;
|
|
|
|
switch ( voice->waveform ) {
|
|
case AKGL_AUDIO_WAVE_TRIANGLE:
|
|
if ( voice->phase < 0.5f ) {
|
|
value = (4.0f * voice->phase) - 1.0f;
|
|
} else {
|
|
value = 3.0f - (4.0f * voice->phase);
|
|
}
|
|
break;
|
|
case AKGL_AUDIO_WAVE_SAWTOOTH:
|
|
value = (2.0f * voice->phase) - 1.0f;
|
|
break;
|
|
case AKGL_AUDIO_WAVE_SQUARE:
|
|
value = ( voice->phase < 0.5f ) ? 1.0f : -1.0f;
|
|
break;
|
|
case AKGL_AUDIO_WAVE_NOISE:
|
|
value = noise_sample();
|
|
break;
|
|
case AKGL_AUDIO_WAVE_SINE:
|
|
value = SDL_sinf(voice->phase * 2.0f * SDL_PI_F);
|
|
break;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* @brief Envelope level @p frame frames into the gate, before the release.
|
|
*
|
|
* Split out because the release has to start from wherever the gate left off,
|
|
* which for a gate shorter than attack plus decay is partway up or down a
|
|
* ramp rather than at the sustain level.
|
|
*/
|
|
static float32_t voice_gate_level(akgl_AudioVoice *voice, uint32_t frame)
|
|
{
|
|
uint32_t elapsed = frame;
|
|
|
|
if ( elapsed < voice->attack_frames ) {
|
|
return (float32_t)elapsed / (float32_t)voice->attack_frames;
|
|
}
|
|
elapsed -= voice->attack_frames;
|
|
if ( elapsed < voice->decay_frames ) {
|
|
return 1.0f - ((1.0f - voice->sustain) * ((float32_t)elapsed / (float32_t)voice->decay_frames));
|
|
}
|
|
return voice->sustain;
|
|
}
|
|
|
|
/** @brief Envelope level for @p voice where it currently stands. */
|
|
static float32_t voice_envelope(akgl_AudioVoice *voice)
|
|
{
|
|
uint32_t released = 0;
|
|
float32_t gatelevel = 0.0f;
|
|
|
|
if ( voice->elapsed_frames < voice->duration_frames ) {
|
|
return voice_gate_level(voice, voice->elapsed_frames);
|
|
}
|
|
released = voice->elapsed_frames - voice->duration_frames;
|
|
if ( released >= voice->release_frames ) {
|
|
return 0.0f;
|
|
}
|
|
gatelevel = voice_gate_level(voice, voice->duration_frames);
|
|
return gatelevel * (1.0f - ((float32_t)released / (float32_t)voice->release_frames));
|
|
}
|
|
|
|
/** @brief Refuse a voice index that is not in the table. */
|
|
static akerr_ErrorContext *check_voice(int voice)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_NONZERO_RETURN(
|
|
errctx,
|
|
((voice < 0) || (voice >= AKGL_AUDIO_MAX_VOICES)),
|
|
AKERR_OUTOFBOUNDS,
|
|
"Voice %d is not in the range 0 to %d",
|
|
voice,
|
|
AKGL_AUDIO_MAX_VOICES - 1);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Fill SDL's request from the voice table.
|
|
*
|
|
* SDL holds the stream lock for the duration of this callback, which is the
|
|
* same lock lock_voices() takes, so the voice table cannot change underneath a
|
|
* mix in progress.
|
|
*/
|
|
static void SDLCALL audio_stream_callback(void *userdata, SDL_AudioStream *stream, int additional_amount, int total_amount)
|
|
{
|
|
akerr_ErrorContext *errctx = NULL;
|
|
int frames = 0;
|
|
|
|
while ( additional_amount > 0 ) {
|
|
frames = additional_amount / (int)sizeof(float32_t);
|
|
if ( frames > AKGL_AUDIO_MIX_FRAMES ) {
|
|
frames = AKGL_AUDIO_MIX_FRAMES;
|
|
}
|
|
if ( frames <= 0 ) {
|
|
return;
|
|
}
|
|
errctx = akgl_audio_mix(mixbuffer, frames);
|
|
if ( errctx != NULL ) {
|
|
// There is nobody to return an error to on the audio thread, and
|
|
// refusing to write leaves SDL underrunning. Report and go quiet.
|
|
LOG_ERROR_WITH_MESSAGE(errctx, "** AUDIO CALLBACK **");
|
|
errctx->handled = true;
|
|
errctx = akerr_release_error(errctx);
|
|
return;
|
|
}
|
|
SDL_PutAudioStreamData(stream, mixbuffer, frames * (int)sizeof(float32_t));
|
|
additional_amount -= frames * (int)sizeof(float32_t);
|
|
}
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_init(void)
|
|
{
|
|
SDL_AudioSpec spec;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
if ( audiostream != NULL ) {
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
ensure_voices();
|
|
|
|
spec.format = SDL_AUDIO_F32;
|
|
spec.channels = 1;
|
|
spec.freq = AKGL_AUDIO_SAMPLE_RATE;
|
|
|
|
audiostream = SDL_OpenAudioDeviceStream(
|
|
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
|
|
&spec,
|
|
&audio_stream_callback,
|
|
NULL);
|
|
FAIL_ZERO_RETURN(
|
|
errctx,
|
|
audiostream,
|
|
AKGL_ERR_SDL,
|
|
"Couldn't open an audio device: %s",
|
|
SDL_GetError());
|
|
|
|
// Devices open paused so a caller can set a stream up before it is heard.
|
|
// Nothing here needs that, and a caller who expected akgl_audio_tone() to
|
|
// make a sound would otherwise get silence with no error to explain it.
|
|
FAIL_ZERO_RETURN(
|
|
errctx,
|
|
SDL_ResumeAudioStreamDevice(audiostream),
|
|
AKGL_ERR_SDL,
|
|
"Couldn't start the audio device: %s",
|
|
SDL_GetError());
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void)
|
|
{
|
|
SDL_AudioStream *closing = audiostream;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
// Clear the pointer before destroying the stream: lock_voices() checks it,
|
|
// and SDL_DestroyAudioStream can run the callback one last time.
|
|
audiostream = NULL;
|
|
if ( closing != NULL ) {
|
|
SDL_DestroyAudioStream(closing);
|
|
}
|
|
reset_voices();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint32_t ms)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
PASS(errctx, check_voice(voice));
|
|
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();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_stop(int voice)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
PASS(errctx, check_voice(voice));
|
|
|
|
ensure_voices();
|
|
lock_voices();
|
|
akgl_audio_voices[voice].active = false;
|
|
akgl_audio_voices[voice].elapsed_frames = 0;
|
|
akgl_audio_voices[voice].phase = 0.0f;
|
|
unlock_voices();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_waveform(int voice, akgl_AudioWaveform waveform)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
PASS(errctx, check_voice(voice));
|
|
FAIL_NONZERO_RETURN(
|
|
errctx,
|
|
((waveform < AKGL_AUDIO_WAVE_TRIANGLE) || (waveform > AKGL_AUDIO_WAVE_SINE)),
|
|
AKERR_OUTOFBOUNDS,
|
|
"Waveform %d is not one of the %d shapes",
|
|
(int)waveform,
|
|
(int)AKGL_AUDIO_WAVE_SINE + 1);
|
|
|
|
ensure_voices();
|
|
lock_voices();
|
|
akgl_audio_voices[voice].waveform = waveform;
|
|
unlock_voices();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_envelope(int voice, uint32_t attack, uint32_t decay, float32_t sustain, uint32_t release)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
PASS(errctx, check_voice(voice));
|
|
FAIL_NONZERO_RETURN(
|
|
errctx,
|
|
((sustain < 0.0f) || (sustain > 1.0f)),
|
|
AKERR_OUTOFBOUNDS,
|
|
"Sustain level %f is not between 0.0 and 1.0",
|
|
sustain);
|
|
|
|
ensure_voices();
|
|
lock_voices();
|
|
akgl_audio_voices[voice].attack_frames = frames_for_ms(attack);
|
|
akgl_audio_voices[voice].decay_frames = frames_for_ms(decay);
|
|
akgl_audio_voices[voice].release_frames = frames_for_ms(release);
|
|
akgl_audio_voices[voice].sustain = sustain;
|
|
unlock_voices();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_volume(float32_t level)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_NONZERO_RETURN(
|
|
errctx,
|
|
((level < 0.0f) || (level > 1.0f)),
|
|
AKERR_OUTOFBOUNDS,
|
|
"Volume level %f is not between 0.0 and 1.0",
|
|
level);
|
|
|
|
ensure_voices();
|
|
lock_voices();
|
|
mastervolume = level;
|
|
unlock_voices();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_voice_active(int voice, bool *active)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
PASS(errctx, check_voice(voice));
|
|
FAIL_ZERO_RETURN(errctx, active, AKERR_NULLPOINTER, "NULL activity destination");
|
|
|
|
ensure_voices();
|
|
lock_voices();
|
|
*active = akgl_audio_voices[voice].active;
|
|
unlock_voices();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames)
|
|
{
|
|
akgl_AudioVoice *voice = NULL;
|
|
float32_t sum = 0.0f;
|
|
int i = 0;
|
|
int v = 0;
|
|
|
|
PREPARE_ERROR(errctx);
|
|
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL sample destination");
|
|
FAIL_NONZERO_RETURN(errctx, (frames < 0), AKERR_OUTOFBOUNDS, "Frame count %d is negative", frames);
|
|
|
|
for ( i = 0; i < frames; i++ ) {
|
|
sum = 0.0f;
|
|
for ( v = 0; v < AKGL_AUDIO_MAX_VOICES; v++ ) {
|
|
voice = &akgl_audio_voices[v];
|
|
if ( voice->active == false ) {
|
|
continue;
|
|
}
|
|
if ( voice->elapsed_frames >= (voice->duration_frames + voice->release_frames) ) {
|
|
// Gate and release are both spent. The voice goes quiet on its
|
|
// own so a caller does not have to keep a clock to stop it.
|
|
voice->active = false;
|
|
voice->elapsed_frames = 0;
|
|
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);
|
|
sum += voice_oscillator(voice) * voice_envelope(voice);
|
|
voice->elapsed_frames += 1;
|
|
}
|
|
sum = sum * mastervolume;
|
|
// Three voices at full level can sum past full scale. Clamping rather
|
|
// than scaling by the voice count keeps a single voice at the level it
|
|
// was asked for instead of a third of it.
|
|
if ( sum > 1.0f ) {
|
|
sum = 1.0f;
|
|
} else if ( sum < -1.0f ) {
|
|
sum = -1.0f;
|
|
}
|
|
dest[i] = sum;
|
|
}
|
|
SUCCEED_RETURN(errctx);
|
|
}
|