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>
This commit is contained in:
2026-07-31 07:13:18 -04:00
parent dba0f8db89
commit 2a3ca48d8f
5 changed files with 1113 additions and 0 deletions

446
tests/audio.c Normal file
View File

@@ -0,0 +1,446 @@
/**
* @file audio.c
* @brief Unit tests for the tone generator.
*
* Almost everything here drives akgl_audio_mix() by hand rather than opening a
* device. That is not a workaround: a device pulls samples on SDL's audio
* thread at whatever rate it likes, so a test that opened one and then asserted
* on voice state would be racing the callback. Pulling the samples ourselves
* makes the synthesis deterministic -- the same input produces the same
* waveform, sample for sample, every run.
*
* The device is opened once, at the end, to prove akgl_audio_init() and
* akgl_audio_shutdown() work against the dummy driver.
*/
#include <SDL3/SDL.h>
#include <math.h>
#include <string.h>
#include <akerror.h>
#include <akgl/audio.h>
#include <akgl/error.h>
#include "testutil.h"
/** @brief Frames of output most tests generate at a time. */
#define TEST_MIX_FRAMES 512
/** @brief 441 Hz at 44100 frames per second is exactly 100 frames per cycle. */
#define TEST_TONE_HZ 441.0f
/** @brief Frames in one cycle of TEST_TONE_HZ. */
#define TEST_TONE_PERIOD 100
/** @brief Somewhere to mix into. */
static float32_t samples[TEST_MIX_FRAMES];
/** @brief Silence every voice and put the master level back. */
static akerr_ErrorContext *reset_audio(void)
{
PREPARE_ERROR(errctx);
int i = 0;
ATTEMPT {
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
CATCH(errctx, akgl_audio_stop(i));
CATCH(errctx, akgl_audio_waveform(i, AKGL_AUDIO_WAVE_SQUARE));
CATCH(errctx, akgl_audio_envelope(i, 0, 0, 1.0f, 0));
}
CATCH(errctx, akgl_audio_volume(1.0f));
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/** @brief Report the largest absolute sample in the first @p frames. */
static float32_t peak_of(int frames)
{
float32_t peak = 0.0f;
int i = 0;
for ( i = 0; i < frames; i++ ) {
if ( fabsf(samples[i]) > peak ) {
peak = fabsf(samples[i]);
}
}
return peak;
}
akerr_ErrorContext *test_audio_silence(void)
{
PREPARE_ERROR(errctx);
bool silent = true;
bool active = true;
int i = 0;
ATTEMPT {
CATCH(errctx, reset_audio());
// With nothing sounding, the mixer produces silence rather than
// whatever was left in the buffer.
SDL_memset((void *)&samples, 0xff, sizeof(samples));
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES), "mixing an idle voice table");
for ( i = 0; i < TEST_MIX_FRAMES; i++ ) {
TEST_ASSERT_FLAG(silent, samples[i] == 0.0f);
}
TEST_ASSERT(errctx, silent == true, "an idle voice table did not mix to silence");
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
CATCH(errctx, akgl_audio_voice_active(i, &active));
TEST_ASSERT(errctx, active == false, "voice %d reports active with nothing playing", i);
}
// Zero frames is a legal request that writes nothing.
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, 0), "mixing zero frames");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_audio_mix(NULL, 16),
"mixing into a NULL destination");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_mix(samples, -1),
"mixing a negative number of frames");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_square_tone(void)
{
PREPARE_ERROR(errctx);
bool firsthalf = true;
bool secondhalf = true;
bool active = false;
int i = 0;
ATTEMPT {
CATCH(errctx, reset_audio());
// A square wave with a flat envelope is the one waveform whose every
// sample is known exactly: +1 for the first half of each cycle, -1 for
// the second.
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000), "sounding a tone");
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == true, "a sounded voice does not report itself active");
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES), "mixing a square wave");
for ( i = 0; i < (TEST_TONE_PERIOD / 2); i++ ) {
TEST_ASSERT_FLAG(firsthalf, samples[i] == 1.0f);
}
for ( i = (TEST_TONE_PERIOD / 2); i < TEST_TONE_PERIOD; i++ ) {
TEST_ASSERT_FLAG(secondhalf, samples[i] == -1.0f);
}
TEST_ASSERT(errctx, firsthalf == true,
"the first half cycle of a square wave is not at full positive level");
TEST_ASSERT(errctx, secondhalf == true,
"the second half cycle of a square wave is not at full negative level");
// The wave repeats: the second cycle matches the first.
TEST_ASSERT(errctx, samples[TEST_TONE_PERIOD] == samples[0],
"the wave did not repeat after one period");
TEST_ASSERT(errctx, samples[TEST_TONE_PERIOD + 60] == samples[60],
"the wave did not repeat after one period");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, 0.0f, 100),
"sounding a tone at zero hertz");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, -100.0f, 100),
"sounding a tone at a negative frequency");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, TEST_TONE_HZ, 0),
"sounding a tone with no duration");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(-1, TEST_TONE_HZ, 100),
"sounding a tone on a negative voice");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_tone(AKGL_AUDIO_MAX_VOICES, TEST_TONE_HZ, 100),
"sounding a tone on a voice past the last one");
} CLEANUP {
IGNORE(reset_audio());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_waveforms(void)
{
PREPARE_ERROR(errctx);
bool inrange = true;
int i = 0;
int w = 0;
akgl_AudioWaveform shapes[4] = {
AKGL_AUDIO_WAVE_TRIANGLE,
AKGL_AUDIO_WAVE_SAWTOOTH,
AKGL_AUDIO_WAVE_NOISE,
AKGL_AUDIO_WAVE_SINE,
};
ATTEMPT {
// Every shape has to stay inside full scale and actually move. The
// exact sample values differ per shape; these two properties do not.
for ( w = 0; w < 4; w++ ) {
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_waveform(0, shapes[w]));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
for ( i = 0; i < TEST_MIX_FRAMES; i++ ) {
TEST_ASSERT_FLAG(inrange, (samples[i] >= -1.0f) && (samples[i] <= 1.0f));
}
TEST_ASSERT_FLAG(inrange, peak_of(TEST_MIX_FRAMES) > 0.5f);
}
TEST_ASSERT(errctx, inrange == true,
"a waveform either left full scale or produced nothing");
// The triangle is symmetric about the middle of its cycle, which the
// square and sawtooth are not -- enough to tell it was really selected.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_TRIANGLE));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, samples[25], 0.0f,
"a triangle wave is at %f a quarter of the way up, expected 0",
samples[25]);
TEST_ASSERT_FEQ(errctx, samples[50], 1.0f,
"a triangle wave is at %f at its peak, expected 1", samples[50]);
TEST_ASSERT_FEQ(errctx, samples[75], 0.0f,
"a triangle wave is at %f three quarters through, expected 0",
samples[75]);
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_waveform(0, (akgl_AudioWaveform)(AKGL_AUDIO_WAVE_SINE + 1)),
"selecting a waveform past the last one");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_waveform(AKGL_AUDIO_MAX_VOICES, AKGL_AUDIO_WAVE_SINE),
"selecting a waveform on a voice past the last one");
} CLEANUP {
IGNORE(reset_audio());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_envelope(void)
{
PREPARE_ERROR(errctx);
bool rising = true;
int i = 0;
ATTEMPT {
CATCH(errctx, reset_audio());
// A 10 ms attack at 44100 is 441 frames, so the level should climb from
// nothing to full across the first 441 samples of a square wave and
// then hold at the sustain level.
CATCH(errctx, akgl_audio_envelope(0, 10, 0, 1.0f, 0));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, samples[0], 0.0f,
"an attack does not start from silence (first sample %f)", samples[0]);
TEST_ASSERT(errctx, fabsf(samples[100]) > fabsf(samples[10]),
"the attack is not climbing (%f at frame 10, %f at frame 100)",
samples[10], samples[100]);
// Frames 0..49 are the positive half of the square, so their level is
// the envelope value directly.
for ( i = 1; i < 50; i++ ) {
TEST_ASSERT_FLAG(rising, samples[i] > samples[i - 1]);
}
TEST_ASSERT(errctx, rising == true, "the attack ramp is not monotonic");
// Half a millisecond of decay to a half sustain level, no attack: the
// very first sample is at full level and it settles at the sustain.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_envelope(0, 0, 10, 0.5f, 0));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
"with no attack the first sample is %f, expected full level", samples[0]);
TEST_ASSERT_FEQ(errctx, samples[450], -0.5f,
"after the decay the level is %f, expected the 0.5 sustain", samples[450]);
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_envelope(0, 1, 1, -0.1f, 1),
"setting a negative sustain level");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_envelope(0, 1, 1, 1.5f, 1),
"setting a sustain level past full");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_envelope(AKGL_AUDIO_MAX_VOICES, 1, 1, 1.0f, 1),
"setting an envelope on a voice past the last one");
} CLEANUP {
IGNORE(reset_audio());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_duration_and_release(void)
{
PREPARE_ERROR(errctx);
bool active = false;
ATTEMPT {
CATCH(errctx, reset_audio());
// A 10 ms gate is 441 frames. The voice is still sounding while they
// are being generated and goes quiet on its own afterwards, without the
// caller having to stop it.
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10));
CATCH(errctx, akgl_audio_mix(samples, 440));
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == true, "the voice went quiet before its gate closed");
CATCH(errctx, akgl_audio_mix(samples, 8));
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == false, "the voice is still sounding past its gate");
CATCH(errctx, akgl_audio_mix(samples, 64));
TEST_ASSERT_FEQ(errctx, peak_of(64), 0.0f,
"a finished voice is still producing sound (peak %f)", peak_of(64));
// With a release, the voice outlives its gate and fades rather than
// stopping dead.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_envelope(0, 0, 0, 1.0f, 10));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10));
CATCH(errctx, akgl_audio_mix(samples, 441));
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == true, "a voice with a release stopped when its gate closed");
// The release starts from the level the gate left off at and falls from
// there, so the end of this window has to be quieter than its start.
CATCH(errctx, akgl_audio_mix(samples, 220));
TEST_ASSERT(errctx, fabsf(samples[219]) < fabsf(samples[0]),
"the release is not attenuating (%f at its start, %f 220 frames later)",
samples[0], samples[219]);
TEST_ASSERT(errctx, peak_of(220) > 0.0f, "the release went silent immediately");
CATCH(errctx, akgl_audio_mix(samples, 250));
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == false, "the voice outlived its gate and its release");
// Stopping cuts a voice off where it stands, release and all.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_envelope(0, 0, 0, 1.0f, 1000));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, 64));
TEST_EXPECT_OK(errctx, akgl_audio_stop(0), "stopping a sounding voice");
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == false, "a stopped voice still reports itself active");
CATCH(errctx, akgl_audio_mix(samples, 64));
TEST_ASSERT_FEQ(errctx, peak_of(64), 0.0f,
"a stopped voice is still producing sound (peak %f)", peak_of(64));
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_stop(AKGL_AUDIO_MAX_VOICES),
"stopping a voice past the last one");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_audio_voice_active(AKGL_AUDIO_MAX_VOICES, &active),
"asking about a voice past the last one");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_audio_voice_active(0, NULL),
"asking about a voice with nowhere to put the answer");
} CLEANUP {
IGNORE(reset_audio());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_volume_and_mixing(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, reset_audio());
// The master level scales everything.
CATCH(errctx, akgl_audio_volume(0.25f));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, samples[0], 0.25f,
"at a quarter volume the first sample is %f, expected 0.25", samples[0]);
CATCH(errctx, akgl_audio_volume(0.0f));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, peak_of(TEST_MIX_FRAMES), 0.0f,
"at zero volume the peak is %f, expected silence",
peak_of(TEST_MIX_FRAMES));
// Voices sum, and the sum is clamped rather than wrapped: three square
// waves in phase at full level would be 3.0 without the clamp.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_tone(1, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_tone(2, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
"three voices in phase mixed to %f, expected a clamp at 1.0", samples[0]);
TEST_ASSERT_FEQ(errctx, samples[50], -1.0f,
"three voices in phase mixed to %f, expected a clamp at -1.0", samples[50]);
// Two voices at different levels sum to their total rather than to
// either one of them, which is only visible below the clamp.
CATCH(errctx, reset_audio());
CATCH(errctx, akgl_audio_volume(0.5f));
CATCH(errctx, akgl_audio_envelope(1, 0, 0, 0.5f, 0));
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_tone(1, TEST_TONE_HZ, 1000));
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
TEST_ASSERT_FEQ(errctx, samples[0], 0.75f,
"a full voice and a half voice at half volume mixed to %f, expected 0.75",
samples[0]);
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_volume(-0.1f),
"setting a negative master volume");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_volume(1.1f),
"setting a master volume past full");
} CLEANUP {
IGNORE(reset_audio());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_audio_device(void)
{
PREPARE_ERROR(errctx);
bool active = true;
ATTEMPT {
// Everything above ran without a device. This is the one test that
// opens one, so nothing that asserts on voice state runs after it.
TEST_EXPECT_OK(errctx, akgl_audio_init(), "opening an audio device");
TEST_EXPECT_OK(errctx, akgl_audio_init(), "opening an audio device a second time");
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10), "sounding a tone on a device");
TEST_EXPECT_OK(errctx, akgl_audio_shutdown(), "closing the audio device");
CATCH(errctx, akgl_audio_voice_active(0, &active));
TEST_ASSERT(errctx, active == false, "shutting down left a voice sounding");
TEST_EXPECT_OK(errctx, akgl_audio_shutdown(), "closing an audio device that is not open");
} CLEANUP {
IGNORE(akgl_audio_shutdown());
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, akgl_error_init());
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_AUDIO),
AKGL_ERR_SDL,
"Couldn't initialize SDL: %s",
SDL_GetError());
CATCH(errctx, test_audio_silence());
CATCH(errctx, test_audio_square_tone());
CATCH(errctx, test_audio_waveforms());
CATCH(errctx, test_audio_envelope());
CATCH(errctx, test_audio_duration_and_release());
CATCH(errctx, test_audio_volume_and_mixing());
CATCH(errctx, test_audio_device());
} CLEANUP {
SDL_Quit();
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}