Files
akbasic/src/audio_tables.c
Andrew Kesterson e24926d429 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>
2026-07-31 08:29:04 -04:00

198 lines
6.5 KiB
C

/**
* @file audio_tables.c
* @brief The SOUND, ENVELOPE and PLAY conversions, laid out as the tables they are.
*
* BASIC 7.0's sound verbs speak in SID register values: SOUND takes a frequency
* *register*, not a pitch, and ENVELOPE takes the SID's 0-15 rate numbers, whose
* real durations are tabulated in silicon rather than computed. The backend takes
* hertz and milliseconds. These are the conversions between the two, and they are
* kept in one file because each is somewhere a wrong constant produces a
* plausible wrong pitch rather than an error somebody would notice.
*
* Two of the three are transcriptions. The third -- TEMPO -- is a calibration
* choice and is labelled as one where it is made.
*/
#include <math.h>
#include <akerror.h>
#include <akbasic/audio.h>
#include <akbasic/error.h>
/**
* @brief The SID's attack rates, register value to milliseconds.
*
* Non-linear, and not a curve anybody would guess: the steps are small at the
* bottom and enormous at the top. This is the 6581/8580 table.
*/
static const int ATTACK_MS[16] = {
2, /* 0 */
8, /* 1 */
16, /* 2 */
24, /* 3 */
38, /* 4 */
56, /* 5 */
68, /* 6 */
80, /* 7 */
100, /* 8 */
250, /* 9 */
500, /* 10 */
800, /* 11 */
1000, /* 12 */
3000, /* 13 */
5000, /* 14 */
8000 /* 15 */
};
/**
* @brief The SID's decay and release rates, register value to milliseconds.
*
* Exactly three times the attack table -- that is a property of the chip's
* envelope generator, not a coincidence, so it is written out rather than
* computed. A reader checking this against a datasheet should see the datasheet's
* numbers.
*/
static const int DECAY_MS[16] = {
6, /* 0 */
24, /* 1 */
48, /* 2 */
72, /* 3 */
114, /* 4 */
168, /* 5 */
204, /* 6 */
240, /* 7 */
300, /* 8 */
750, /* 9 */
1500, /* 10 */
2400, /* 11 */
3000, /* 12 */
9000, /* 13 */
15000, /* 14 */
24000 /* 15 */
};
/*
* SOUND's frequency register to hertz.
*
* The SID computes Fout = (Fn * Fclk) / 2^24. Fclk is the system clock, which is
* 1022730 Hz on an NTSC machine and 985248 on a PAL one -- so the same listing
* plays very slightly sharp or flat depending on which side of the Atlantic it
* was typed on. NTSC is used here; the difference is about 0.6%, well under a
* semitone, and picking one is unavoidable.
*/
#define SID_CLOCK_HZ 1022730.0
#define SID_ACCUMULATOR 16777216.0
/*
* A whole note at TEMPO 1, in milliseconds.
*
* This one is a **calibration choice, not a transcription.** BASIC 7.0 documents
* TEMPO as a relative duration from 1 to 255 with a default of 8 and does not
* publish the constant. 16000 puts a default-tempo quarter note at 500 ms, which
* is 120 beats per minute -- a moderate tempo, which is what a default should be.
* If somebody turns up the real constant, this is the one line to change.
*/
#define WHOLE_NOTE_AT_TEMPO_1 16000
/** @brief Concert pitch, and the note the equal-tempered scale is derived from. */
#define A4_HZ 440.0
/** @brief Semitone offset of A within an octave that starts at C. */
#define A_SEMITONE 9
akerr_ErrorContext *akbasic_audio_register_to_hz(int value, double *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER,
"NULL destination in register_to_hz");
FAIL_ZERO_RETURN(errctx, (value >= 0 && value <= 65535), AKBASIC_ERR_BOUNDS,
"SOUND frequency %d out of range (0 to 65535)", value);
*dest = ((double)value * SID_CLOCK_HZ) / SID_ACCUMULATOR;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_audio_rate_to_ms(int rate, bool isattack, int *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER,
"NULL destination in rate_to_ms");
FAIL_ZERO_RETURN(errctx, (rate >= 0 && rate <= 15), AKBASIC_ERR_BOUNDS,
"ENVELOPE rate %d out of range (0 to 15)", rate);
*dest = isattack ? ATTACK_MS[rate] : DECAY_MS[rate];
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_audio_note_hz(int semitone, int octave, double *dest)
{
PREPARE_ERROR(errctx);
int steps = 0;
FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER,
"NULL destination in note_hz");
FAIL_ZERO_RETURN(errctx, (semitone >= 0 && semitone <= 11), AKBASIC_ERR_BOUNDS,
"Note %d out of range (0 to 11)", semitone);
FAIL_ZERO_RETURN(errctx, (octave >= 0 && octave <= 6), AKBASIC_ERR_BOUNDS,
"PLAY octave %d out of range (0 to 6)", octave);
/*
* Equal temperament from A4. Computed rather than tabulated because a table
* of 84 frequencies is 84 chances to fat-finger one, and this is exact to
* the precision anybody can hear.
*/
steps = ((octave - 4) * 12) + (semitone - A_SEMITONE);
*dest = A4_HZ * pow(2.0, (double)steps / 12.0);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_audio_whole_note_ms(int tempo, int *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (dest != NULL), AKERR_NULLPOINTER,
"NULL destination in whole_note_ms");
FAIL_ZERO_RETURN(errctx, (tempo >= AKBASIC_TEMPO_MIN && tempo <= AKBASIC_TEMPO_MAX),
AKBASIC_ERR_BOUNDS, "TEMPO %d out of range (%d to %d)",
tempo, AKBASIC_TEMPO_MIN, AKBASIC_TEMPO_MAX);
*dest = WHOLE_NOTE_AT_TEMPO_1 / tempo;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_audio_state_init(akbasic_AudioState *obj)
{
PREPARE_ERROR(errctx);
int i = 0;
int wholems = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER,
"NULL audio state in init");
obj->head = 0;
obj->count = 0;
obj->nextms = 0;
obj->sounding = false;
obj->tempo = AKBASIC_TEMPO_DEFAULT;
obj->voice = 0;
obj->octave = 4;
obj->envelope = 0;
obj->level = 1.0;
PASS(errctx, akbasic_audio_whole_note_ms(obj->tempo, &wholems));
obj->notems = wholems / 4; /* PLAY starts on quarter notes */
/*
* Every preset starts as a plain square wave held at full level. A zeroed
* envelope would have a sustain of 0.0, which is silence with nothing to
* explain it -- the same reasoning libakgl gives for its own voice defaults.
*/
for ( i = 0; i < AKBASIC_ENVELOPES; i++ ) {
obj->envelopes[i].attack = 0;
obj->envelopes[i].decay = 0;
obj->envelopes[i].sustain = 1.0;
obj->envelopes[i].release = 0;
obj->envelopes[i].waveform = AKBASIC_WAVE_SQUARE;
}
SUCCEED_RETURN(errctx);
}