Files
akbasic/include/akbasic/audio.h
Tachikoma b751c1b600 Consume libakgl 0.3.0: every workaround deleted, two capabilities gained
0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().

Two gaps were capabilities rather than inconveniences, and both are now real:

The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.

SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.

The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-07-31 13:25:14 -04:00

229 lines
9.7 KiB
C

/**
* @file audio.h
* @brief Declares the audio backend: where SOUND, PLAY, ENVELOPE and VOL land.
*
* Same reasoning as graphics.h -- the core library is free of SDL, so the sound
* verbs call through a record of function pointers and akbasic_audio_init_akgl()
* in the akbasic_akgl target wires that record to akgl_audio_*.
*
* The libakgl API this is modelled on is a tone generator, not a sample player:
* three voices, a waveform and an ADSR envelope each, mixed to one stream. That
* is the right shape, because BASIC 7.0's sound verbs describe notes rather than
* recordings. What it does not have is a filter stage, which is why FILTER is
* refused -- see TODO.md section 7.
*/
#ifndef _AKBASIC_AUDIO_H_
#define _AKBASIC_AUDIO_H_
#include <akerror.h>
#include <akbasic/types.h>
/** @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.
*
* Declared here rather than reused from akgl/audio.h so the core library does
* not include a libakgl header. The akgl backend maps these across; the values
* happen to agree today and the mapping is still written out, because two
* enumerations agreeing by accident is not a contract.
*/
typedef enum
{
AKBASIC_WAVE_TRIANGLE = 0, /* SOUND waveform 0 */
AKBASIC_WAVE_SAWTOOTH = 1, /* SOUND waveform 1 */
AKBASIC_WAVE_SQUARE = 2, /* SOUND waveform 2, and the power-on default */
AKBASIC_WAVE_NOISE = 3 /* SOUND waveform 3 */
} akbasic_Waveform;
/**
* @brief Where the sound verbs play.
*
* Voices here are numbered from zero. BASIC numbers them 1 through 3 and the
* conversion happens once, in the verb handler, so a backend never sees a BASIC
* voice number.
*
* Durations are milliseconds. BASIC counts in jiffies (1/60 s) and note lengths
* derived from TEMPO; both are converted in src/audio_tables.c before they get
* here, so a backend never sees a jiffy either.
*/
typedef struct akbasic_AudioBackend
{
void *self;
/** Start a note on a voice, for a bounded duration. */
akerr_ErrorContext AKERR_NOIGNORE *(*tone)(struct akbasic_AudioBackend *self, int voice, double hz, int ms);
/**
* Start a note whose pitch moves, which is what SOUND's `dir`, `min` and
* `step` arguments ask for -- a siren, a laser, a falling bomb.
*
* May be NULL, and a host that leaves it so gets `SOUND` refused with
* AKBASIC_ERR_DEVICE for a swept note and served normally for a held one.
* That is deliberate: this arrived in libakgl 0.3.0, and a backend written
* against an older one is still a valid backend for everything else.
*/
akerr_ErrorContext AKERR_NOIGNORE *(*sweep)(struct akbasic_AudioBackend *self, int voice, double from_hz, double to_hz, double step_hz, int ms);
/** Silence a voice immediately. */
akerr_ErrorContext AKERR_NOIGNORE *(*stop)(struct akbasic_AudioBackend *self, int voice);
/** Select the waveform a voice synthesises. */
akerr_ErrorContext AKERR_NOIGNORE *(*waveform)(struct akbasic_AudioBackend *self, int voice, akbasic_Waveform waveform);
/** Set a voice's ADSR envelope. Sustain is a level from 0.0 to 1.0, not a time. */
akerr_ErrorContext AKERR_NOIGNORE *(*envelope)(struct akbasic_AudioBackend *self, int voice, int attack, int decay, double sustain, int release);
/** Set the master output level, 0.0 to 1.0. */
akerr_ErrorContext AKERR_NOIGNORE *(*volume)(struct akbasic_AudioBackend *self, double level);
/** Report whether a voice is still sounding. PLAY's queue uses this to pace itself. */
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_