Files
akbasic/tests/audio_verbs.c
Andrew Kesterson fca9ad4a89 Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().

Two gaps were capabilities rather than inconveniences, and both are now real:

The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.

SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.

The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:25:14 -04:00

435 lines
18 KiB
C

/**
* @file audio_verbs.c
* @brief Tests the group I verbs and the PLAY queue against the mock backend.
*
* Two things here need saying about method. The conversion tables are asserted
* against numbers derived independently -- the SID clock formula, equal
* temperament from A440 -- rather than against whatever the code happens to
* produce, because a table's whole failure mode is being plausibly wrong.
*
* And the PLAY queue is driven by hand: akbasic_runtime_settime() then
* akbasic_runtime_step(), so the test owns the clock the same way a host does.
* That is the only way to assert that a note is held for its written length
* without the test sleeping through it.
*/
#include <string.h>
#include <akbasic/audio.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "mockdevice.h"
#include "testutil.h"
/** @brief Bring up a runtime with the mock devices attached and a program loaded. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
mock_devices_init();
PASS(errctx, akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS,
&MOCK_AUDIO, &MOCK_INPUT));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
SUCCEED_RETURN(errctx);
}
/**
* @brief The conversion tables, against numbers worked out independently.
*/
static void test_tables(void)
{
double hz = 0.0;
int ms = 0;
/*
* The SID computes Fout = Fn * Fclk / 2^24. At Fn = 16777 and an NTSC clock
* of 1022730 that is 1022.7 Hz, which is the arithmetic and not a reading of
* the implementation.
*/
TEST_REQUIRE_OK(akbasic_audio_register_to_hz(16777, &hz));
TEST_REQUIRE(hz > 1022.0 && hz < 1023.5, "register 16777 should be about 1022 Hz, got %f", hz);
TEST_REQUIRE_OK(akbasic_audio_register_to_hz(0, &hz));
TEST_REQUIRE_FEQ(hz, 0.0);
TEST_REQUIRE_STATUS(akbasic_audio_register_to_hz(65536, &hz), AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_audio_register_to_hz(-1, &hz), AKBASIC_ERR_BOUNDS);
/*
* Every entry of both ADSR tables, not just the ends.
*
* Mutation testing is why: checking only rate 0 and rate 15 left the twelve
* entries between them free to be any number at all, and the failure mode of
* this file is a constant that is plausibly wrong rather than obviously
* wrong. A corrupted middle entry makes one envelope decay at the wrong
* speed, which nothing else in the suite would notice and no listener could
* attribute.
*
* The values are the 6581/8580 datasheet's, transcribed here independently
* of src/audio_tables.c rather than read back from it.
*/
{
static const int attack[16] = {
2, 8, 16, 24, 38, 56, 68, 80,
100, 250, 500, 800, 1000, 3000, 5000, 8000
};
int rate = 0;
for ( rate = 0; rate < 16; rate++ ) {
TEST_REQUIRE_OK(akbasic_audio_rate_to_ms(rate, true, &ms));
TEST_REQUIRE_INT(ms, attack[rate]);
/*
* Decay and release are exactly three times attack. That is a
* property of the chip's envelope generator rather than a
* coincidence, so it is asserted as the relationship it is.
*/
TEST_REQUIRE_OK(akbasic_audio_rate_to_ms(rate, false, &ms));
TEST_REQUIRE_INT(ms, attack[rate] * 3);
}
}
TEST_REQUIRE_STATUS(akbasic_audio_rate_to_ms(16, true, &ms), AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_audio_rate_to_ms(-1, false, &ms), AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_audio_rate_to_ms(0, true, NULL), AKERR_NULLPOINTER);
/*
* Equal temperament, checked at the three points anybody would know: A4 is
* 440, the A an octave up is exactly double, and middle C is 261.63.
*/
TEST_REQUIRE_OK(akbasic_audio_note_hz(9, 4, &hz));
TEST_REQUIRE_FEQ(hz, 440.0);
TEST_REQUIRE_OK(akbasic_audio_note_hz(9, 5, &hz));
TEST_REQUIRE_FEQ(hz, 880.0);
TEST_REQUIRE_OK(akbasic_audio_note_hz(0, 4, &hz));
TEST_REQUIRE(hz > 261.5 && hz < 261.7, "C4 should be about 261.63 Hz, got %f", hz);
TEST_REQUIRE_STATUS(akbasic_audio_note_hz(12, 4, &hz), AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_audio_note_hz(0, 7, &hz), AKBASIC_ERR_BOUNDS);
/* TEMPO 8 should put a quarter note at 500 ms, which is 120 bpm. */
TEST_REQUIRE_OK(akbasic_audio_whole_note_ms(AKBASIC_TEMPO_DEFAULT, &ms));
TEST_REQUIRE_INT(ms / 4, 500);
TEST_REQUIRE_STATUS(akbasic_audio_whole_note_ms(0, &ms), AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_audio_whole_note_ms(256, &ms), AKBASIC_ERR_BOUNDS);
}
/** @brief SOUND converts its register and its jiffies, and checks its voice. */
static void test_sound(void)
{
/* 60 jiffies is one second. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 16777, 60\n"));
TEST_REQUIRE(strstr(MOCK.log, "1000ms") != NULL,
"60 jiffies should be 1000 ms, got \"%s\"", MOCK.log);
TEST_REQUIRE(strstr(MOCK.log, "tone v0 ") != NULL,
"BASIC voice 1 should reach backend voice 0, got \"%s\"", MOCK.log);
harness_stop();
TEST_REQUIRE_OK(run_program("10 SOUND 4, 1000, 10\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "SOUND voice 4") != NULL,
"voice 4 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/*
* The frequency sweep reaches the device, which it could not until libakgl
* 0.3.0 grew akgl_audio_sweep -- it was refused outright before, because
* faking it would have meant re-issuing tones from step() and tying audible
* pitch to how often the host calls us.
*
* Sweeping *down*: register 1000 is below register 16777, so the endpoints
* decide the direction and both the SID and akgl_audio_sweep read them that
* way.
*/
TEST_REQUIRE_OK(run_program("10 SOUND 1, 16777, 60, 2, 1000, 10\n"));
TEST_REQUIRE(strstr(MOCK.log, "sweep v0 ") != NULL,
"a sweep should reach the device, got \"%s\"", MOCK.log);
TEST_REQUIRE(strstr(MOCK.log, "1000ms") != NULL,
"60 jiffies should still be 1000 ms on a sweep, got \"%s\"", MOCK.log);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "") != NULL && HARNESS_OUTPUT[0] == '\0',
"a sweep should not be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A direction outside 0..3 is a typo worth catching. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 9, 500, 10\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "SOUND direction 9") != NULL,
"direction 9 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A sweep needs all three of its arguments. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 1\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "direction, a minimum and a step") != NULL,
"a partial sweep should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A sweep direction of zero is "no sweep" and must still play a held note. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 0\n"));
TEST_REQUIRE(strstr(MOCK.log, "tone v0 ") != NULL,
"direction 0 is not a sweep and should play, got \"%s\"", MOCK.log);
harness_stop();
/*
* A backend with no sweep is still a valid backend -- that is what a host
* written against libakgl 0.2.0 looks like -- and SOUND refuses the swept
* note rather than dereferencing a NULL entry point.
*/
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK_AUDIO.sweep = NULL;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS,
&MOCK_AUDIO, &MOCK_INPUT));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SOUND 1, 1000, 60, 1, 500, 10\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "audio device that can sweep") != NULL,
"a backend without sweep should refuse, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief ENVELOPE maps the SID rate numbers, and sustain is a level not a time. */
static void test_envelope(void)
{
TEST_REQUIRE_OK(run_program("10 ENVELOPE 3, 0, 0, 15, 0, 1\n"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.envelopes[3].attack, 2);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.envelopes[3].decay, 6);
TEST_REQUIRE_FEQ(HARNESS_RUNTIME.audio_state.envelopes[3].sustain, 1.0);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.envelopes[3].waveform, AKBASIC_WAVE_SAWTOOTH);
harness_stop();
/* Sustain 0 is silence, and is a legal thing to ask for. */
TEST_REQUIRE_OK(run_program("10 ENVELOPE 0, 0, 0, 0\n"));
TEST_REQUIRE_FEQ(HARNESS_RUNTIME.audio_state.envelopes[0].sustain, 0.0);
harness_stop();
TEST_REQUIRE_OK(run_program("10 ENVELOPE 10\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "ENVELOPE 10") != NULL,
"preset 10 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 ENVELOPE 0, 16\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "ENVELOPE rate 16") != NULL,
"rate 16 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* Defining an instrument needs no device; a host may not have lent one yet. */
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 ENVELOPE 1, 5\n20 VOL 8\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "");
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.envelopes[1].attack, 56);
harness_stop();
}
/** @brief VOL and TEMPO record their setting and range-check it. */
static void test_vol_and_tempo(void)
{
TEST_REQUIRE_OK(run_program("10 VOL 15\n"));
TEST_REQUIRE_STR(MOCK.log, "vol 1.00\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 VOL 0\n"));
TEST_REQUIRE_STR(MOCK.log, "vol 0.00\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 VOL 16\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "VOL 16") != NULL,
"VOL 16 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/*
* A tempo change rescales the *current* note length rather than resetting it
* to a quarter note: a program that set eighth notes and then doubled the
* tempo means faster eighth notes.
*/
TEST_REQUIRE_OK(run_program("10 TEMPO 16\n"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.tempo, 16);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.notems, 250);
harness_stop();
TEST_REQUIRE_OK(run_program("10 TEMPO 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "TEMPO 0") != NULL,
"TEMPO 0 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief Bring up a runtime and parse a PLAY string without draining the queue.
*
* akbasic_play_parse() is called directly rather than through a program, because
* running one would also run akbasic_play_service() -- and with the clock left at
* zero every note's duration has already expired, so the queue would be empty
* again before the assertions could look at it. That draining is correct
* behaviour, tested on its own in test_play_is_not_blocking(); here the question
* is only what a string parses *to*.
*/
static akerr_ErrorContext AKERR_NOIGNORE *parse_notes(const char *notes)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
mock_devices_init();
PASS(errctx, akbasic_runtime_set_devices(&HARNESS_RUNTIME, NULL, &MOCK_AUDIO, NULL));
PASS(errctx, akbasic_play_parse(&HARNESS_RUNTIME, notes));
SUCCEED_RETURN(errctx);
}
/** @brief The PLAY string parser: notes, octaves, accidentals, rests and durations. */
static void test_play_parse(void)
{
TEST_REQUIRE_OK(parse_notes("O4C"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.count, 1);
TEST_REQUIRE(HARNESS_RUNTIME.audio_state.queue[0].hz > 261.5 &&
HARNESS_RUNTIME.audio_state.queue[0].hz < 261.7,
"O4C should be middle C, got %f", HARNESS_RUNTIME.audio_state.queue[0].hz);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[0].ms, 500);
harness_stop();
/* A sharp raises by a semitone; C# and D$ are the same pitch. */
TEST_REQUIRE_OK(parse_notes("O4#CO4$D"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.count, 2);
TEST_REQUIRE_FEQ(HARNESS_RUNTIME.audio_state.queue[0].hz,
HARNESS_RUNTIME.audio_state.queue[1].hz);
harness_stop();
/*
* B# belongs in the octave above and C$ in the one below. Wrapping the
* semitone without moving the octave would put B# an octave too low, which
* is the classic way to get this wrong.
*/
TEST_REQUIRE_OK(parse_notes("O4#BO5C"));
TEST_REQUIRE_FEQ(HARNESS_RUNTIME.audio_state.queue[0].hz,
HARNESS_RUNTIME.audio_state.queue[1].hz);
harness_stop();
/* Durations persist until changed, and a dot adds half again. */
TEST_REQUIRE_OK(parse_notes("HCD.E"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.count, 3);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[0].ms, 1000);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[1].ms, 1000);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[2].ms, 1500);
harness_stop();
/* A rest occupies its time and makes no sound. */
TEST_REQUIRE_OK(parse_notes("CRC"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.count, 3);
TEST_REQUIRE(HARNESS_RUNTIME.audio_state.queue[1].rest, "R should queue a rest");
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[1].ms, 500);
harness_stop();
/* V selects the voice for everything after it. */
TEST_REQUIRE_OK(parse_notes("CV2D"));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[0].voice, 0);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.queue[1].voice, 1);
harness_stop();
TEST_REQUIRE_OK(run_program("10 PLAY \"Z\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "does not understand") != NULL,
"an unknown PLAY letter should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 PLAY \"V9C\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "PLAY V9") != NULL,
"voice 9 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* X selects the filter, which is the one thing here with nothing behind it. */
TEST_REQUIRE_OK(run_program("10 PLAY \"X1C\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "filter") != NULL,
"PLAY X should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief PLAY does not block, and the queue drains against the host's clock.
*
* This is the assertion that matters most in the file. A C128 holds the program
* inside PLAY until the last note ends; here PLAY returns immediately and the
* notes come out of step(). The test owns the clock, so it can assert a note was
* held for its full length without waiting through it.
*/
static void test_play_is_not_blocking(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, NULL, &MOCK_AUDIO, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PLAY \"QCD\"\n20 PRINT \"AFTER\"\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
/*
* The program ran to completion -- PRINT on line 20 happened -- with a
* second of music still outstanding. That is the whole point: on a C128 the
* program would have sat inside PLAY for that second.
*
* One note has already gone out. The clock was never set, so it still reads
* zero, and the step that followed PLAY found the head note's start time
* (also zero) already reached. Both notes are still counted; only the head
* has moved.
*/
TEST_REQUIRE_STR(HARNESS_OUTPUT, "AFTER\n");
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.count, 2);
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.head, 1);
TEST_REQUIRE(strstr(MOCK.log, "tone v0 261") != NULL,
"the first note should have gone out during the run, got \"%s\"", MOCK.log);
mock_log_reset();
/* Halfway through it, nothing new comes out. */
TEST_REQUIRE_OK(akbasic_runtime_settime(&HARNESS_RUNTIME, 250));
TEST_REQUIRE_OK(akbasic_runtime_step(&HARNESS_RUNTIME));
TEST_REQUIRE_STR(MOCK.log, "");
/* At its end, the second note starts. */
TEST_REQUIRE_OK(akbasic_runtime_settime(&HARNESS_RUNTIME, 500));
TEST_REQUIRE_OK(akbasic_runtime_step(&HARNESS_RUNTIME));
TEST_REQUIRE(strstr(MOCK.log, "tone v0 293") != NULL,
"the second note should have been released, got \"%s\"", MOCK.log);
/* Once drained, the queue resets so the next PLAY starts from the front. */
TEST_REQUIRE_OK(akbasic_runtime_settime(&HARNESS_RUNTIME, 1000));
TEST_REQUIRE_OK(akbasic_runtime_step(&HARNESS_RUNTIME));
TEST_REQUIRE_INT(HARNESS_RUNTIME.audio_state.count, 0);
harness_stop();
}
/** @brief FILTER is refused rather than ignored, and says why. */
static void test_filter(void)
{
TEST_REQUIRE_OK(run_program("10 FILTER 1000, 1, 0, 0, 5\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "filter stage") != NULL,
"FILTER should be refused with a reason, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief The verbs that need a device name themselves when there is none. */
static void test_no_device(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SOUND 1, 1000, 10\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "SOUND needs an audio device") != NULL,
"SOUND without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PLAY \"C\"\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "PLAY needs an audio device") != NULL,
"PLAY without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
int main(void)
{
test_tables();
test_sound();
test_envelope();
test_vol_and_tempo();
test_play_parse();
test_play_is_not_blocking();
test_filter();
test_no_device();
return akbasic_test_failures;
}