Files
libakgl/tests/audio.c
Andrew Kesterson 34a076b851 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

626 lines
24 KiB
C

/**
* @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.
*
* 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
/** @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_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);
}
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]);
// 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]);
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);
}
/** @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);
}
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_defaults());
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_sweep());
CATCH(errctx, test_audio_volume_and_mixing());
CATCH(errctx, test_audio_device());
} CLEANUP {
SDL_Quit();
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}