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

43
TODO.md
View File

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