Files
akbasic/src/play.c
Tachikoma d219f80777
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m27s
akbasic CI Build / coverage (push) Failing after 3m44s
akbasic CI Build / sanitizers (push) Failing after 4m43s
akbasic CI Build / mutation_test (push) Failing after 3m45s
akbasic CI Build / akgl_build (push) Failing after 4m51s
Port onto libakstdlib 2b79aca and convert the eight bool predicates
akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2%
bypassed, against 86.4% on the same tree before this. The submodule bump
669b2b3 -> 2b79aca needed no source change of its own: the release is
drop-in for what akbasic already used.

Seven of the eight sites the earlier port left on raw libc change their own
signature rather than swallowing an error, per andrew's ruling on
libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end,
peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's
scroll/newline/putchar_at/echo_line/edit_key chain all return an
akerr_ErrorContext * and hand the answer back through an out parameter.
is_waiting_for and is_waiting_for_any are a public header change; every
call site that used one as a term in a condition hoists it into a
statement first.

verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the
comparator's signature, so there is no out parameter to report through --
which is what libakstdlib#38 concluded. It carries a comment saying so and
saying why the bypass is safe there.

Six snprintf sites stay raw because they want truncation as an answer
rather than an error, and aksl_snprintf cannot express that until
libakstdlib#34 hands the required length back. Each of the six says so at
the site. Two of them, in host.c, are a latent defect rather than a
decision: a host type name over 31 characters truncates silently and two
sharing a prefix then collide, where structtype.c refuses the same case.

DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the
PASS in it returned past CLEANUP, so a scan error left the file open.
Hoisting the loop into its own helper to convert fgets fixes it.

Refs libakstdlib#26, libakstdlib#38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:41:49 -04:00

278 lines
8.4 KiB
C

