Synthesise tones on three voices

There was no audio API at all: SDL3_mixer was vendored and AKGL_REGISTRY_MUSIC
was declared, but nothing opened a mixer, and a mixer plays recordings anyway.
BASIC 7.0's SOUND, PLAY, ENVELOPE and VOL describe notes, so this adds a tone
generator -- three voices, five waveforms, an ADSR envelope each, mixed to one
float stream and fed to an SDL_AudioStream.

The voice table works whether or not a device is open. akgl_audio_init connects
it to one; a host that owns its own audio pipeline can call akgl_audio_mix
instead. That is also what makes the tests deterministic: a device pulls samples
on SDL's audio thread whenever it likes, so tests/audio.c mixes by hand and only
opens a device in its last test.

Phase is computed from the frame counter rather than accumulated, because a
float increment of hz/44100 added 44100 times a second walks a held note off
pitch. A voice that has never been configured defaults to a square wave at full
level, since a zeroed voice would have a sustain of 0.0 and make no sound with
no error to explain why. Voices summing past full scale are clamped rather than
scaled, so one voice plays at the level it was asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 07:13:18 -04:00
parent dba0f8db89
commit 2a3ca48d8f
5 changed files with 1113 additions and 0 deletions

29
TODO.md
View File

@@ -725,6 +725,35 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
of the four and the one most worth designing before writing. Tests can run under the dummy
audio driver and assert state transitions rather than sound.
**Resolved.** `include/akgl/audio.h` and `src/audio.c` add a three-voice tone
generator over `SDL_AudioStream`: `akgl_audio_init`, `_shutdown`, `_tone`,
`_stop`, `_waveform`, `_envelope`, `_volume`, `_voice_active` and `_mix`.
It is deliberately separate from the SDL3_mixer side of the library, which
plays audio *assets*; nothing in `audio.c` reads a file. Decisions worth
knowing:
- **The voice table works with no device open.** `akgl_audio_init` connects
it to one; without that a host can still pull samples itself through
`akgl_audio_mix`. That is not only for embedding — it is what makes the
suite deterministic. A device pulls samples on SDL's audio thread whenever
it likes, so a test that opened one and then asserted on voice state would
be racing the callback. `tests/audio.c` mixes by hand and opens a device
only in its last test.
- **Phase is derived from the frame counter, not accumulated.** A float
increment of `hz / 44100` is not exact, and adding it 44100 times a second
walks a held note off pitch.
- **A voice that was never configured is audible.** A zeroed voice has a
sustain of 0.0, which is silence with no error to explain it, so the table
defaults to a square wave held at full level.
- **Three voices summing past full scale are clamped, not scaled**, so one
voice plays at the level it was asked for rather than a third of it.
- Everything that touches the table takes the stream lock when a device is
open, since the callback reads it on another thread.
Still missing for a complete BASIC sound vocabulary: `FILTER` (SDL3 has no
filter primitive; this would need writing), `TEMPO` and the `PLAY` note-string
parser, both of which belong in the interpreter rather than here.
4. **No non-blocking keystroke read.** `include/akgl/controller.h` is built around SDL event
handlers the host pumps (`akgl_controller_handle_event` and friends), which suits a game
loop and does not suit `GET` and `GETKEY` -- those ask "is there a keystroke waiting, yes or