// 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 #include #include #include #include #include #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 "); } 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 \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 \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; }