/**
* @file play.c
* @brief The PLAY note-string parser and the queue that paces it.
*
* libakgl's audio commit says where this belongs: *"TEMPO and the PLAY
* note-string parser ... belong in the interpreter rather than here."* A tone
* generator synthesises pitches; deciding that `O4CDEFG` means five quarter notes
* starting at middle C is a language question.
*
* On a C128, PLAY holds the program until the string finishes. Section 1.6
* forbids that, so PLAY parses the whole string into a fixed queue and returns
* immediately; akbasic_play_service() releases one note at a time against the
* host's clock and is called from akbasic_runtime_step(). A host keeps its frame
* rate and the notes still come out in order at their written lengths.
*/
#include <ctype.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akbasic/audio.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "verbs.h"
/**
* @brief Semitone offset of each note letter within an octave that starts at C.
*
* Indexed by letter minus 'A', so it reads out of order. That is the price of
* indexing directly instead of searching, and the alternative -- a search over a
* seven-entry table -- would not be clearer.
*/
static const int NOTE_SEMITONE[7] = {
9, /* A */
11, /* B */
0, /* C */
2, /* D */
4, /* E */
5, /* F */
7 /* G */
};
/**
* @brief What each duration letter divides a whole note by.
*
* BASIC 7.0 spells them Whole, Half, Quarter, eIghth and Sixteenth -- the eighth
* is I rather than E because E is already a note.
*/
static const struct
{
char letter;
int divisor;
} DURATIONS[] = {
{ 'W', 1 }, /* whole */
{ 'H', 2 }, /* half */
{ 'Q', 4 }, /* quarter */
{ 'I', 8 }, /* eighth */
{ 'S', 16 } /* sixteenth */
};
#define DURATION_COUNT ((int)(sizeof(DURATIONS) / sizeof(DURATIONS[0])))
/** @brief Append one note to the queue, or report that it is full. */
static akerr_ErrorContext *enqueue(akbasic_AudioState *audio, int voice, double hz, int ms, bool rest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (audio->count < AKBASIC_MAX_PLAY_NOTES), AKBASIC_ERR_BOUNDS,
"PLAY queue is full at %d notes", AKBASIC_MAX_PLAY_NOTES);
audio->queue[audio->count].voice = voice;
audio->queue[audio->count].hz = hz;
audio->queue[audio->count].ms = ms;
audio->queue[audio->count].rest = rest;
audio->count += 1;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_play_parse(akbasic_Runtime *obj, const char *notes)
{
PREPARE_ERROR(errctx);
akbasic_AudioState *audio = NULL;
size_t i = 0;
size_t len = 0;
int accidental = 0;
int semitone = 0;
int wholems = 0;
int ms = 0;
int value = 0;
int d = 0;
double hz = 0.0;
char c = '\0';
bool dotted = false;
FAIL_ZERO_RETURN(errctx, (obj != NULL && notes != NULL), AKERR_NULLPOINTER,
"NULL argument in play_parse");
audio = &obj->audio_state;
PASS(errctx, aksl_strlen(notes, &len));
/*
* A loop, so no CATCH and no _BREAK macros in here -- they expand to a C
* break and would leave the loop with an error still pending. PASS and the
* _RETURN forms only.
*/
for ( i = 0; i < len; i++ ) {
c = (char)toupper((unsigned char)notes[i]);
/* Accidentals and dots are prefixes; they modify the next note letter. */
if ( c == '#' ) {
accidental = 1;
continue;
}
if ( c == '$' ) {
accidental = -1;
continue;
}
if ( c == '.' ) {
dotted = true;
continue;
}
if ( c == ' ' ) {
continue;
}
/* A duration letter changes the length of every note after it. */
for ( d = 0; d < DURATION_COUNT; d++ ) {
if ( DURATIONS[d].letter == c ) {
PASS(errctx, akbasic_audio_whole_note_ms(audio->tempo, &wholems));
audio->notems = wholems / DURATIONS[d].divisor;
break;
}
}
if ( d < DURATION_COUNT ) {
continue;
}
/* The single-argument settings all take one decimal digit. */
if ( c == 'V' || c == 'O' || c == 'T' || c == 'U' || c == 'X' ) {
FAIL_ZERO_RETURN(errctx,
(i + 1 < len && isdigit((unsigned char)notes[i + 1])),
AKBASIC_ERR_SYNTAX,
"PLAY %c expected a digit", c);
value = notes[i + 1] - '0';
i += 1;
switch ( c ) {
case 'V':
FAIL_ZERO_RETURN(errctx, (value >= 1 && value <= AKBASIC_AUDIO_VOICES),
AKBASIC_ERR_BOUNDS,
"PLAY V%d out of range (1 to %d)", value, AKBASIC_AUDIO_VOICES);
audio->voice = value - 1;
break;
case 'O':
FAIL_ZERO_RETURN(errctx, (value >= 0 && value <= 6), AKBASIC_ERR_BOUNDS,
"PLAY O%d out of range (0 to 6)", value);
audio->octave = value;
break;
case 'T':
FAIL_ZERO_RETURN(errctx, (value >= 0 && value < AKBASIC_ENVELOPES),
AKBASIC_ERR_BOUNDS,
"PLAY T%d out of range (0 to %d)", value, AKBASIC_ENVELOPES - 1);
audio->envelope = value;
break;
case 'U':
FAIL_ZERO_RETURN(errctx, (value >= 0 && value <= 9), AKBASIC_ERR_BOUNDS,
"PLAY U%d out of range (0 to 9)", value);
audio->level = (double)value / 9.0;
break;
default:
/*
* X selects the filter, which there is nothing behind -- see
* TODO.md section 7. Refused rather than ignored, for the same
* reason FILTER is: a program that asked for a filtered sound
* and got an unfiltered one has been lied to.
*/
FAIL_RETURN(errctx, AKBASIC_ERR_DEVICE,
"PLAY X selects the filter, which this device does not have");
}
continue;
}
ms = audio->notems;
if ( dotted ) {
/* A dot adds half the note's length again. */
ms = ms + (ms / 2);
dotted = false;
}
if ( c == 'R' ) {
PASS(errctx, enqueue(audio, audio->voice, 0.0, ms, true));
accidental = 0;
continue;
}
if ( c == 'M' ) {
/*
* A measure bar. On a C128 it synchronises the voices; with one
* sequential queue there is nothing to synchronise, so it is a
* no-op rather than an error -- a listing full of them should still
* play. TODO.md section 5.
*/
continue;
}
FAIL_ZERO_RETURN(errctx, (c >= 'A' && c <= 'G'), AKBASIC_ERR_SYNTAX,
"PLAY does not understand '%c'", notes[i]);
semitone = NOTE_SEMITONE[c - 'A'] + accidental;
accidental = 0;
/*
* B# wraps to the next octave and C$ to the previous one. Wrapping the
* semitone without moving the octave would put B# a full octave below
* where it belongs.
*/
if ( semitone > 11 ) {
semitone -= 12;
PASS(errctx, akbasic_audio_note_hz(semitone,
(audio->octave < 6) ? audio->octave + 1 : 6,
&hz));
} else if ( semitone < 0 ) {
semitone += 12;
PASS(errctx, akbasic_audio_note_hz(semitone,
(audio->octave > 0) ? audio->octave - 1 : 0,
&hz));
} else {
PASS(errctx, akbasic_audio_note_hz(semitone, audio->octave, &hz));
}
PASS(errctx, enqueue(audio, audio->voice, hz, ms, false));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_play_service(akbasic_Runtime *obj)
{
PREPARE_ERROR(errctx);
akbasic_AudioState *audio = NULL;
akbasic_PlayNote *note = NULL;
akbasic_Envelope *envelope = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in play_service");
audio = &obj->audio_state;
if ( audio->head >= audio->count ) {
/* Nothing queued. Reset so the next PLAY starts from the front. */
audio->head = 0;
audio->count = 0;
audio->sounding = false;
SUCCEED_RETURN(errctx);
}
if ( obj->timems < audio->nextms ) {
/* The note holding the queue has not run out yet. */
SUCCEED_RETURN(errctx);
}
/*
* No audio backend means the queue drains silently rather than jamming. A
* host that took the device away mid-tune should not leave the script stuck
* behind a note that can never finish.
*/
note = &audio->queue[audio->head];
audio->head += 1;
audio->nextms = obj->timems + note->ms;
audio->sounding = true;
if ( obj->audio == NULL ) {
SUCCEED_RETURN(errctx);
}
if ( note->rest ) {
PASS(errctx, obj->audio->stop(obj->audio, note->voice));
SUCCEED_RETURN(errctx);
}
envelope = &audio->envelopes[audio->envelope];
PASS(errctx, obj->audio->waveform(obj->audio, note->voice, envelope->waveform));
PASS(errctx, obj->audio->envelope(obj->audio, note->voice, envelope->attack,
envelope->decay, envelope->sustain, envelope->release));
PASS(errctx, obj->audio->tone(obj->audio, note->voice, note->hz, note->ms));
SUCCEED_RETURN(errctx);
}