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

View File

@@ -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_

View File

@@ -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