joeylib2/tools/songbake/songbake.c

709 lines
23 KiB
C

// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
// See LICENSE.md for license information.
// SPDX-License-Identifier: MIT
//
// songbake.c - bake JoeyLib tracker text notation into a JYM1 event stream.
//
// One authored .song file plays on every JoeyLib port through the
// jlMusicPlay sequencer (src/core/music.c), which drives the jlAudioVoice
// 3-voice tone layer and the jlAudioNoise percussion generator. See music.c
// for the JYM1 stream layout this tool emits.
//
// Input format (one row per line, ';' starts a comment):
//
// tickms 110 directive: ms per row. Required before the first
// row; later occurrences emit a tempo change event at
// that point in the song.
// loop directive: the NEXT row is the loop-back target
// repeat 4 directive: repeat the rows down to 'endrepeat'
// endrepeat 4 times (repeats may nest; 'loop' inside a repeat
// block is rejected)
// C-2:2 A-4:5 --- row: 3 tone cells, or 4 cells with noise last
// C-2:2 --- --- N4:6
//
// Tone cell forms:
// C-4:3 note-on, note name + octave, ':' + AGI attenuation 0..14 (0 =
// loudest). ':atten' may be omitted (defaults to 0). Sharps use
// '#': C#4:3. Note range C-0..B-7.
// @440:3 note-on at an exact frequency in Hz (1..65535) -- for
// chip-accurate transcription work where equal-temperament note
// names would round the pitch.
// --- hold: no event for this voice this row
// off silence this voice
//
// Noise cell forms (4th column):
// N4:3 noise-on, pitch 0..31 (0 = brightest hiss, 31 = lowest rumble),
// ':' + attenuation as above
// --- hold
// off silence the noise voice
//
// On the Atari ST the noise generator shares channel C's volume with tone
// voice 2; songbake warns when both are audible at once.
//
// A row lasts one tick. Rows with no events compress into wait bytes.
//
// Usage:
// songbake song.song song.jym binary stream
// songbake -c gName song.song song.h C header (uint8_t array)
#include <ctype.h>
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "toolBuf.h"
#include "toolFile.h"
#define TOOL_NAME "songbake"
#define USAGE_EXIT 2
#define OP_END 0x00u
#define OP_NOTE 0x10u
#define OP_OFF 0x20u
#define OP_TEMPO 0x40u
#define OP_WAIT 0x80u
#define WAIT_MAX 0x7Fu
#define LOOP_NONE 0xFFFFu
#define TONE_VOICES 3u
#define NOISE_VOICE 3u
#define NOISE_PITCH_MAX 31u
#define ATTEN_MAX 14u
#define TICK_MS_MAX 1000u
#define REPEAT_MAX 256u
#define FREQ_MAX 0xFFFFu
#define JYM_MAGIC "JYM1"
#define JYM_MAGIC_BYTES 4u
// Equal temperament: A-4 (MIDI note 69) is 440 Hz, 12 notes an octave, and
// octave 0 starts at MIDI note 12.
#define A4_HZ 440.0
#define A4_MIDI 69
#define OCTAVE_NOTES 12
#define OCTAVE_MAX 7
// A row's cells, and the most of a line's words kept (enough to tell a row
// of too many cells by its count).
#define CELLS_MAX 5u
// The ST overlap warning names this many lines at most.
#define OVERLAP_SHOWN 8u
// The C header's array: this many bytes a line.
#define C_BYTES_PER_LINE 12u
#define ASCII_MAX 0x7Fu
// A number's digits saturate here: anything this big is out of every range.
#define NUMBER_CAP 0x7FFFFFFFu
// One line of the source that is not blank: its number, its words (the
// first CELLS_MAX), and how many words it has.
typedef struct LineT {
uint32_t lineNum;
uint32_t cellCount;
char *cells[CELLS_MAX];
} LineT;
// A row of the song once repeats are unrolled: its line, and whether it
// came from inside a repeat block.
typedef struct RowT {
const LineT *line;
bool inRepeat;
} RowT;
typedef struct RowListT {
RowT *rows;
size_t count;
size_t cap;
} RowListT;
// An open repeat block: its count and the rows in it so far.
typedef struct BlockT {
uint32_t count;
RowListT rows;
} BlockT;
// A parsed cell: held, off, or on with a value (Hz or noise pitch) and an
// attenuation.
typedef enum CellKindE {
CELL_HOLD = 0,
CELL_OFF,
CELL_ON
} CellKindE;
typedef struct CellT {
CellKindE kind;
uint32_t value;
uint32_t atten;
} CellT;
static void bake(const LineT *lines, size_t lineCount, ToolBufT *blob);
static bool digits(const char *s, size_t len, uint32_t *value);
static void expandRepeats(const LineT *lines, size_t lineCount, RowListT *out);
static void fail(uint32_t lineNum, const char *fmt, ...) __attribute__((noreturn, format(printf, 2, 3)));
static void flushWait(ToolBufT *events, uint32_t *pendingWait);
static bool isWord(const char *cell, const char *word);
static size_t linesRead(char *text, size_t len, LineT **lines);
static uint32_t noteToFreq(const char *cell, size_t len, uint32_t lineNum);
static void overlapNote(uint32_t *shown, uint32_t *shownCount, uint32_t lineNum);
static CellT parseNoiseCell(const char *cell, uint32_t lineNum);
static CellT parseToneCell(const char *cell, uint32_t lineNum);
static void rowAdd(RowListT *list, const LineT *line, bool inRepeat);
static size_t splitAtten(const char *cell, uint32_t lineNum, uint32_t *atten);
static void usage(void);
static void writeCHeader(const char *path, const char *name, const ToolBufT *blob, const char *srcName);
static const char *const kNoteNames[OCTAVE_NOTES] = {
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
};
// The song's lines into its JYM1 stream: the header, then the events.
static void bake(const LineT *lines, size_t lineCount, ToolBufT *blob) {
RowListT rows;
ToolBufT events;
ToolBufT rowEvents;
CellT cell;
const LineT *line;
uint32_t tickMs;
uint32_t loopOfs;
uint32_t pendingWait;
uint32_t ms;
uint32_t voice;
uint32_t shown[OVERLAP_SHOWN];
uint32_t shownCount;
uint32_t i;
bool haveTick;
bool loopPending;
bool sawRow;
bool voice2On;
bool noiseOn;
size_t r;
memset(&rows, 0, sizeof(rows));
memset(&events, 0, sizeof(events));
memset(&rowEvents, 0, sizeof(rowEvents));
expandRepeats(lines, lineCount, &rows);
tickMs = 0u;
haveTick = false;
loopOfs = LOOP_NONE;
loopPending = false;
pendingWait = 0u;
sawRow = false;
voice2On = false;
noiseOn = false;
shownCount = 0u;
for (r = 0; r < rows.count; r++) {
line = rows.rows[r].line;
if (isWord(line->cells[0], "tickms")) {
if (line->cellCount != 2u || !digits(line->cells[1], strlen(line->cells[1]), &ms)) {
fail(line->lineNum, "usage: tickms <1..%u>", TICK_MS_MAX);
}
if (ms < 1u || ms > TICK_MS_MAX) {
fail(line->lineNum, "tickms out of range (1..%u)", TICK_MS_MAX);
}
if (!haveTick) {
tickMs = ms;
haveTick = true;
} else {
// Mid-song tempo change: flush waits owed at the old tempo,
// then emit the change event.
flushWait(&events, &pendingWait);
toolBufByte(&events, OP_TEMPO);
toolBufLe16(&events, ms);
}
continue;
}
if (isWord(line->cells[0], "loop")) {
if (line->cellCount != 1u) {
fail(line->lineNum, "'loop' takes no arguments");
}
if (rows.rows[r].inRepeat) {
fail(line->lineNum, "'loop' inside a repeat block is ambiguous");
}
if (loopOfs != LOOP_NONE || loopPending) {
fail(line->lineNum, "second 'loop' directive");
}
loopPending = true;
continue;
}
if (line->cellCount != TONE_VOICES && line->cellCount != TONE_VOICES + 1u) {
fail(line->lineNum, "want %u or %u voice cells, got %u", TONE_VOICES, TONE_VOICES + 1u, line->cellCount);
}
rowEvents.len = 0u;
for (voice = 0; voice < TONE_VOICES; voice++) {
cell = parseToneCell(line->cells[voice], line->lineNum);
if (cell.kind == CELL_HOLD) {
continue;
}
if (cell.kind == CELL_OFF) {
toolBufByte(&rowEvents, OP_OFF + voice);
} else {
toolBufByte(&rowEvents, OP_NOTE + voice);
toolBufLe16(&rowEvents, cell.value);
toolBufByte(&rowEvents, cell.atten);
}
if (voice == TONE_VOICES - 1u) {
voice2On = cell.kind == CELL_ON;
}
}
if (line->cellCount == TONE_VOICES + 1u) {
cell = parseNoiseCell(line->cells[NOISE_VOICE], line->lineNum);
if (cell.kind == CELL_OFF) {
toolBufByte(&rowEvents, OP_OFF + NOISE_VOICE);
noiseOn = false;
} else if (cell.kind == CELL_ON) {
toolBufByte(&rowEvents, OP_NOTE + NOISE_VOICE);
toolBufLe16(&rowEvents, cell.value);
toolBufByte(&rowEvents, cell.atten);
noiseOn = true;
}
}
if (voice2On && noiseOn) {
overlapNote(shown, &shownCount, line->lineNum);
}
// The loop target is the first byte this row contributes; flush
// queued waits first so the jump replays them from the row itself,
// not from silence belonging to earlier rows.
if (loopPending || rowEvents.len > 0u) {
flushWait(&events, &pendingWait);
}
if (loopPending) {
loopOfs = (uint32_t)events.len;
loopPending = false;
}
toolBufAdd(&events, rowEvents.data, rowEvents.len);
pendingWait++;
sawRow = true;
}
if (!haveTick) {
fail(0u, "missing 'tickms' directive");
}
if (!sawRow) {
fail(0u, "song has no rows");
}
if (loopPending) {
fail(0u, "'loop' directive with no row after it");
}
flushWait(&events, &pendingWait);
toolBufByte(&events, OP_END);
if (events.len >= LOOP_NONE) {
fail(0u, "event stream exceeds 64 KB");
}
if (shownCount > 0u) {
fprintf(stderr, "%s: warning: tone voice 2 and noise are audible together (lines ", TOOL_NAME);
for (i = 0; i < shownCount; i++) {
fprintf(stderr, "%s%u", i == 0u ? "" : ", ", shown[i]);
}
fprintf(stderr, "); on the Atari ST they share channel C's volume\n");
}
toolBufAdd(blob, JYM_MAGIC, JYM_MAGIC_BYTES);
toolBufLe16(blob, tickMs);
toolBufLe16(blob, loopOfs);
toolBufAdd(blob, events.data, events.len);
toolBufFree(&rowEvents);
toolBufFree(&events);
free(rows.rows);
}
// len bytes of s as a decimal number (saturating at NUMBER_CAP); false
// unless all of them, at least one, are digits.
static bool digits(const char *s, size_t len, uint32_t *value) {
size_t i;
if (len == 0u) {
return false;
}
*value = 0u;
for (i = 0; i < len; i++) {
if (!isdigit((unsigned char)s[i])) {
return false;
}
if (*value < NUMBER_CAP / 10u) {
*value = *value * 10u + (uint32_t)(s[i] - '0');
} else {
*value = NUMBER_CAP;
}
}
return true;
}
// Unroll repeat/endrepeat blocks (nesting allowed), keeping each row's line
// for error reporting.
static void expandRepeats(const LineT *lines, size_t lineCount, RowListT *out) {
BlockT *stack;
RowListT *dest;
uint32_t count;
uint32_t n;
size_t depth;
size_t i;
size_t r;
stack = (BlockT *)calloc(lineCount + 1u, sizeof(BlockT));
if (stack == NULL) {
toolFail("out of memory");
}
depth = 0u;
for (i = 0; i < lineCount; i++) {
if (isWord(lines[i].cells[0], "repeat")) {
if (lines[i].cellCount != 2u || !digits(lines[i].cells[1], strlen(lines[i].cells[1]), &count)) {
fail(lines[i].lineNum, "usage: repeat <count>");
}
if (count < 1u || count > REPEAT_MAX) {
fail(lines[i].lineNum, "repeat count out of range (1..%u)", REPEAT_MAX);
}
memset(&stack[depth], 0, sizeof(BlockT));
stack[depth].count = count;
depth++;
continue;
}
if (isWord(lines[i].cells[0], "endrepeat")) {
if (depth == 0u) {
fail(lines[i].lineNum, "endrepeat without repeat");
}
depth--;
dest = depth > 0u ? &stack[depth - 1u].rows : out;
for (n = 0; n < stack[depth].count; n++) {
for (r = 0; r < stack[depth].rows.count; r++) {
rowAdd(dest, stack[depth].rows.rows[r].line, stack[depth].rows.rows[r].inRepeat);
}
}
free(stack[depth].rows.rows);
continue;
}
rowAdd(depth > 0u ? &stack[depth - 1u].rows : out, &lines[i], depth > 0u);
}
if (depth > 0u) {
fail(0u, "repeat without endrepeat");
}
free(stack);
}
// "songbake: line N: message" (no line for a whole-song error, lineNum 0)
// to stderr, and exit 1.
static void fail(uint32_t lineNum, const char *fmt, ...) {
va_list args;
fprintf(stderr, "%s: ", TOOL_NAME);
if (lineNum != 0u) {
fprintf(stderr, "line %u: ", lineNum);
}
va_start(args, fmt);
vfprintf(stderr, fmt, args);
va_end(args);
fputc('\n', stderr);
exit(1);
}
// The rows waited so far as wait bytes, WAIT_MAX at most each.
static void flushWait(ToolBufT *events, uint32_t *pendingWait) {
uint32_t chunk;
while (*pendingWait > 0u) {
chunk = *pendingWait < WAIT_MAX ? *pendingWait : WAIT_MAX;
toolBufByte(events, OP_WAIT | chunk);
*pendingWait -= chunk;
}
}
static bool isWord(const char *cell, const char *word) {
return strcmp(cell, word) == 0;
}
// The text's lines that are not blank once their comment (from ';') is
// gone, split into words in place; how many. Lines end at "\n", "\r\n", or
// "\r", as Python's text files read them.
static size_t linesRead(char *text, size_t len, LineT **lines) {
LineT *out;
size_t count;
size_t at;
size_t end;
size_t next;
size_t i;
uint32_t lineNum;
char *p;
char *comment;
LineT *line;
out = (LineT *)malloc((len + 1u) * sizeof(LineT));
if (out == NULL) {
toolFail("out of memory");
}
count = 0u;
lineNum = 0u;
for (at = 0; at < len; at = next) {
for (end = at; end < len && text[end] != '\n' && text[end] != '\r'; end++) {
}
next = end + ((end + 1u < len && text[end] == '\r' && text[end + 1u] == '\n') ? 2u : 1u);
text[end] = '\0';
lineNum++;
comment = strchr(text + at, ';');
if (comment != NULL) {
*comment = '\0';
}
line = &out[count];
line->lineNum = lineNum;
line->cellCount = 0u;
for (p = text + at; *p != '\0';) {
while (isspace((unsigned char)*p)) {
p++;
}
if (*p == '\0') {
break;
}
if (line->cellCount < CELLS_MAX) {
line->cells[line->cellCount] = p;
}
line->cellCount++;
while (*p != '\0' && !isspace((unsigned char)*p)) {
p++;
}
if (*p != '\0') {
*p++ = '\0';
}
}
for (i = line->cellCount; i < CELLS_MAX; i++) {
line->cells[i] = "";
}
if (line->cellCount > 0u) {
count++;
}
}
*lines = out;
return count;
}
// 'C-4' / 'C#4' (len bytes of cell) as its equal-temperament Hz, A-4 = 440.
static uint32_t noteToFreq(const char *cell, size_t len, uint32_t lineNum) {
size_t nameLen;
uint32_t note;
int octave;
int midi;
double freq;
nameLen = len > 0u ? len - 1u : 0u;
while (nameLen > 0u && cell[nameLen - 1u] == '-') {
nameLen--;
}
for (note = 0; note < OCTAVE_NOTES; note++) {
if (strlen(kNoteNames[note]) == nameLen && strncmp(cell, kNoteNames[note], nameLen) == 0) {
break;
}
}
if (len == 0u || note == OCTAVE_NOTES || !isdigit((unsigned char)cell[len - 1u])) {
fail(lineNum, "bad note '%.*s' (want e.g. C-4, C#4, A-2)", (int)len, cell);
}
octave = cell[len - 1u] - '0';
if (octave > OCTAVE_MAX) {
fail(lineNum, "octave out of range in '%.*s' (0..%d)", (int)len, cell, OCTAVE_MAX);
}
midi = (octave + 1) * OCTAVE_NOTES + (int)note;
freq = nearbyint(A4_HZ * pow(2.0, (double)(midi - A4_MIDI) / (double)OCTAVE_NOTES));
if (freq < 1.0 || freq > (double)FREQ_MAX) {
fail(lineNum, "'%.*s' is out of the 16-bit Hz range", (int)len, cell);
}
return (uint32_t)freq;
}
// lineNum among the first OVERLAP_SHOWN distinct lines, smallest first.
static void overlapNote(uint32_t *shown, uint32_t *shownCount, uint32_t lineNum) {
uint32_t i;
uint32_t j;
for (i = 0; i < *shownCount && shown[i] < lineNum; i++) {
}
if ((i < *shownCount && shown[i] == lineNum) || i == OVERLAP_SHOWN) {
return;
}
if (*shownCount < OVERLAP_SHOWN) {
(*shownCount)++;
}
for (j = *shownCount - 1u; j > i; j--) {
shown[j] = shown[j - 1u];
}
shown[i] = lineNum;
}
// A noise cell: held, off, or a pitch (0..31) at an attenuation.
static CellT parseNoiseCell(const char *cell, uint32_t lineNum) {
CellT out;
size_t bodyLen;
memset(&out, 0, sizeof(out));
if (isWord(cell, "---")) {
return out;
}
if (isWord(cell, "off")) {
out.kind = CELL_OFF;
return out;
}
bodyLen = splitAtten(cell, lineNum, &out.atten);
if (bodyLen == 0u || cell[0] != 'N' || !digits(cell + 1, bodyLen - 1u, &out.value)) {
fail(lineNum, "bad noise cell '%s' (want e.g. N4:3, N31)", cell);
}
if (out.value > NOISE_PITCH_MAX) {
fail(lineNum, "noise pitch out of range in '%s' (0..%u)", cell, NOISE_PITCH_MAX);
}
out.kind = CELL_ON;
return out;
}
// A tone cell: held, off, or a frequency (a note, or @Hz) at an
// attenuation.
static CellT parseToneCell(const char *cell, uint32_t lineNum) {
CellT out;
size_t bodyLen;
memset(&out, 0, sizeof(out));
if (isWord(cell, "---")) {
return out;
}
if (isWord(cell, "off")) {
out.kind = CELL_OFF;
return out;
}
bodyLen = splitAtten(cell, lineNum, &out.atten);
if (bodyLen > 0u && cell[0] == '@') {
if (!digits(cell + 1, bodyLen - 1u, &out.value)) {
fail(lineNum, "bad frequency cell '%s' (want e.g. @440:3)", cell);
}
if (out.value < 1u || out.value > FREQ_MAX) {
fail(lineNum, "frequency out of range in '%s' (1..%u)", cell, FREQ_MAX);
}
} else {
out.value = noteToFreq(cell, bodyLen, lineNum);
}
out.kind = CELL_ON;
return out;
}
static void rowAdd(RowListT *list, const LineT *line, bool inRepeat) {
RowT *rows;
size_t cap;
if (list->count == list->cap) {
cap = list->cap == 0u ? 64u : list->cap * 2u;
rows = (RowT *)realloc(list->rows, cap * sizeof(RowT));
if (rows == NULL) {
toolFail("out of memory");
}
list->rows = rows;
list->cap = cap;
}
list->rows[list->count].line = line;
list->rows[list->count].inRepeat = inRepeat;
list->count++;
}
// A cell's attenuation, after its last ':' (0 when it has none): the
// length of the cell before it.
static size_t splitAtten(const char *cell, uint32_t lineNum, uint32_t *atten) {
const char *colon;
colon = strrchr(cell, ':');
*atten = 0u;
if (colon == NULL) {
return strlen(cell);
}
if (!digits(colon + 1, strlen(colon + 1), atten) || *atten > ATTEN_MAX) {
fail(lineNum, "bad attenuation in '%s' (0..%u)", cell, ATTEN_MAX);
}
return (size_t)(colon - cell);
}
static void usage(void) {
fprintf(stderr, "usage: %s [-c NAME] input.song output\n", TOOL_NAME);
exit(USAGE_EXIT);
}
// The stream as a C header: a static uint8_t array named name.
static void writeCHeader(const char *path, const char *name, const ToolBufT *blob, const char *srcName) {
FILE *f;
size_t i;
bool ok;
f = fopen(path, "w");
if (f == NULL) {
toolFail("cannot write %s", path);
}
fprintf(f, "// Generated by tools/songbake from %s.\n", srcName);
fprintf(f, "// Do not hand-edit; edit the .song source and rebake:\n");
fprintf(f, "// build/tools/songbake -c %s %s <this file>\n\n", name, srcName);
fprintf(f, "#ifndef JOEYLIB_BAKED_");
for (i = 0; name[i] != '\0'; i++) {
fputc(toupper((unsigned char)name[i]), f);
}
fprintf(f, "_H\n#define JOEYLIB_BAKED_");
for (i = 0; name[i] != '\0'; i++) {
fputc(toupper((unsigned char)name[i]), f);
}
fprintf(f, "_H\n\n#include <stdint.h>\n\n");
fprintf(f, "static const uint8_t %s[%u] = {\n", name, (unsigned)blob->len);
for (i = 0; i < blob->len; i++) {
fprintf(f, "%s0x%02X%s", i % C_BYTES_PER_LINE == 0u ? " " : " ", blob->data[i], (i % C_BYTES_PER_LINE == C_BYTES_PER_LINE - 1u || i == blob->len - 1u) ? ",\n" : ",");
}
fprintf(f, "};\n\n#endif\n");
ok = !ferror(f);
ok = fclose(f) == 0 && ok;
if (!ok) {
toolFail("cannot write %s", path);
}
}
int main(int argc, char **argv) {
const char *cName;
char *text;
uint8_t *data;
size_t len;
size_t i;
size_t lineCount;
int a;
LineT *lines;
ToolBufT blob;
toolFileName(TOOL_NAME);
cName = NULL;
a = 1;
if (argc > 1 && strcmp(argv[1], "-c") == 0) {
if (argc < 3) {
fail(0u, "-c needs a symbol name");
}
cName = argv[2];
a = 3;
}
if (argc - a != 2) {
usage();
}
if (!toolReadFile(argv[a], &data, &len)) {
toolFail("cannot read %s", argv[a]);
}
text = (char *)data;
for (i = 0; i < len; i++) {
if ((unsigned char)text[i] > ASCII_MAX) {
toolFail("%s is not ASCII (byte %zu)", argv[a], i);
}
}
lineCount = linesRead(text, len, &lines);
memset(&blob, 0, sizeof(blob));
bake(lines, lineCount, &blob);
if (cName != NULL) {
writeCHeader(argv[a + 1], cName, &blob, argv[a]);
} else if (!toolWriteFile(argv[a + 1], blob.data, blob.len)) {
toolFail("cannot write %s", argv[a + 1]);
}
fprintf(stderr, "%s: %s: %u bytes\n", TOOL_NAME, argv[a + 1], (unsigned)blob.len);
toolBufFree(&blob);
free(lines);
free(data);
return 0;
}