Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
/**
|
|
|
|
|
* @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;
|
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>
2026-07-31 12:52:21 -04:00
|
|
|
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;
|
Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
}
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-07-31 12:52:21 -04:00
|
|
|
/**
|
|
|
|
|
* @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();
|
|
|
|
|
}
|
|
|
|
|
|
Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
/** @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");
|
|
|
|
|
|
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>
2026-07-31 12:52:21 -04:00
|
|
|
// 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);
|
Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
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;
|
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>
2026-07-31 12:52:21 -04:00
|
|
|
bool sweeping = false;
|
Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
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;
|
|
|
|
|
}
|
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>
2026-07-31 12:52:21 -04:00
|
|
|
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);
|
|
|
|
|
}
|
Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
sum += voice_oscillator(voice) * voice_envelope(voice);
|
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>
2026-07-31 12:52:21 -04:00
|
|
|
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);
|
|
|
|
|
}
|
Synthesise tones on three voices
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>
2026-07-31 07:13:18 -04:00
|
|
|
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);
|
|
|
|
|
}
|