/** * @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 #include #include #include #include #include #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); }