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 +