Files
akbasic/src/runtime_audio.c

345 lines
14 KiB
C
Raw Normal View History

Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
/**
* @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 <string.h>
#include <akerror.h>
#include <akbasic/audio.h>
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 21:50:37 -04:00
#include <akbasic/args.h>
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#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 Issue SOUND's swept note, translating its arguments out of SID space.
*
* BASIC 7.0 spells the sweep as `dir`, `min` and `step`, all in the SID's
* 16-bit register units rather than in hertz, and `dir` picks the shape:
*
* 0 no sweep -- handled by the caller, which issues a held note instead
* 1 up, from the SOUND frequency toward min
* 2 down, from the SOUND frequency toward min
* 3 oscillate between the two
*
* **Direction 3 sweeps once rather than oscillating**, and that is a deviation
* worth knowing about: akgl_audio_sweep runs one pass from a start to an end,
* and a genuine oscillation needs the mixer to turn around at the endpoints.
* Sweeping once in the direction the endpoints imply is the closest honest
* approximation; TODO.md section 5 records it.
*
* Directions 1 and 2 both name `min` as the far end, so the *value* of min is
* what decides which way the pitch actually travels -- a `min` above the
* starting frequency rises whatever `dir` says. That is the SID's behaviour and
* akgl_audio_sweep works the same way, comparing its two endpoints, so the two
* agree without this having to second-guess either.
*/
static akerr_ErrorContext AKERR_NOIGNORE *sweep_note(akbasic_Runtime *obj, int voice, double hz, double *args, int ms)
{
PREPARE_ERROR(errctx);
double tohz = 0.0;
double stephz = 0.0;
double stepbase = 0.0;
PASS(errctx, akbasic_audio_register_to_hz((int)args[4], &tohz));
/*
* `step` is a register *delta*, not a register value, so it cannot go
* through register_to_hz on its own -- that maps a position, and a delta has
* no position. Convert it as the distance between register 0 and register
* `step`, which is the same linear scale the table applies to everything
* else.
*/
PASS(errctx, akbasic_audio_register_to_hz(0, &stepbase));
PASS(errctx, akbasic_audio_register_to_hz((int)args[5], &stephz));
stephz -= stepbase;
/*
* A zero step would never arrive and libakgl refuses it outright. One hertz
* is the smallest move that still gets there, and a program that asked for
* no movement asked for a held note -- which is what it gets.
*/
if ( stephz <= 0.0 ) {
stephz = 1.0;
}
PASS(errctx, obj->audio->sweep(obj->audio, voice, hz, tohz, stephz, ms));
SUCCEED_RETURN(errctx);
}
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
/* --------------------------------------------------------------- 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"));
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 21:50:37 -04:00
PASS(errctx, akbasic_args_numbers(obj, expr, "SOUND", args, 8, &count));
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
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. `dir` is 0 to hold, 1 to sweep up, 2 to sweep down and 3 to
* oscillate; `min` is the far end of the sweep as a register value, and
* `step` is how far the pitch moves per tick.
*
* This was refused outright until libakgl 0.3.0 grew akgl_audio_sweep --
* faking it by re-issuing tones from akbasic_runtime_step() would have tied
* audible pitch to how often the host happens to call us, a tune that
* changes key with the frame rate. It is still refused when the host's
* backend has no sweep, which is what an older one looks like.
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
*/
if ( count >= 4 && args[3] != 0.0 ) {
FAIL_ZERO_RETURN(errctx, (args[3] >= 0.0 && args[3] <= 3.0), AKBASIC_ERR_BOUNDS,
"SOUND direction %d out of range (0 to 3)", (int)args[3]);
FAIL_ZERO_RETURN(errctx, (obj->audio->sweep != NULL), AKBASIC_ERR_DEVICE,
"SOUND's frequency sweep needs an audio device that can sweep");
FAIL_ZERO_RETURN(errctx, (count >= 6), AKBASIC_ERR_SYNTAX,
"SOUND's frequency sweep expected a direction, a minimum and a step");
PASS(errctx, sweep_note(obj, voice, hz, args, ms));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
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");
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 21:50:37 -04:00
PASS(errctx, akbasic_args_numbers(obj, expr, "ENVELOPE", args, 7, &count));
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
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");
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 21:50:37 -04:00
PASS(errctx, akbasic_args_numbers(obj, expr, "VOL", args, 1, &count));
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
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");
Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 21:50:37 -04:00
PASS(errctx, akbasic_args_numbers(obj, expr, "TEMPO", args, 1, &count));
Sound: implement the BASIC 7.0 sound verbs and the PLAY parser SOUND, ENVELOPE, VOL, PLAY and TEMPO against the akbasic_AudioBackend record, plus FILTER, which is in the table only so that it can be refused with a reason. The PLAY note-string parser and TEMPO are here rather than in libakgl because that is where its audio commit says they belong: a tone generator synthesises pitches, but deciding that O4CDEFG is five quarter notes starting at middle C is a language question. PLAY does not block. On a C128 it holds the program until the last note ends, which section 1.6 forbids outright, so it parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). The driver now steps one at a time with the clock refreshed in between rather than making a single unbounded run() call -- a tune whose notes all measured themselves against a frozen zero would rush out at once. That loop is its own function because CATCH expands to a break and PASS expands to a return of the context, and main() returns an int; wrapping the loop is what the protocol prescribes for that shape. src/audio_tables.c holds the three conversions, laid out as tables because each is somewhere a wrong constant produces a plausible wrong pitch rather than an error anybody would notice. Two are transcriptions -- the SID frequency formula and its non-linear ADSR rate tables, where decay is exactly three times attack. The third is not: BASIC 7.0 never published what a whole note lasts at a given TEMPO, so 16000 ms at TEMPO 1 is a calibration choice putting a default quarter note at 120 bpm, and it is labelled as a choice where it is made. SOUND's frequency sweep is refused rather than faked, and filed upstream as akgl_audio_sweep. The only way to fake it here is to re-issue tones from step(), which ties audible pitch to how often the host calls us -- a tune that changes key with the frame rate. FILTER is refused for the reason upstream already gave: there is no filter stage and SDL3 has no primitive to build one from. The PLAY parser is tested through akbasic_play_parse() directly rather than through a program, because running one also runs the queue service -- and with the clock at zero every duration has already expired, so the queue empties before an assertion can look at it. Draining is correct behaviour and is tested on its own; the parse tests ask a different question. 68/68 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 08:29:04 -04:00
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");
}