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>
This commit is contained in:
2026-07-31 08:29:04 -04:00
parent a6ac2ee9e8
commit 7bf271eba4
16 changed files with 1453 additions and 2 deletions

View File

@@ -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 <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <akerror.h>
#include <akstdlib.h>
@@ -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));