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 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"
|
|
|
|
|
|
2026-07-31 07:30:18 -04:00
|
|
|
/**
|
|
|
|
|
* @brief Frames of output most tests generate at a time.
|
|
|
|
|
*
|
|
|
|
|
* Enough to hold a 10 ms attack and a 10 ms decay back to back at 44100 frames
|
|
|
|
|
* per second, which is the longest single stretch any test here inspects.
|
|
|
|
|
*/
|
|
|
|
|
#define TEST_MIX_FRAMES 1024
|
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 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 07:30:18 -04:00
|
|
|
akerr_ErrorContext *test_audio_defaults(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
// Deliberately the first thing this file does, and deliberately without
|
|
|
|
|
// a call to reset_audio(): a voice nobody has configured has to make a
|
|
|
|
|
// sound. A zeroed voice would have a sustain of 0.0 and be silent, with
|
|
|
|
|
// no error to say why, which is exactly the trap this defends against.
|
|
|
|
|
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,
|
|
|
|
|
"an unconfigured voice mixed to %f, expected a full-level square wave",
|
|
|
|
|
samples[0]);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, samples[50], -1.0f,
|
|
|
|
|
"an unconfigured voice is not a square wave (%f half a cycle in)",
|
|
|
|
|
samples[50]);
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
IGNORE(reset_audio());
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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]);
|
|
|
|
|
|
2026-07-31 07:30:18 -04:00
|
|
|
// Attack and decay together, which is the case where the decay has to
|
|
|
|
|
// measure from the end of the attack rather than from the start of the
|
|
|
|
|
// note. 10 ms of each is 441 frames of each at 44100.
|
|
|
|
|
CATCH(errctx, reset_audio());
|
|
|
|
|
CATCH(errctx, akgl_audio_envelope(0, 10, 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[441], 1.0f,
|
|
|
|
|
"at the end of the attack the level is %f, expected full", samples[441]);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, samples[661], -(1.0f - (0.5f * (220.0f / 441.0f))),
|
|
|
|
|
"halfway through the decay the level is %f, expected the halfway ramp",
|
|
|
|
|
samples[661]);
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, samples[900], 0.5f,
|
|
|
|
|
"after the decay the level is %f, expected the 0.5 sustain", samples[900]);
|
|
|
|
|
|
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
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
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 Mix @p ticks whole sweep steps, one step per call. */
|
|
|
|
|
static akerr_ErrorContext *mix_sweep_ticks(int ticks)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
int i = 0;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
for ( i = 0; i < ticks; i++ ) {
|
|
|
|
|
CATCH(errctx, akgl_audio_mix(samples, AKGL_AUDIO_SWEEP_TICK_FRAMES));
|
|
|
|
|
}
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
akerr_ErrorContext *test_audio_sweep(void)
|
|
|
|
|
{
|
|
|
|
|
PREPARE_ERROR(errctx);
|
|
|
|
|
bool active = false;
|
|
|
|
|
|
|
|
|
|
ATTEMPT {
|
|
|
|
|
CATCH(errctx, reset_audio());
|
|
|
|
|
|
|
|
|
|
// 400 Hz climbing to 500 in 25 Hz steps: four steps, at 60 steps per
|
|
|
|
|
// second. The pitch is advanced by the mixer's own frame counter, so
|
|
|
|
|
// these assertions are about exact frames rather than about how often
|
|
|
|
|
// the caller happened to ask for samples.
|
|
|
|
|
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 400.0f, 500.0f, 25.0f, 1000),
|
|
|
|
|
"sounding a rising sweep");
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
|
|
|
|
|
"a sweep starts at %f, expected its start frequency",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
// One tick's worth of frames is not yet a step: the last frame of that
|
|
|
|
|
// window is still the first tick.
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(1));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
|
|
|
|
|
"the pitch moved at %f before the first step was due",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
TEST_ASSERT(errctx, peak_of(AKGL_AUDIO_SWEEP_TICK_FRAMES) > 0.5f,
|
|
|
|
|
"a sweep is not making a sound (peak %f)",
|
|
|
|
|
peak_of(AKGL_AUDIO_SWEEP_TICK_FRAMES));
|
|
|
|
|
|
|
|
|
|
// The frame after it is.
|
|
|
|
|
CATCH(errctx, akgl_audio_mix(samples, 1));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 425.0f,
|
|
|
|
|
"one step in, the pitch is %f, expected 425",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
// It stops when it arrives rather than running past the target.
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(8));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 500.0f,
|
|
|
|
|
"a finished sweep sits at %f, expected its target 500",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
// Downward is the same sweep with the frequencies the other way round.
|
|
|
|
|
// The step stays positive; the direction is not its sign.
|
|
|
|
|
CATCH(errctx, reset_audio());
|
|
|
|
|
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 500.0f, 400.0f, 25.0f, 1000),
|
|
|
|
|
"sounding a falling sweep");
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(1));
|
|
|
|
|
CATCH(errctx, akgl_audio_mix(samples, 1));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 475.0f,
|
|
|
|
|
"one step down, the pitch is %f, expected 475",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(8));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
|
|
|
|
|
"a finished falling sweep sits at %f, expected its target 400",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
// Equal frequencies are a held tone, not an error: a caller translating
|
|
|
|
|
// a statement that computes its own limits does not have to special
|
|
|
|
|
// case them.
|
|
|
|
|
CATCH(errctx, reset_audio());
|
|
|
|
|
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 440.0f, 440.0f, 10.0f, 1000),
|
|
|
|
|
"sweeping between two identical frequencies");
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(4));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 440.0f,
|
|
|
|
|
"a sweep with nowhere to go moved to %f", akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
// The gate still governs: a sweep with further to go than the note lasts
|
|
|
|
|
// ends with the note, part way up. 30 ms is 1323 frames, which is one
|
|
|
|
|
// whole step and most of a second.
|
|
|
|
|
CATCH(errctx, reset_audio());
|
|
|
|
|
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 400.0f, 4000.0f, 25.0f, 30),
|
|
|
|
|
"sounding a sweep longer than its gate");
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(3));
|
|
|
|
|
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
|
|
|
|
TEST_ASSERT(errctx, active == false, "a swept voice outlived its gate");
|
|
|
|
|
TEST_ASSERT(errctx, akgl_audio_voices[0].hz > 400.0f,
|
|
|
|
|
"a sweep cut off by its gate never stepped at all (%f)",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
TEST_ASSERT(errctx, akgl_audio_voices[0].hz < 4000.0f,
|
|
|
|
|
"a sweep cut off by its gate reached %f anyway",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
// A voice reused for a plain tone stops sweeping.
|
|
|
|
|
CATCH(errctx, reset_audio());
|
|
|
|
|
CATCH(errctx, akgl_audio_sweep(0, 400.0f, 800.0f, 25.0f, 1000));
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(2));
|
|
|
|
|
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000),
|
|
|
|
|
"sounding a plain tone on a voice that was sweeping");
|
|
|
|
|
CATCH(errctx, mix_sweep_ticks(4));
|
|
|
|
|
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, TEST_TONE_HZ,
|
|
|
|
|
"a plain tone on a swept voice drifted to %f",
|
|
|
|
|
akgl_audio_voices[0].hz);
|
|
|
|
|
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
|
|
|
|
akgl_audio_sweep(AKGL_AUDIO_MAX_VOICES, 400.0f, 500.0f, 25.0f, 100),
|
|
|
|
|
"sweeping a voice past the last one");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
|
|
|
|
akgl_audio_sweep(0, 0.0f, 500.0f, 25.0f, 100),
|
|
|
|
|
"sweeping from zero hertz");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
|
|
|
|
akgl_audio_sweep(0, 400.0f, -1.0f, 25.0f, 100),
|
|
|
|
|
"sweeping to a negative frequency");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
|
|
|
|
akgl_audio_sweep(0, 400.0f, 500.0f, 0.0f, 100),
|
|
|
|
|
"sweeping with no step");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
|
|
|
|
akgl_audio_sweep(0, 500.0f, 400.0f, -25.0f, 100),
|
|
|
|
|
"sweeping down with a negative step");
|
|
|
|
|
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
|
|
|
|
akgl_audio_sweep(0, 400.0f, 500.0f, 25.0f, 0),
|
|
|
|
|
"sweeping with no duration");
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
IGNORE(reset_audio());
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH(errctx, true);
|
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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());
|
Namespace every exported symbol, and bump to 0.5.0
Closes internal-consistency items 1 through 6, 12 and 13. Every include guard
is _AKGL_<FILE>_H_, every in-project header include is angled, and every
exported function, type and global carries the akgl_ prefix. This is an ABI
break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename
table.
The renames were driven by renaming each declaration and letting the compiler
find the uses, not by pattern substitution: renderer, physics and camera are
also parameter and struct-member names, and a sed would have rewritten
map->physics and every akgl_RenderBackend *renderer parameter without a word.
Item 4 turned out not to be cosmetic. The library exported a global called
renderer and tests/character.c defined an SDL_Renderer *renderer of its own;
the executable's definition preempted the library's, akgl_sprite_load_json
read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load
in that suite failed. The suite reported success anyway, because libakerror's
unhandled-error handler ends in exit(errctx->status), exit keeps only the low
byte, and AKGL_ERR_SDL is exactly 256. So character had been green while
running one of its four tests, and every suite in the tree was unable to fail
on the most common status in a library built on SDL.
Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which
collapses any status a byte cannot carry onto 1, and every suite installs it.
character binds a real backend with akgl_render_2d_bind. Its fourth test then
runs for the first time and fails on a defect it has asserted all along, so
akgl_heap_release_character now walks state_sprites with
AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot
-- TODO.md Defects item 21 and half of Carried over item 1.
AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so
akgl_game_state_lock waited roughly sixteen minutes rather than one second. It
is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and
tests/game.c holds the mutex from a second thread to assert the wait -- the
contended path had no coverage at all.
Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both
install() and a generated translation unit per header, so a header that ships
is a header that is checked. Writing that found registry.h, which used
SDL_PropertiesID in eight declarations and included no SDL header.
23/23 suites pass, memcheck is clean, reindent --check is clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
|
|
|
TEST_TRAP_UNHANDLED_ERRORS();
|
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
|
|
|
FAIL_ZERO_BREAK(
|
|
|
|
|
errctx,
|
|
|
|
|
SDL_Init(SDL_INIT_AUDIO),
|
|
|
|
|
AKGL_ERR_SDL,
|
|
|
|
|
"Couldn't initialize SDL: %s",
|
|
|
|
|
SDL_GetError());
|
|
|
|
|
|
2026-07-31 07:30:18 -04:00
|
|
|
CATCH(errctx, test_audio_defaults());
|
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
|
|
|
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());
|
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
|
|
|
CATCH(errctx, test_audio_sweep());
|
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
|
|
|
CATCH(errctx, test_audio_volume_and_mixing());
|
|
|
|
|
CATCH(errctx, test_audio_device());
|
|
|
|
|
} CLEANUP {
|
|
|
|
|
SDL_Quit();
|
|
|
|
|
} PROCESS(errctx) {
|
|
|
|
|
} FINISH_NORETURN(errctx);
|
|
|
|
|
}
|