/** * @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 #include #include #include #include #include #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); }