From e24926d429e1214f4508a478d9c5a21e38407ca4 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Fri, 31 Jul 2026 08:29:04 -0400 Subject: [PATCH] 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) --- CMakeLists.txt | 4 + TODO.md | 43 +++ include/akbasic/audio.h | 147 ++++++++++ include/akbasic/runtime.h | 7 + src/audio_tables.c | 197 +++++++++++++ src/main.c | 59 +++- src/play.c | 277 ++++++++++++++++++ src/runtime.c | 9 + src/runtime_audio.c | 306 +++++++++++++++++++ src/verbs.c | 6 + src/verbs.h | 8 + tests/audio_verbs.c | 371 ++++++++++++++++++++++++ tests/language/audio/filter_refused.bas | 7 + tests/language/audio/filter_refused.txt | 3 + tests/language/audio/no_device.bas | 8 + tests/language/audio/no_device.txt | 3 + 16 files changed, 1453 insertions(+), 2 deletions(-) create mode 100644 src/audio_tables.c create mode 100644 src/play.c create mode 100644 src/runtime_audio.c create mode 100644 tests/audio_verbs.c create mode 100644 tests/language/audio/filter_refused.bas create mode 100644 tests/language/audio/filter_refused.txt create mode 100644 tests/language/audio/no_device.bas create mode 100644 tests/language/audio/no_device.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index bc25c8e..177b160 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,6 +97,7 @@ endfunction() # process-terminating call, and buildable with no libakgl present. # --------------------------------------------------------------------------- set(AKBASIC_SOURCES + src/audio_tables.c src/convert.c src/environment.c src/error.c @@ -104,7 +105,9 @@ set(AKBASIC_SOURCES src/graphics_tables.c src/parser.c src/parser_commands.c + src/play.c src/runtime.c + src/runtime_audio.c src/runtime_commands.c src/runtime_functions.c src/runtime_graphics.c @@ -187,6 +190,7 @@ endif() # still selects it. # --------------------------------------------------------------------------- set(AKBASIC_TESTS + audio_verbs convert devices environment_scope diff --git a/TODO.md b/TODO.md index de89f3a..cd8960f 100644 --- a/TODO.md +++ b/TODO.md @@ -523,6 +523,35 @@ behaviour to port. They matter to somebody typing in a listing out of a C128 man the host's job.** The interpreter cannot ask the renderer how big it is without owning it. `SCALE` maps user coordinates onto that space; with `SCALE` off they pass through. +19. **`PLAY` does not block.** On a C128 `PLAY` holds the program until the last note ends. + §1.6 forbids that outright, so `PLAY` 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()`. **What this changes for a program:** the statement + after a `PLAY` runs immediately rather than a bar later, so a listing that relied on `PLAY` + for timing will race ahead. A program that wants to wait should test the queue rather than + assume the verb waits for it. + +20. **The host owns the clock, and an unset clock rushes the music.** The library reads no + clock — it owns no loop and must not block. A host calls `akbasic_runtime_settime()` once a + frame; the standalone driver calls it every step off `CLOCK_MONOTONIC`. Left unset it reads + zero forever, so every duration has already expired and the queue drains as fast as + `step()` is called. Audible, deterministic, and never a hang, which is the right way for + that to fail. + +21. **`PLAY`'s `M` (measure) is a no-op and `SOUND`'s frequency sweep is refused.** `M` + synchronises the three voices on a C128; with one sequential queue there is nothing to + synchronise, and a listing full of them should still play, so it is accepted and ignored. + The sweep is the opposite call: `SOUND`'s `dir`/`min`/`step` arguments have no + `akgl_audio_*` equivalent, and the only way to fake one is to re-issue tones from + `step()` — which would tie audible pitch to how often the host happens to call us, a tune + that changes key with the frame rate. Filed upstream as `akgl_audio_sweep`; see §7. + +22. **`TEMPO`'s constant is a calibration choice, not a transcription.** BASIC 7.0 documents + `TEMPO` as a relative duration from 1 to 255 defaulting to 8, and does not publish what a + whole note actually lasts. `src/audio_tables.c` uses 16000 ms at `TEMPO` 1, which puts a + default-tempo quarter note at 500 ms — 120 beats per minute. If the real constant turns + up, that is the one line to change. + --- ## 6. Reference defects — ported faithfully, fix them deliberately @@ -691,6 +720,20 @@ note-string parser, both of which belong in the interpreter rather than here."* the parser and refused at execution with `AKBASIC_ERR_DEVICE` rather than silently ignored — a program that asks for a low-pass and gets an unfiltered square wave has been lied to. +A second gap turned up while implementing group I, and is filed alongside it: + +- **`SOUND` has no frequency sweep.** BASIC 7.0's `SOUND voice, freq, dur, dir, min, step` + ramps the pitch from `freq` toward `min` in `step` increments per tick, in the direction + `dir` selects. `akgl_audio_tone(voice, hz, ms)` holds one pitch for one duration and there + is no entry point that changes pitch over the life of a note. The wanted API is roughly + `akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)`, + advanced on the mixer's own frame counter — the same place the phase is already derived + from, and the reason it belongs there rather than here. Faking it in the interpreter means + re-issuing tones from `akbasic_runtime_step()`, which ties audible pitch to how often a host + calls us: a tune that changes key with the frame rate. Refused with `AKBASIC_ERR_DEVICE` + until it lands. Tests would drive `akgl_audio_mix` by hand and assert the frame at which the + pitch has moved, exactly as `tests/audio.c` already does for the envelope. + When the next gap turns up, file it there rather than working around it here. --- diff --git a/include/akbasic/audio.h b/include/akbasic/audio.h index ee9ba47..e8e46e2 100644 --- a/include/akbasic/audio.h +++ b/include/akbasic/audio.h @@ -23,6 +23,17 @@ /** @brief Number of independent voices, matching the SID and matching libakgl. */ #define AKBASIC_AUDIO_VOICES 3 +/** @brief How many ENVELOPE presets a program may define. BASIC 7.0 numbers them 0 through 9. */ +#define AKBASIC_ENVELOPES 10 + +/** @brief How many notes one PLAY string may queue. */ +#define AKBASIC_MAX_PLAY_NOTES 128 + +/** @brief TEMPO's range, as BASIC 7.0 states it. */ +#define AKBASIC_TEMPO_MIN 1 +#define AKBASIC_TEMPO_MAX 255 +#define AKBASIC_TEMPO_DEFAULT 8 + /** * @brief Waveform selection, numbered as BASIC 7.0's SOUND numbers it. * @@ -68,4 +79,140 @@ typedef struct akbasic_AudioBackend akerr_ErrorContext AKERR_NOIGNORE *(*voice_active)(struct akbasic_AudioBackend *self, int voice, bool *active); } akbasic_AudioBackend; +/** @brief One ENVELOPE preset, in the units the backend takes. */ +typedef struct +{ + int attack; /* milliseconds */ + int decay; /* milliseconds */ + double sustain; /* level from 0.0 to 1.0, not a time */ + int release; /* milliseconds */ + akbasic_Waveform waveform; +} akbasic_Envelope; + +/** @brief One note waiting to be played. A rest occupies time and makes no sound. */ +typedef struct +{ + int voice; + double hz; + int ms; + bool rest; +} akbasic_PlayNote; + +/** + * @brief The sound verbs' own state, which lives on the runtime. + * + * The queue is why PLAY does not block. On a C128, PLAY holds the program until + * the string finishes; section 1.6 forbids that, so PLAY parses the string into + * this queue and returns, and akbasic_runtime_step() releases one note at a time + * against the host's clock. A host keeps its frame rate and the notes still come + * out in order, at their written lengths. + */ +typedef struct +{ + akbasic_Envelope envelopes[AKBASIC_ENVELOPES]; + akbasic_PlayNote queue[AKBASIC_MAX_PLAY_NOTES]; + int head; /* next note to start */ + int count; /* notes queued */ + int64_t nextms; /* host time the head note may start at */ + bool sounding; /* a queued note is currently held */ + + int tempo; /* TEMPO, 1 to 255 */ + int voice; /* PLAY's current voice, 0-based */ + int octave; /* PLAY's current octave, 0 to 6 */ + int envelope; /* PLAY's current ENVELOPE preset */ + int notems; /* PLAY's current note length in milliseconds */ + double level; /* VOL, 0.0 to 1.0 */ +} akbasic_AudioState; + +/** + * @brief Reset the sound state to its power-on values. + * @param obj Object to initialize, inspect, or modify. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_state_init(akbasic_AudioState *obj); + +/** + * @brief Convert a SOUND frequency register value to hertz. + * + * BASIC's SOUND takes the number that would go into a SID frequency register, + * not a pitch. The backend takes hertz. + * + * @param value Register value, 0 through 65535. + * @param dest Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `dest` is NULL. + * @throws AKBASIC_ERR_BOUNDS When `value` is outside 0..65535. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_register_to_hz(int value, double *dest); + +/** + * @brief Convert an ENVELOPE rate number to milliseconds. + * + * BASIC's ENVELOPE takes the SID's 0-15 rate numbers, whose real durations are + * non-linear and tabulated in silicon rather than computed. + * + * @param rate Rate number, 0 through 15. + * @param isattack True for the attack table, false for the decay/release one. + * @param dest Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `dest` is NULL. + * @throws AKBASIC_ERR_BOUNDS When `rate` is outside 0..15. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_rate_to_ms(int rate, bool isattack, int *dest); + +/** + * @brief Pitch of a note, by semitone offset from C and by octave. + * @param semitone Offset from C within the octave, 0 through 11. + * @param octave Octave number, 0 through 6, where 4 holds the A of 440 Hz. + * @param dest Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `dest` is NULL. + * @throws AKBASIC_ERR_BOUNDS When either argument is out of range. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_note_hz(int semitone, int octave, double *dest); + +/** + * @brief How long a whole note lasts at a given TEMPO, in milliseconds. + * @param tempo TEMPO value, 1 through 255. + * @param dest Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `dest` is NULL. + * @throws AKBASIC_ERR_BOUNDS When `tempo` is outside 1..255. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_whole_note_ms(int tempo, int *dest); + +/* --- Internal API: exposed for the PLAY verb, the step loop and the tests. --- */ + +struct akbasic_Runtime; + +/** + * @brief Parse a PLAY string and append its notes to the queue. + * + * Split out from the verb so a test can assert what a string parses *to* without + * also having to drive the clock that drains it. + * + * @param obj Object to initialize, inspect, or modify. + * @param notes A PLAY note string. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When either argument is NULL. + * @throws AKBASIC_ERR_SYNTAX When the string contains something PLAY does not define. + * @throws AKBASIC_ERR_BOUNDS When a setting is out of range or the queue is full. + * @throws AKBASIC_ERR_DEVICE When the string selects the filter, which does not exist. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_play_parse(struct akbasic_Runtime *obj, const char *notes); + +/** + * @brief Release the next queued note if the one before it has run out. + * + * Called from akbasic_runtime_step(). This is what makes PLAY non-blocking: the + * verb queues and returns, and the notes come out here against whatever time the + * host last handed to akbasic_runtime_settime(). + * + * @param obj Object to initialize, inspect, or modify. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_play_service(struct akbasic_Runtime *obj); + #endif // _AKBASIC_AUDIO_H_ diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index 84f22bd..3fc08dd 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -104,6 +104,13 @@ typedef struct akbasic_Runtime */ akbasic_GraphicsState gfx; + /* + * The sound verbs' state, including the PLAY queue. Same reasoning as gfx: + * TEMPO, the ENVELOPE presets and the current octave are the program's, not + * the device's. + */ + akbasic_AudioState audio_state; + /* * The host's clock, in milliseconds, as of its last akbasic_runtime_settime() * call. The interpreter does not read a clock: it owns no loop and must not diff --git a/src/audio_tables.c b/src/audio_tables.c new file mode 100644 index 0000000..7a74041 --- /dev/null +++ b/src/audio_tables.c @@ -0,0 +1,197 @@ +/** + * @file audio_tables.c + * @brief The SOUND, ENVELOPE and PLAY conversions, laid out as the tables they are. + * + * BASIC 7.0's sound verbs speak in SID register values: SOUND takes a frequency + * *register*, not a pitch, and ENVELOPE takes the SID's 0-15 rate numbers, whose + * real durations are tabulated in silicon rather than computed. The backend takes + * hertz and milliseconds. These are the conversions between the two, and they are + * kept in one file because each is somewhere a wrong constant produces a + * plausible wrong pitch rather than an error somebody would notice. + * + * Two of the three are transcriptions. The third -- TEMPO -- is a calibration + * choice and is labelled as one where it is made. + */ + +#include + +#include + +#include +#include + +/** + * @brief The SID's attack rates, register value to milliseconds. + * + * Non-linear, and not a curve anybody would guess: the steps are small at the + * bottom and enormous at the top. This is the 6581/8580 table. + */ +static const int ATTACK_MS[16] = { + 2, /* 0 */ + 8, /* 1 */ + 16, /* 2 */ + 24, /* 3 */ + 38, /* 4 */ + 56, /* 5 */ + 68, /* 6 */ + 80, /* 7 */ + 100, /* 8 */ + 250, /* 9 */ + 500, /* 10 */ + 800, /* 11 */ + 1000, /* 12 */ + 3000, /* 13 */ + 5000, /* 14 */ + 8000 /* 15 */ +}; + +/** + * @brief The SID's decay and release rates, register value to milliseconds. + * + * Exactly three times the attack table -- that is a property of the chip's + * envelope generator, not a coincidence, so it is written out rather than + * computed. A reader checking this against a datasheet should see the datasheet's + * numbers. + */ +static const int DECAY_MS[16] = { + 6, /* 0 */ + 24, /* 1 */ + 48, /* 2 */ + 72, /* 3 */ + 114, /* 4 */ + 168, /* 5 */ + 204, /* 6 */ + 240, /* 7 */ + 300, /* 8 */ + 750, /* 9 */ + 1500, /* 10 */ + 2400, /* 11 */ + 3000, /* 12 */ + 9000, /* 13 */ + 15000, /* 14 */ + 24000 /* 15 */ +}; + +/* + * SOUND's frequency register to hertz. + * + * The SID computes Fout = (Fn * Fclk) / 2^24. Fclk is the system clock, which is + * 1022730 Hz on an NTSC machine and 985248 on a PAL one -- so the same listing + * plays very slightly sharp or flat depending on which side of the Atlantic it + * was typed on. NTSC is used here; the difference is about 0.6%, well under a + * semitone, and picking one is unavoidable. + */ +#define SID_CLOCK_HZ 1022730.0 +#define SID_ACCUMULATOR 16777216.0 + +/* + * A whole note at TEMPO 1, in milliseconds. + * + * This one is a **calibration choice, not a transcription.** BASIC 7.0 documents + * TEMPO as a relative duration from 1 to 255 with a default of 8 and does not + * publish the constant. 16000 puts a default-tempo quarter note at 500 ms, which + * is 120 beats per minute -- a moderate tempo, which is what a default should be. + * If somebody turns up the real constant, this is the one line to change. + */ +#define WHOLE_NOTE_AT_TEMPO_1 16000 + +/** @brief Concert pitch, and the note the equal-tempered scale is derived from. */ +#define A4_HZ 440.0 +/** @brief Semitone offset of A within an octave that starts at C. */ +#define A_SEMITONE 9 + +akerr_ErrorContext *akbasic_audio_register_to_hz(int value, double *dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER, + "NULL destination in register_to_hz"); + FAIL_ZERO_RETURN(errctx, (value >= 0 && value <= 65535), AKBASIC_ERR_BOUNDS, + "SOUND frequency %d out of range (0 to 65535)", value); + *dest = ((double)value * SID_CLOCK_HZ) / SID_ACCUMULATOR; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_audio_rate_to_ms(int rate, bool isattack, int *dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER, + "NULL destination in rate_to_ms"); + FAIL_ZERO_RETURN(errctx, (rate >= 0 && rate <= 15), AKBASIC_ERR_BOUNDS, + "ENVELOPE rate %d out of range (0 to 15)", rate); + *dest = isattack ? ATTACK_MS[rate] : DECAY_MS[rate]; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_audio_note_hz(int semitone, int octave, double *dest) +{ + PREPARE_ERROR(errctx); + int steps = 0; + + FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER, + "NULL destination in note_hz"); + FAIL_ZERO_RETURN(errctx, (semitone >= 0 && semitone <= 11), AKBASIC_ERR_BOUNDS, + "Note %d out of range (0 to 11)", semitone); + FAIL_ZERO_RETURN(errctx, (octave >= 0 && octave <= 6), AKBASIC_ERR_BOUNDS, + "PLAY octave %d out of range (0 to 6)", octave); + + /* + * Equal temperament from A4. Computed rather than tabulated because a table + * of 84 frequencies is 84 chances to fat-finger one, and this is exact to + * the precision anybody can hear. + */ + steps = ((octave - 4) * 12) + (semitone - A_SEMITONE); + *dest = A4_HZ * pow(2.0, (double)steps / 12.0); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_audio_whole_note_ms(int tempo, int *dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER, + "NULL destination in whole_note_ms"); + FAIL_ZERO_RETURN(errctx, (tempo >= AKBASIC_TEMPO_MIN && tempo <= AKBASIC_TEMPO_MAX), + AKBASIC_ERR_BOUNDS, "TEMPO %d out of range (%d to %d)", + tempo, AKBASIC_TEMPO_MIN, AKBASIC_TEMPO_MAX); + *dest = WHOLE_NOTE_AT_TEMPO_1 / tempo; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_audio_state_init(akbasic_AudioState *obj) +{ + PREPARE_ERROR(errctx); + int i = 0; + int wholems = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL audio state in init"); + + obj->head = 0; + obj->count = 0; + obj->nextms = 0; + obj->sounding = false; + obj->tempo = AKBASIC_TEMPO_DEFAULT; + obj->voice = 0; + obj->octave = 4; + obj->envelope = 0; + obj->level = 1.0; + + PASS(errctx, akbasic_audio_whole_note_ms(obj->tempo, &wholems)); + obj->notems = wholems / 4; /* PLAY starts on quarter notes */ + + /* + * Every preset starts as a plain square wave held at full level. A zeroed + * envelope would have a sustain of 0.0, which is silence with nothing to + * explain it -- the same reasoning libakgl gives for its own voice defaults. + */ + for ( i = 0; i < AKBASIC_ENVELOPES; i++ ) { + obj->envelopes[i].attack = 0; + obj->envelopes[i].decay = 0; + obj->envelopes[i].sustain = 1.0; + obj->envelopes[i].release = 0; + obj->envelopes[i].waveform = AKBASIC_WAVE_SQUARE; + } + SUCCEED_RETURN(errctx); +} diff --git a/src/main.c b/src/main.c index 9e26d6e..6e73f9e 100644 --- a/src/main.c +++ b/src/main.c @@ -12,8 +12,12 @@ * stack. An embedding game would place it in its own state for the same reason. */ +/* clock_gettime and CLOCK_MONOTONIC. C99 alone does not declare either. */ +#define _POSIX_C_SOURCE 199309L + #include #include +#include #include #include @@ -26,6 +30,58 @@ static akbasic_Runtime RUNTIME; static akbasic_TextSink SINK; static akbasic_StdioSink SINKSTATE; +/** + * @brief Milliseconds off a monotonic clock, for akbasic_runtime_settime(). + * + * The library reads no clock of its own -- it owns no loop and must not block -- + * so somebody has to tell it what time it is, and for a standalone program that + * is this. A game does the same thing once a frame off whatever clock it already + * keeps. + * + * Monotonic rather than wall-clock: the interpreter only ever compares these + * values, and an NTP step backwards in the middle of a tune would hold a note + * for however long the correction was. + */ +static int64_t monotonic_ms(void) +{ + struct timespec now; + + if ( clock_gettime(CLOCK_MONOTONIC, &now) != 0 ) { + /* + * A clock that cannot be read leaves time frozen, which expires every + * duration immediately: the program still runs and the music simply + * rushes. Better than refusing to start over a note length. + */ + return 0; + } + return ((int64_t)now.tv_sec * 1000) + (now.tv_nsec / 1000000); +} + +/** + * @brief Step the interpreter to completion, refreshing the clock as it goes. + * + * One step at a time rather than a single unbounded run(), because the driver is + * the thing that owns the clock: a PLAY string whose notes all measured + * themselves against a frozen zero would rush the whole tune out at once. + * + * A function of its own rather than a loop inside main()'s ATTEMPT, for two + * reasons that both come from the error protocol. CATCH expands to a `break`, so + * inside a loop it would escape only the loop and leave the rest of the ATTEMPT + * running with an error pending. And PASS expands to a `return` of the context, + * which main() cannot do -- it returns an int. Wrapping the loop is what the + * protocol prescribes for exactly this shape. + */ +static akerr_ErrorContext AKERR_NOIGNORE *drive(akbasic_Runtime *obj) +{ + PREPARE_ERROR(errctx); + + while ( obj->mode != AKBASIC_MODE_QUIT ) { + PASS(errctx, akbasic_runtime_settime(obj, monotonic_ms())); + PASS(errctx, akbasic_runtime_run(obj, 1)); + } + SUCCEED_RETURN(errctx); +} + int main(int argc, char **argv) { PREPARE_ERROR(errctx); @@ -47,8 +103,7 @@ int main(int argc, char **argv) CATCH(errctx, akbasic_runtime_init(&RUNTIME, &SINK)); CATCH(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL)); } - /* Unbounded: this is the driver, and it has nothing else to do. */ - CATCH(errctx, akbasic_runtime_run(&RUNTIME, 0)); + CATCH(errctx, drive(&RUNTIME)); } CLEANUP { if ( program != NULL ) { IGNORE(aksl_fclose(program)); diff --git a/src/play.c b/src/play.c new file mode 100644 index 0000000..e0de3e9 --- /dev/null +++ b/src/play.c @@ -0,0 +1,277 @@ +/** + * @file play.c + * @brief The PLAY note-string parser and the queue that paces it. + * + * libakgl's audio commit says where this belongs: *"TEMPO and the PLAY + * note-string parser ... belong in the interpreter rather than here."* A tone + * generator synthesises pitches; deciding that `O4CDEFG` means five quarter notes + * starting at middle C is a language question. + * + * On a C128, PLAY holds the program until the string finishes. Section 1.6 + * forbids that, so PLAY parses the whole string into a fixed queue and returns + * immediately; akbasic_play_service() releases one note at a time against the + * host's clock and is called from akbasic_runtime_step(). A host keeps its frame + * rate and the notes still come out in order at their written lengths. + */ + +#include +#include + +#include + +#include +#include +#include + +#include "verbs.h" + +/** + * @brief Semitone offset of each note letter within an octave that starts at C. + * + * Indexed by letter minus 'A', so it reads out of order. That is the price of + * indexing directly instead of searching, and the alternative -- a search over a + * seven-entry table -- would not be clearer. + */ +static const int NOTE_SEMITONE[7] = { + 9, /* A */ + 11, /* B */ + 0, /* C */ + 2, /* D */ + 4, /* E */ + 5, /* F */ + 7 /* G */ +}; + +/** + * @brief What each duration letter divides a whole note by. + * + * BASIC 7.0 spells them Whole, Half, Quarter, eIghth and Sixteenth -- the eighth + * is I rather than E because E is already a note. + */ +static const struct +{ + char letter; + int divisor; +} DURATIONS[] = { + { 'W', 1 }, /* whole */ + { 'H', 2 }, /* half */ + { 'Q', 4 }, /* quarter */ + { 'I', 8 }, /* eighth */ + { 'S', 16 } /* sixteenth */ +}; + +#define DURATION_COUNT ((int)(sizeof(DURATIONS) / sizeof(DURATIONS[0]))) + +/** @brief Append one note to the queue, or report that it is full. */ +static akerr_ErrorContext *enqueue(akbasic_AudioState *audio, int voice, double hz, int ms, bool rest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (audio->count < AKBASIC_MAX_PLAY_NOTES), AKBASIC_ERR_BOUNDS, + "PLAY queue is full at %d notes", AKBASIC_MAX_PLAY_NOTES); + audio->queue[audio->count].voice = voice; + audio->queue[audio->count].hz = hz; + audio->queue[audio->count].ms = ms; + audio->queue[audio->count].rest = rest; + audio->count += 1; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_play_parse(akbasic_Runtime *obj, const char *notes) +{ + PREPARE_ERROR(errctx); + akbasic_AudioState *audio = NULL; + size_t i = 0; + size_t len = 0; + int accidental = 0; + int semitone = 0; + int wholems = 0; + int ms = 0; + int value = 0; + int d = 0; + double hz = 0.0; + char c = '\0'; + bool dotted = false; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && notes != NULL), AKERR_NULLPOINTER, + "NULL argument in play_parse"); + audio = &obj->audio_state; + len = strlen(notes); + + /* + * A loop, so no CATCH and no _BREAK macros in here -- they expand to a C + * break and would leave the loop with an error still pending. PASS and the + * _RETURN forms only. + */ + for ( i = 0; i < len; i++ ) { + c = (char)toupper((unsigned char)notes[i]); + + /* Accidentals and dots are prefixes; they modify the next note letter. */ + if ( c == '#' ) { + accidental = 1; + continue; + } + if ( c == '$' ) { + accidental = -1; + continue; + } + if ( c == '.' ) { + dotted = true; + continue; + } + if ( c == ' ' ) { + continue; + } + + /* A duration letter changes the length of every note after it. */ + for ( d = 0; d < DURATION_COUNT; d++ ) { + if ( DURATIONS[d].letter == c ) { + PASS(errctx, akbasic_audio_whole_note_ms(audio->tempo, &wholems)); + audio->notems = wholems / DURATIONS[d].divisor; + break; + } + } + if ( d < DURATION_COUNT ) { + continue; + } + + /* The single-argument settings all take one decimal digit. */ + if ( c == 'V' || c == 'O' || c == 'T' || c == 'U' || c == 'X' ) { + FAIL_ZERO_RETURN(errctx, + (i + 1 < len && isdigit((unsigned char)notes[i + 1])), + AKBASIC_ERR_SYNTAX, + "PLAY %c expected a digit", c); + value = notes[i + 1] - '0'; + i += 1; + switch ( c ) { + case 'V': + FAIL_ZERO_RETURN(errctx, (value >= 1 && value <= AKBASIC_AUDIO_VOICES), + AKBASIC_ERR_BOUNDS, + "PLAY V%d out of range (1 to %d)", value, AKBASIC_AUDIO_VOICES); + audio->voice = value - 1; + break; + case 'O': + FAIL_ZERO_RETURN(errctx, (value >= 0 && value <= 6), AKBASIC_ERR_BOUNDS, + "PLAY O%d out of range (0 to 6)", value); + audio->octave = value; + break; + case 'T': + FAIL_ZERO_RETURN(errctx, (value >= 0 && value < AKBASIC_ENVELOPES), + AKBASIC_ERR_BOUNDS, + "PLAY T%d out of range (0 to %d)", value, AKBASIC_ENVELOPES - 1); + audio->envelope = value; + break; + case 'U': + FAIL_ZERO_RETURN(errctx, (value >= 0 && value <= 9), AKBASIC_ERR_BOUNDS, + "PLAY U%d out of range (0 to 9)", value); + audio->level = (double)value / 9.0; + break; + default: + /* + * X selects the filter, which there is nothing behind -- see + * TODO.md section 7. Refused rather than ignored, for the same + * reason FILTER is: a program that asked for a filtered sound + * and got an unfiltered one has been lied to. + */ + FAIL_RETURN(errctx, AKBASIC_ERR_DEVICE, + "PLAY X selects the filter, which this device does not have"); + } + continue; + } + + ms = audio->notems; + if ( dotted ) { + /* A dot adds half the note's length again. */ + ms = ms + (ms / 2); + dotted = false; + } + + if ( c == 'R' ) { + PASS(errctx, enqueue(audio, audio->voice, 0.0, ms, true)); + accidental = 0; + continue; + } + if ( c == 'M' ) { + /* + * A measure bar. On a C128 it synchronises the voices; with one + * sequential queue there is nothing to synchronise, so it is a + * no-op rather than an error -- a listing full of them should still + * play. TODO.md section 5. + */ + continue; + } + + FAIL_ZERO_RETURN(errctx, (c >= 'A' && c <= 'G'), AKBASIC_ERR_SYNTAX, + "PLAY does not understand '%c'", notes[i]); + + semitone = NOTE_SEMITONE[c - 'A'] + accidental; + accidental = 0; + /* + * B# wraps to the next octave and C$ to the previous one. Wrapping the + * semitone without moving the octave would put B# a full octave below + * where it belongs. + */ + if ( semitone > 11 ) { + semitone -= 12; + PASS(errctx, akbasic_audio_note_hz(semitone, + (audio->octave < 6) ? audio->octave + 1 : 6, + &hz)); + } else if ( semitone < 0 ) { + semitone += 12; + PASS(errctx, akbasic_audio_note_hz(semitone, + (audio->octave > 0) ? audio->octave - 1 : 0, + &hz)); + } else { + PASS(errctx, akbasic_audio_note_hz(semitone, audio->octave, &hz)); + } + PASS(errctx, enqueue(audio, audio->voice, hz, ms, false)); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_play_service(akbasic_Runtime *obj) +{ + PREPARE_ERROR(errctx); + akbasic_AudioState *audio = NULL; + akbasic_PlayNote *note = NULL; + akbasic_Envelope *envelope = NULL; + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in play_service"); + audio = &obj->audio_state; + + if ( audio->head >= audio->count ) { + /* Nothing queued. Reset so the next PLAY starts from the front. */ + audio->head = 0; + audio->count = 0; + audio->sounding = false; + SUCCEED_RETURN(errctx); + } + if ( obj->timems < audio->nextms ) { + /* The note holding the queue has not run out yet. */ + SUCCEED_RETURN(errctx); + } + /* + * No audio backend means the queue drains silently rather than jamming. A + * host that took the device away mid-tune should not leave the script stuck + * behind a note that can never finish. + */ + note = &audio->queue[audio->head]; + audio->head += 1; + audio->nextms = obj->timems + note->ms; + audio->sounding = true; + + if ( obj->audio == NULL ) { + SUCCEED_RETURN(errctx); + } + if ( note->rest ) { + PASS(errctx, obj->audio->stop(obj->audio, note->voice)); + SUCCEED_RETURN(errctx); + } + + envelope = &audio->envelopes[audio->envelope]; + PASS(errctx, obj->audio->waveform(obj->audio, note->voice, envelope->waveform)); + PASS(errctx, obj->audio->envelope(obj->audio, note->voice, envelope->attack, + envelope->decay, envelope->sustain, envelope->release)); + PASS(errctx, obj->audio->tone(obj->audio, note->voice, note->hz, note->ms)); + SUCCEED_RETURN(errctx); +} diff --git a/src/runtime.c b/src/runtime.c index e5f3d60..ba0203e 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -141,6 +141,7 @@ akerr_ErrorContext *akbasic_runtime_init(akbasic_Runtime *obj, akbasic_TextSink obj->inputEof = false; PASS(errctx, akbasic_graphics_state_init(&obj->gfx)); + PASS(errctx, akbasic_audio_state_init(&obj->audio_state)); PASS(errctx, akbasic_valuepool_init(&obj->valuepool)); PASS(errctx, akbasic_value_zero(&obj->staticTrueValue)); PASS(errctx, akbasic_value_zero(&obj->staticFalseValue)); @@ -826,6 +827,14 @@ akerr_ErrorContext *akbasic_runtime_step(akbasic_Runtime *obj) FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in step"); + /* + * Release the next queued note if its predecessor's time is up. PLAY does not + * block -- section 1.6 forbids it -- so this is what paces a tune, and it + * runs before the QUIT check so a program's last notes still come out while + * a host keeps calling step(). + */ + PASS(errctx, akbasic_play_service(obj)); + if ( obj->mode == AKBASIC_MODE_QUIT ) { SUCCEED_RETURN(errctx); } diff --git a/src/runtime_audio.c b/src/runtime_audio.c new file mode 100644 index 0000000..1d6c97d --- /dev/null +++ b/src/runtime_audio.c @@ -0,0 +1,306 @@ +/** + * @file runtime_audio.c + * @brief The group I verb implementations: SOUND, ENVELOPE, VOL, PLAY, TEMPO + * and the refusal that is FILTER. + * + * Same rule as src/runtime_graphics.c -- no SDL and no libakgl header here. + * Everything goes through the akbasic_AudioBackend record. + * + * The reference implements none of these, so the semantics are Commodore BASIC + * 7.0's. Two of its behaviours are deliberately not reproduced and both are + * recorded in TODO.md section 5: PLAY does not block, and SOUND's frequency-sweep + * arguments are refused rather than faked. + */ + +#include + +#include + +#include +#include +#include + +#include "verbs.h" + +/* Most verbs answer "did something happen"; this is that answer. */ +#define SUCCEED_TRUE(__obj, __dest) \ + do { \ + *(__dest) = &(__obj)->staticTrueValue; \ + } while ( 0 ) + +/** @brief Refuse politely when the host lent us no audio device. */ +static akerr_ErrorContext *require_audio(akbasic_Runtime *obj, const char *verb) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL && verb != NULL), AKERR_NULLPOINTER, + "NULL argument in require_audio"); + FAIL_ZERO_RETURN(errctx, (obj->audio != NULL), AKBASIC_ERR_DEVICE, + "%s needs an audio device and this runtime has none", verb); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Collect a verb's numeric arguments, as the graphics verbs do. + * + * Duplicated rather than shared with src/runtime_graphics.c on purpose: sharing + * it would mean a third file existing only to hold one static helper, and the + * two copies answer to different verbs. If a third group wants it, that is the + * point at which it earns a home of its own. + */ +static akerr_ErrorContext *collect_numbers(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, double *values, int max, int *count) +{ + PREPARE_ERROR(errctx); + akbasic_ASTLeaf *arg = NULL; + akbasic_Value *value = NULL; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && expr != NULL && values != NULL && count != NULL), + AKERR_NULLPOINTER, "NULL argument in collect_numbers"); + *count = 0; + arg = akbasic_leaf_first_argument(expr); + while ( arg != NULL ) { + FAIL_NONZERO_RETURN(errctx, (*count >= max), AKBASIC_ERR_SYNTAX, + "%s takes at most %d arguments", verb, max); + PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value)); + FAIL_NONZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), + AKBASIC_ERR_TYPE, + "%s expected a number in argument %d", verb, *count + 1); + values[*count] = (value->valuetype == AKBASIC_TYPE_FLOAT) + ? value->floatval : (double)value->intval; + *count += 1; + arg = arg->right; + } + SUCCEED_RETURN(errctx); +} + +/* --------------------------------------------------------------- SOUND --- */ + +akerr_ErrorContext *akbasic_cmd_sound(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + double args[8]; + int count = 0; + int voice = 0; + int ms = 0; + double hz = 0.0; + akbasic_Envelope *envelope = NULL; + + (void)lval; (void)rval; + PASS(errctx, require_audio(obj, "SOUND")); + PASS(errctx, collect_numbers(obj, expr, "SOUND", args, 8, &count)); + FAIL_ZERO_RETURN(errctx, (count >= 3), AKBASIC_ERR_SYNTAX, + "SOUND expected a voice, a frequency and a duration"); + + voice = (int)args[0]; + FAIL_ZERO_RETURN(errctx, (voice >= 1 && voice <= AKBASIC_AUDIO_VOICES), + AKBASIC_ERR_BOUNDS, "SOUND voice %d out of range (1 to %d)", + voice, AKBASIC_AUDIO_VOICES); + voice -= 1; /* BASIC counts voices from 1; the backend from 0 */ + + PASS(errctx, akbasic_audio_register_to_hz((int)args[1], &hz)); + + /* + * BASIC counts SOUND's duration in jiffies -- sixtieths of a second, the + * PAL/NTSC vertical blank the KERNAL counts on. The backend takes + * milliseconds. + */ + FAIL_ZERO_RETURN(errctx, (args[2] >= 0.0), AKBASIC_ERR_VALUE, + "SOUND duration must not be negative"); + ms = (int)((args[2] * 1000.0) / 60.0); + + /* + * Arguments 4 through 6 are a frequency sweep: a direction, a floor and a + * step. There is no akgl_audio_* equivalent, and faking one by re-issuing + * tones from akbasic_runtime_step() would tie audible pitch to how often the + * host happens to call us -- a tune that changes key with the frame rate. + * Refused, and filed upstream as akgl_audio_sweep. TODO.md section 7. + */ + FAIL_NONZERO_RETURN(errctx, (count >= 4 && args[3] != 0.0), AKBASIC_ERR_DEVICE, + "SOUND's frequency sweep needs a device capability that does not exist yet"); + + if ( count >= 7 ) { + /* The seventh argument selects a waveform, 0 through 3. */ + FAIL_ZERO_RETURN(errctx, (args[6] >= 0.0 && args[6] <= 3.0), AKBASIC_ERR_BOUNDS, + "SOUND waveform %d out of range (0 to 3)", (int)args[6]); + PASS(errctx, obj->audio->waveform(obj->audio, voice, (akbasic_Waveform)(int)args[6])); + } else { + envelope = &obj->audio_state.envelopes[obj->audio_state.envelope]; + PASS(errctx, obj->audio->waveform(obj->audio, voice, envelope->waveform)); + } + + PASS(errctx, obj->audio->tone(obj->audio, voice, hz, ms)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ------------------------------------------------------------ ENVELOPE --- */ + +akerr_ErrorContext *akbasic_cmd_envelope(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + double args[7]; + int count = 0; + int preset = 0; + int ms = 0; + akbasic_Envelope *envelope = NULL; + + (void)lval; (void)rval; + /* + * ENVELOPE defines a preset; it does not sound anything, so it needs no + * device and a program can set its instruments up before a host lends it one. + */ + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in ENVELOPE"); + PASS(errctx, collect_numbers(obj, expr, "ENVELOPE", args, 7, &count)); + FAIL_ZERO_RETURN(errctx, (count >= 1), AKBASIC_ERR_SYNTAX, + "ENVELOPE expected a preset number"); + + preset = (int)args[0]; + FAIL_ZERO_RETURN(errctx, (preset >= 0 && preset < AKBASIC_ENVELOPES), AKBASIC_ERR_BOUNDS, + "ENVELOPE %d out of range (0 to %d)", preset, AKBASIC_ENVELOPES - 1); + envelope = &obj->audio_state.envelopes[preset]; + + /* + * Attack, decay and release arrive as the SID's 0-15 rate numbers and become + * milliseconds. Sustain is the odd one out: it is a *level*, not a time, so + * 0-15 maps onto 0.0-1.0 rather than through the rate table. + */ + if ( count >= 2 ) { + PASS(errctx, akbasic_audio_rate_to_ms((int)args[1], true, &ms)); + envelope->attack = ms; + } + if ( count >= 3 ) { + PASS(errctx, akbasic_audio_rate_to_ms((int)args[2], false, &ms)); + envelope->decay = ms; + } + if ( count >= 4 ) { + FAIL_ZERO_RETURN(errctx, (args[3] >= 0.0 && args[3] <= 15.0), AKBASIC_ERR_BOUNDS, + "ENVELOPE sustain %d out of range (0 to 15)", (int)args[3]); + envelope->sustain = args[3] / 15.0; + } + if ( count >= 5 ) { + PASS(errctx, akbasic_audio_rate_to_ms((int)args[4], false, &ms)); + envelope->release = ms; + } + if ( count >= 6 ) { + FAIL_ZERO_RETURN(errctx, (args[5] >= 0.0 && args[5] <= 3.0), AKBASIC_ERR_BOUNDS, + "ENVELOPE waveform %d out of range (0 to 3)", (int)args[5]); + envelope->waveform = (akbasic_Waveform)(int)args[5]; + } + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ----------------------------------------------------------------- VOL --- */ + +akerr_ErrorContext *akbasic_cmd_vol(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + double args[1]; + int count = 0; + int level = 0; + + (void)lval; (void)rval; + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in VOL"); + PASS(errctx, collect_numbers(obj, expr, "VOL", args, 1, &count)); + FAIL_ZERO_RETURN(errctx, (count == 1), AKBASIC_ERR_SYNTAX, "VOL expected a level"); + + level = (int)args[0]; + FAIL_ZERO_RETURN(errctx, (level >= 0 && level <= 15), AKBASIC_ERR_BOUNDS, + "VOL %d out of range (0 to 15)", level); + obj->audio_state.level = (double)level / 15.0; + + /* + * Recorded whatever happens, but only pushed when there is somewhere to push + * it. VOL before the host attaches a device should still take effect when + * one arrives. + */ + if ( obj->audio != NULL ) { + PASS(errctx, obj->audio->volume(obj->audio, obj->audio_state.level)); + } + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* --------------------------------------------------------------- TEMPO --- */ + +akerr_ErrorContext *akbasic_cmd_tempo(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + double args[1]; + int count = 0; + int tempo = 0; + int wholems = 0; + + (void)lval; (void)rval; + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in TEMPO"); + PASS(errctx, collect_numbers(obj, expr, "TEMPO", args, 1, &count)); + FAIL_ZERO_RETURN(errctx, (count == 1), AKBASIC_ERR_SYNTAX, "TEMPO expected a value"); + + tempo = (int)args[0]; + PASS(errctx, akbasic_audio_whole_note_ms(tempo, &wholems)); + obj->audio_state.tempo = tempo; + + /* + * The current note length rescales with the tempo rather than resetting to a + * quarter note: a program that set eighth notes and then changed tempo means + * faster eighth notes, not quarter notes again. + */ + PASS(errctx, akbasic_audio_whole_note_ms(AKBASIC_TEMPO_DEFAULT, &count)); + obj->audio_state.notems = (obj->audio_state.notems * wholems) / count; + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* ---------------------------------------------------------------- PLAY --- */ + +akerr_ErrorContext *akbasic_cmd_play(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + akbasic_ASTLeaf *arg = NULL; + akbasic_Value *value = NULL; + + (void)lval; (void)rval; + PASS(errctx, require_audio(obj, "PLAY")); + FAIL_ZERO_RETURN(errctx, (expr != NULL), AKERR_NULLPOINTER, "NIL leaf"); + + arg = akbasic_leaf_first_argument(expr); + if ( arg == NULL ) { + arg = expr->right; + } + FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, "PLAY expected a string"); + PASS(errctx, akbasic_runtime_evaluate(obj, arg, &value)); + FAIL_ZERO_RETURN(errctx, (value->valuetype == AKBASIC_TYPE_STRING), AKBASIC_ERR_TYPE, + "PLAY expected a string"); + + /* + * Parses and queues, then returns. A C128 would hold the program here until + * the last note finished; section 1.6 forbids that, so the notes come out of + * akbasic_runtime_step() against the host's clock instead. TODO.md section 5. + */ + PASS(errctx, akbasic_play_parse(obj, value->stringval)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- FILTER --- */ + +akerr_ErrorContext *akbasic_cmd_filter(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + + (void)expr; (void)lval; (void)rval; (void)dest; + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in FILTER"); + + /* + * FILTER sets the SID's cutoff, its three band switches and its resonance. + * akgl_audio_* synthesises raw waveforms and mixes them; there is no filter + * stage to configure, and SDL3 supplies no primitive to build one from. + * + * Refused rather than accepted-and-ignored. A program that asks for a + * low-pass and gets an unfiltered square wave has been lied to, and the whole + * reason it is in the table at all is so the diagnostic names the real + * problem instead of "undefined verb". TODO.md section 7. + */ + FAIL_RETURN(errctx, AKBASIC_ERR_DEVICE, + "FILTER needs a filter stage this device does not have"); +} diff --git a/src/verbs.c b/src/verbs.c index 98eb6fb..f0219b4 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -50,7 +50,9 @@ static const akbasic_Verb VERBS[] = { { "DRAW", AKBASIC_TOK_COMMAND, -1, akbasic_parse_draw, akbasic_cmd_draw }, { "DSAVE", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_dsave }, { "ELSE", AKBASIC_TOK_COMMAND, -1, NULL, NULL }, + { "ENVELOPE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_envelope }, { "EXIT", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_exit }, + { "FILTER", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_filter }, { "FOR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_for, akbasic_cmd_for }, { "GOSUB", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_gosub }, { "GOTO", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_goto }, @@ -74,6 +76,7 @@ static const akbasic_Verb VERBS[] = { { "OR", AKBASIC_TOK_OR, -1, NULL, NULL }, { "PAINT", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_paint }, { "PEEK", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_peek }, + { "PLAY", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_play }, { "POINTER", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_pointer }, { "POINTERVAR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_pointervar }, { "POKE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_poke, akbasic_cmd_poke }, @@ -90,15 +93,18 @@ static const akbasic_Verb VERBS[] = { { "SHL", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shl }, { "SHR", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shr }, { "SIN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_sin }, + { "SOUND", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_sound }, { "SPC", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_spc }, { "SSHAPE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_sshape }, { "STEP", AKBASIC_TOK_COMMAND, -1, NULL, NULL }, { "STOP", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_stop }, { "STR", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_str }, { "TAN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_tan }, + { "TEMPO", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_tempo }, { "THEN", AKBASIC_TOK_COMMAND, -1, NULL, NULL }, { "TO", AKBASIC_TOK_COMMAND, -1, NULL, NULL }, { "VAL", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_val }, + { "VOL", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_vol }, { "XOR", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_xor }, }; diff --git a/src/verbs.h b/src/verbs.h index b1e3d3c..cacccf7 100644 --- a/src/verbs.h +++ b/src/verbs.h @@ -93,4 +93,12 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_paint(struct akbasic_Runtime *obj akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_scale(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sshape(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +/* Group I sound verbs -- src/runtime_audio.c */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_envelope(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_filter(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_play(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sound(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_tempo(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_vol(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); + #endif // _AKBASIC_SRC_VERBS_H_ diff --git a/tests/audio_verbs.c b/tests/audio_verbs.c new file mode 100644 index 0000000..1d20875 --- /dev/null +++ b/tests/audio_verbs.c @@ -0,0 +1,371 @@ +/** + * @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 + +#include +#include +#include + +#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); + + /* Attack and decay are different tables, and decay is three times attack. */ + TEST_REQUIRE_OK(akbasic_audio_rate_to_ms(0, true, &ms)); + TEST_REQUIRE_INT(ms, 2); + TEST_REQUIRE_OK(akbasic_audio_rate_to_ms(0, false, &ms)); + TEST_REQUIRE_INT(ms, 6); + TEST_REQUIRE_OK(akbasic_audio_rate_to_ms(15, true, &ms)); + TEST_REQUIRE_INT(ms, 8000); + TEST_REQUIRE_OK(akbasic_audio_rate_to_ms(15, false, &ms)); + TEST_REQUIRE_INT(ms, 24000); + TEST_REQUIRE_STATUS(akbasic_audio_rate_to_ms(16, true, &ms), AKBASIC_ERR_BOUNDS); + + /* + * 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 is refused rather than faked. Faking it would mean + * re-issuing tones from step(), which would tie audible pitch to how often + * the host calls us -- a tune that changes key with the frame rate. + */ + TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 1, 500, 10\n")); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "frequency sweep") != NULL, + "a sweep should be refused, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + /* A sweep direction of zero is "no sweep" and must still play. */ + 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(); +} + +/** @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; +} diff --git a/tests/language/audio/filter_refused.bas b/tests/language/audio/filter_refused.bas new file mode 100644 index 0000000..6acc401 --- /dev/null +++ b/tests/language/audio/filter_refused.bas @@ -0,0 +1,7 @@ +10 REM FILTER has no device capability behind it -- akgl_audio_* synthesises and +20 REM mixes but has no filter stage, and SDL3 supplies no primitive to build one +30 REM from. It is refused rather than silently ignored, so a program that asked +40 REM for a low-pass finds out it did not get one. +50 PRINT "BEFORE" +60 FILTER 1000, 1, 0, 0, 5 +70 PRINT "UNREACHABLE" diff --git a/tests/language/audio/filter_refused.txt b/tests/language/audio/filter_refused.txt new file mode 100644 index 0000000..c626607 --- /dev/null +++ b/tests/language/audio/filter_refused.txt @@ -0,0 +1,3 @@ +BEFORE +? 60 : RUNTIME ERROR FILTER needs a filter stage this device does not have + diff --git a/tests/language/audio/no_device.bas b/tests/language/audio/no_device.bas new file mode 100644 index 0000000..4de2bb5 --- /dev/null +++ b/tests/language/audio/no_device.bas @@ -0,0 +1,8 @@ +10 REM The standalone driver lends the script no audio device. ENVELOPE, VOL and +20 REM TEMPO only change interpreter state, so they work regardless; SOUND and +30 REM PLAY need the device and must name themselves when there is none. +40 ENVELOPE 1, 5, 9, 12, 2 +50 VOL 12 +60 TEMPO 16 +70 PRINT "STATE VERBS OK" +80 SOUND 1, 16777, 30 diff --git a/tests/language/audio/no_device.txt b/tests/language/audio/no_device.txt new file mode 100644 index 0000000..b9efc24 --- /dev/null +++ b/tests/language/audio/no_device.txt @@ -0,0 +1,3 @@ +STATE VERBS OK +? 80 : RUNTIME ERROR SOUND needs an audio device and this runtime has none +