Files
akbasic/tests/audio_verbs.c

435 lines
18 KiB
C
Raw Normal View History

Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
/**
* @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);
Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family could not report a conversion failure at all -- atoi("not a number") returned success with 0, turning four diagnosable errors into wrong answers. Its own note said what to do when that changed: "When it grows it, delete src/convert.c and switch the call sites over." 0.2.0 grew it, so it is gone. Six call sites go straight to the library now: both literal constructors in src/grammar.c, the line number in src/scanner.c, FunctionVAL in src/runtime_functions.c, INPUT in src/runtime_commands.c, and GSHAPE's handle in src/runtime_graphics.c. aksl_strtoll(str, NULL, base, &dest) rather than aksl_atoll wherever a base is involved, because the ato* forms are base 10 and grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what makes trailing junk an error rather than a stopping point. The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes message text part of the acceptance contract, so that was checked against the corpus before touching anything: no golden file contained a conversion message, which is also why section 1.9 had been asking for one. Two exist now, so the next change to that text has to move a golden file, and one of them pins the reference's octal-literal defect (section 6 item 10) while it is still deliberately reproduced. tests/convert.c became tests/numeric_contract.c rather than being deleted with the code. The assertions did not stop being worth making when the wrapper went away -- they became assertions about a contract this port depends on and no longer owns, and a regression in it would make four things quietly return zero. Repoints the CI mutation job, which was bounded to src/convert.c and src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65. src/audio_tables.c was measured as a replacement second file and scored 64.7%: that is a real gap rather than a reason to skip it, since almost every survivor is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio state is actually zeroed -- the same gap this job's history records closing for symtab. Recorded in TODO.md so the file can earn its place back. One thing worth knowing before reading any score here, and now written down: the harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of other values produces no mutants and a high score over one says nothing about whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR entries against the datasheet anyway, because a hand-edit typo is the real failure mode there -- but that could not and did not move the mutation number, and claiming otherwise would be the kind of thing this file exists to stop. 72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and 97.8% function, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
/*
* 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);
}
}
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
TEST_REQUIRE_STATUS(akbasic_audio_rate_to_ms(16, true, &ms), AKBASIC_ERR_BOUNDS);
Delete src/convert.c: libakstdlib 0.2.0 grew the contract it existed for That file wrapped strtoll and strtod because libakstdlib's aksl_ato* family could not report a conversion failure at all -- atoi("not a number") returned success with 0, turning four diagnosable errors into wrong answers. Its own note said what to do when that changed: "When it grows it, delete src/convert.c and switch the call sites over." 0.2.0 grew it, so it is gone. Six call sites go straight to the library now: both literal constructors in src/grammar.c, the line number in src/scanner.c, FunctionVAL in src/runtime_functions.c, INPUT in src/runtime_commands.c, and GSHAPE's handle in src/runtime_graphics.c. aksl_strtoll(str, NULL, base, &dest) rather than aksl_atoll wherever a base is involved, because the ato* forms are base 10 and grammar.c picks base 8 or 16 off the lexeme's prefix; the NULL endptr is what makes trailing junk an error rather than a stopping point. The raised status changed from AKBASIC_ERR_VALUE to AKERR_VALUE, and the message with it -- VAL("garbage") now says `no digits in "garbage"`. Section 1.8 makes message text part of the acceptance contract, so that was checked against the corpus before touching anything: no golden file contained a conversion message, which is also why section 1.9 had been asking for one. Two exist now, so the next change to that text has to move a golden file, and one of them pins the reference's octal-literal defect (section 6 item 10) while it is still deliberately reproduced. tests/convert.c became tests/numeric_contract.c rather than being deleted with the code. The assertions did not stop being worth making when the wrapper went away -- they became assertions about a contract this port depends on and no longer owns, and a regression in it would make four things quietly return zero. Repoints the CI mutation job, which was bounded to src/convert.c and src/symtab.c. src/symtab.c alone measures 74.1% against the gate of 65. src/audio_tables.c was measured as a replacement second file and scored 64.7%: that is a real gap rather than a reason to skip it, since almost every survivor is in akbasic_audio_state_init, where nothing asserts a freshly initialised audio state is actually zeroed -- the same gap this job's history records closing for symtab. Recorded in TODO.md so the file can earn its place back. One thing worth knowing before reading any score here, and now written down: the harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of other values produces no mutants and a high score over one says nothing about whether its entries are right. tests/audio_verbs.c now asserts all 32 ADSR entries against the datasheet anyway, because a hand-edit typo is the real failure mode there -- but that could not and did not move the mutation number, and claiming otherwise would be the kind of thing this file exists to stop. 72/72 core, 73/73 with libakgl, 72/72 under ASan+UBSan, coverage 93.6% line and 97.8% function, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:04 -04:00
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);
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
/*
* 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.
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
*/
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);
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
harness_stop();
/* A sweep direction of zero is "no sweep" and must still play a held note. */
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
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();
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:29:04 -04:00
}
/** @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;
}