TuneC: a written tune becomes the bytes the player reads

The compiler, and the last thing the ladder was waiting for. A tune names
its instruments, writes sequences of notes and durations, and gives each
voice an order list of sequence names - which is where repetition comes
from, since a phrase played four times is written once and named four
times.

  #Tick 0d125000
  #Patch Oboe oboe.patch
  #Voice 0d0 Oboe
  #Sequence Verse
    0d64 0d4  0d67 0d4  0d72 0d8
  #Order 0d0
    Verse Verse Ending

"#" is a directive and ";" is a comment, exactly as in SplitBit assembly
and in the shell's scripts, and numbers are written the way the assembler
writes them. One rule across the machine rather than a third dialect -
and the rule earned itself immediately: the first tune I wrote said
"#Voice 0" and was refused, correctly, for a bare number.

WHAT IT REFUSES IS EVERYTHING THE PLAYER CANNOT NOTICE. The machine has
no names, so it cannot say a sequence does not exist. It has no lengths,
so it cannot say the voices will come apart four bars after the mistake.
A duration of nought is counted down to 255 and held, which sounds like a
hang rather than an error. And by the time a tune is loaded, "no starting
instrument" and "instrument nought" are the same byte - so the user's
ruling, that a voice with a part and no instrument is an error, can only
be kept here.

SoundPatch gains --blob, writing the same table as raw bytes. It stays
the only thing that reads soundThing's JSON: a second program parsing
that format is a second opinion about what a patch means, and the seam
between two opinions is where the LFO bug lived for a fortnight. Patches
are found beside the tune and then on a -I path, the way an include is.

THE TEST IS THAT TWO IMPLEMENTATIONS AGREE. maketune.py lays the fixture
out by hand and TuneC compiles a written source, and the suite checks
they match byte for byte - the discipline SplitDisk and sbfs.asm are held
to, for the same reason: either alone is only self-consistent. The
fixture predates the compiler, so this is also TuneC checked against
something written before it existed. Four more checks cover the four
refusals.

Also: the SoundPatch binary was tracked, alone among the six tools, and
.gitignore lists every other one. Untracked, and TuneC added beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-09-05 21:11:36 -04:00
co-authored by Claude Opus 5
parent bfc46d982e
commit 2808688fa1
10 changed files with 713 additions and 12 deletions
+513
View File
@@ -0,0 +1,513 @@
// TuneC.c
// Turns a written tune into the bytes SplitBit's music player reads.
// Written by Anachronaut
//
// ---- What a tune is ----
//
// Three tables and one indirection, which is a tracker's own shape. INSTRUMENTS are patches
// designed in soundThing and converted by SoundPatch. SEQUENCES are one voice's phrase, notes
// and rests and commands. An ORDER LIST names sequences, one list a voice, and that is where
// repetition comes from: a bass line that plays under four different melodies is written once
// and named four times.
//
// ---- The syntax is SplitBit's ----
//
// `#` is a directive and `;` is a comment, exactly as in SplitBit assembly and in the shell's
// scripts. One rule across the machine rather than a third dialect, and numbers are written
// the way the assembler writes them: 0d for decimal and 0x for hexadecimal, with no bare
// numbers, so that a person reading a tune is reading something they already know.
//
// ; Four bars, and the bass is the same one under two of them.
// #Tick 0d125000 ; cycles a tick: a sixteenth note at 120 beats a minute
// #Patch Oboe oboe.patch
// #Patch Strings strings.patch
// #Voice 0 Oboe ; which instrument each voice starts on
// #Sequence Verse
// 0d64 0d4 0d67 0d4 0d72 0d8
// #Sequence Ending
// #Use Strings ; a command, which takes no time at all
// 0d72 0d16
// #Order 0
// Verse Verse Ending
//
// ---- What it checks, and why those ----
//
// Everything here is something the player cannot notice for itself. It has no names, so it
// cannot say that a sequence does not exist; it has no lengths, so it cannot say that the
// voices will come apart four bars in; and by the time a tune is loaded, "no starting patch"
// and "starting patch nought" are the same byte.
//
// A DURATION OF NOUGHT IS THE DANGEROUS ONE. The player counts a duration down and reads the
// next event when it reaches nought, so a count that starts there is decremented to 255 and
// holds for a very long time. It is the one mistake in a tune that sounds like a hang.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#define MAX_PATCHES 64
#define MAX_SEQUENCES 200 // 0xFF ends an order list, so 255 is the ceiling.
#define MAX_EVENTS 1024
#define MAX_ORDER 256
#define MAX_NAME 64
#define VOICES 4
#define HEADER_BYTES 28
#define RED "\x1B[31m"
#define RESET "\x1B[0m"
typedef struct {
char name[MAX_NAME];
unsigned char bytes[512];
int length;
int line;
} Patch;
typedef struct {
char name[MAX_NAME];
unsigned char bytes[MAX_EVENTS];
int length;
int ticks; // What it adds up to, which is what the columns are checked on.
int line;
} Sequence;
static Patch patches[MAX_PATCHES];
static int patchCount = 0;
static Sequence sequences[MAX_SEQUENCES];
static int sequenceCount = 0;
static int order[VOICES][MAX_ORDER];
static int orderLength[VOICES];
static int voiceStart[VOICES]; // -1 until said, which is how "never said" is told from nought.
static long tick = -1;
static const char *sourceName = "";
static int problems = 0;
// ---- Where a patch is looked for ----
//
// Beside the tune first, then wherever -I says, which is the assembler's rule for an
// #Include. A tune that names its instruments by bare name can then be moved about with them,
// and a build that generates its patches somewhere else can say where without the tune having
// to know a build directory exists.
#define MAX_INCLUDES 16
static const char *includes[MAX_INCLUDES];
static int includeCount = 0;
static char sourceDirectory[512] = "";
static FILE *openPatch(const char *name, char *found, size_t room) {
FILE *in = NULL;
if (sourceDirectory[0]) {
snprintf(found, room, "%s/%s", sourceDirectory, name);
if ((in = fopen(found, "rb"))) return in;
}
for (int i = 0; i < includeCount; i++) {
snprintf(found, room, "%s/%s", includes[i], name);
if ((in = fopen(found, "rb"))) return in;
}
snprintf(found, room, "%s", name);
return fopen(found, "rb");
}
static void complain(int line, const char *what, const char *detail) {
fprintf(stderr, RED "%s:%d: %s%s%s\n" RESET, sourceName, line, what,
detail ? ": " : "", detail ? detail : "");
problems++;
}
// ---- Tokens ----
//
// A token is a run of anything that is not a space. A semicolon takes the rest of its line
// with it, which is the assembler's rule and the shell's.
typedef struct { char text[MAX_NAME]; int line; } Token;
static Token tokens[16384];
static int tokenCount = 0;
static int at = 0;
static void readTokens(FILE *in) {
int c, line = 1;
while ((c = fgetc(in)) != EOF) {
if (c == '\n') { line++; continue; }
if (isspace(c)) continue;
if (c == ';') {
while ((c = fgetc(in)) != EOF && c != '\n');
line++;
continue;
}
if (tokenCount >= (int)(sizeof(tokens) / sizeof(tokens[0]))) {
fprintf(stderr, RED "%s: too many words in one tune.\n" RESET, sourceName);
exit(1);
}
Token *t = &tokens[tokenCount++];
t->line = line;
int n = 0;
while (c != EOF && !isspace(c) && c != ';') {
if (n < MAX_NAME - 1) t->text[n++] = (char)c;
c = fgetc(in);
}
t->text[n] = '\0';
if (c == ';') { ungetc(c, in); }
else if (c == '\n') { line++; }
}
}
static Token *next(void) { return at < tokenCount ? &tokens[at++] : NULL; }
static Token *peek(void) { return at < tokenCount ? &tokens[at] : NULL; }
static int isDirective(const Token *t) { return t && t->text[0] == '#'; }
// ---- Numbers, spelled the way the assembler spells them ----
//
// 0d and 0x and nothing else. A bare number is refused rather than guessed at, because this
// machine has one way of writing one and a tune is not the place to introduce a second.
static int number(const Token *t, long *out) {
if (!t) return 0;
const char *s = t->text;
if (s[0] == '0' && (s[1] == 'd' || s[1] == 'D')) {
char *end;
*out = strtol(s + 2, &end, 10);
return *end == '\0' && s[2] != '\0';
}
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
char *end;
*out = strtol(s + 2, &end, 16);
return *end == '\0' && s[2] != '\0';
}
return 0;
}
static int findPatch(const char *name) {
for (int i = 0; i < patchCount; i++) if (strcmp(patches[i].name, name) == 0) return i;
return -1;
}
static int findSequence(const char *name) {
for (int i = 0; i < sequenceCount; i++) if (strcmp(sequences[i].name, name) == 0) return i;
return -1;
}
// ---- Reading a tune ----
static void doTick(void) {
Token *t = next();
long value;
if (!number(t, &value)) {
complain(t ? t->line : 0, "#Tick wants a number of cycles", t ? t->text : "nothing");
return;
}
if (value < 1 || value > 0xFFFFFF) {
complain(t->line, "a tick has to be between 1 and 16,777,215 cycles", t->text);
return;
}
tick = value;
}
static void doPatch(void) {
Token *name = next();
Token *file = next();
if (!name || !file || isDirective(name) || isDirective(file)) {
complain(name ? name->line : 0, "#Patch wants a name and a file", NULL);
return;
}
if (patchCount >= MAX_PATCHES) {
complain(name->line, "too many patches", name->text);
return;
}
if (findPatch(name->text) >= 0) {
complain(name->line, "that patch is named twice", name->text);
return;
}
char where[1024];
FILE *in = openPatch(file->text, where, sizeof(where));
if (!in) {
complain(file->line, "cannot find that patch beside the tune or on the -I path",
file->text);
return;
}
Patch *p = &patches[patchCount];
snprintf(p->name, MAX_NAME, "%s", name->text);
p->length = (int)fread(p->bytes, 1, sizeof(p->bytes), in);
p->line = name->line;
fclose(in);
// A patch is a count and that many pairs. Anything else is a file that is not one, and
// embedding it would make a tune that writes rubbish at the sound device.
if (p->length < 1 || p->length != 1 + p->bytes[0] * 2) {
complain(file->line, "that is not a patch: SoundPatch --blob writes one", file->text);
return;
}
patchCount++;
}
static void doVoice(void) {
Token *which = next();
Token *name = next();
long v;
if (!number(which, &v) || v < 0 || v >= VOICES) {
complain(which ? which->line : 0, "#Voice wants a voice from 0d0 to 0d3",
which ? which->text : "nothing");
return;
}
if (!name || isDirective(name)) {
complain(which->line, "#Voice wants a patch to start that voice on", NULL);
return;
}
int p = findPatch(name->text);
if (p < 0) {
complain(name->line, "no patch of that name", name->text);
return;
}
voiceStart[v] = p;
}
static void doSequence(void) {
Token *name = next();
if (!name || isDirective(name)) {
complain(name ? name->line : 0, "#Sequence wants a name", NULL);
return;
}
if (sequenceCount >= MAX_SEQUENCES) {
complain(name->line, "too many sequences", name->text);
return;
}
if (findSequence(name->text) >= 0) {
complain(name->line, "that sequence is named twice", name->text);
return;
}
Sequence *q = &sequences[sequenceCount];
snprintf(q->name, MAX_NAME, "%s", name->text);
q->length = 0;
q->ticks = 0;
q->line = name->line;
for (;;) {
Token *t = peek();
if (!t) break;
if (isDirective(t)) {
// #Use is the one directive that belongs INSIDE a sequence. Anything else ends it.
if (strcmp(t->text, "#Use") != 0) break;
next();
Token *what = next();
if (!what || isDirective(what)) {
complain(t->line, "#Use wants a patch", NULL);
break;
}
int p = findPatch(what->text);
if (p < 0) {
complain(what->line, "no patch of that name", what->text);
continue;
}
q->bytes[q->length++] = 0x80;
q->bytes[q->length++] = (unsigned char)p;
continue;
}
long note, ticksFor;
if (!number(t, &note)) {
complain(t->line, "a sequence holds notes and durations", t->text);
next();
continue;
}
next();
Token *d = next();
if (!number(d, &ticksFor)) {
complain(d ? d->line : t->line, "that note has no duration after it", t->text);
break;
}
if (note < 0 || note > 127) {
complain(t->line, "a note is 0d0 for a rest, or 0d1 to 0d127", t->text);
}
// The one that sounds like a hang. The player counts a duration down and reads the
// next event at nought, so a count that starts there wraps to 255 and holds.
if (ticksFor < 1 || ticksFor > 255) {
complain(d->line, "a duration is from 0d1 to 0d255 ticks", d->text);
}
if (q->length + 2 >= MAX_EVENTS) {
complain(t->line, "that sequence is too long", q->name);
break;
}
q->bytes[q->length++] = (unsigned char)(note & 0x7F);
q->bytes[q->length++] = (unsigned char)(ticksFor & 0xFF);
q->ticks += (int)ticksFor;
}
q->bytes[q->length++] = 0xFF;
sequenceCount++;
}
static void doOrder(void) {
Token *which = next();
long v;
if (!number(which, &v) || v < 0 || v >= VOICES) {
complain(which ? which->line : 0, "#Order wants a voice from 0d0 to 0d3",
which ? which->text : "nothing");
return;
}
for (;;) {
Token *t = peek();
if (!t || isDirective(t)) break;
next();
int q = findSequence(t->text);
if (q < 0) {
complain(t->line, "no sequence of that name", t->text);
continue;
}
if (orderLength[v] >= MAX_ORDER - 1) {
complain(t->line, "that order list is too long", t->text);
break;
}
order[v][orderLength[v]++] = q;
}
}
// ---- What the player cannot check for itself ----
static void check(void) {
if (tick < 0) {
fprintf(stderr, RED "%s: no #Tick, so there is no beat to play it at.\n" RESET, sourceName);
problems++;
}
for (int v = 0; v < VOICES; v++) {
// The ruling: a voice with a part and no instrument is an error rather than a default.
// By the time a tune is loaded this cannot be noticed - "never said" and "patch
// nought" are the same byte - so it has to be noticed here or not at all.
if (orderLength[v] > 0 && voiceStart[v] < 0) {
fprintf(stderr, RED "%s: voice %d has a part and no instrument. Say #Voice 0d%d.\n"
RESET, sourceName, v, v);
problems++;
}
}
// ---- The columns have to add up ----
//
// Nothing keeps four voices together except that the sequences they play at the same
// position last the same number of ticks. Get one wrong and the parts come apart, quietly,
// some bars after the mistake - which is the hardest kind of fault to find by ear and the
// easiest kind to find here.
int longest = 0;
for (int v = 0; v < VOICES; v++) if (orderLength[v] > longest) longest = orderLength[v];
for (int i = 0; i < longest; i++) {
int want = -1, wantVoice = -1;
for (int v = 0; v < VOICES; v++) {
if (i >= orderLength[v]) continue;
Sequence *q = &sequences[order[v][i]];
if (want < 0) { want = q->ticks; wantVoice = v; continue; }
if (q->ticks != want) {
fprintf(stderr, RED "%s:%d: at position %d, %s lasts %d ticks and voice %d's %s"
" lasts %d. The voices would come apart here.\n" RESET,
sourceName, q->line, i, q->name, q->ticks, wantVoice,
sequences[order[wantVoice][i]].name, want);
problems++;
}
}
}
}
// ---- Laying it out ----
static unsigned char out[65536];
static int outLength = 0;
static void put(int byte) {
if (outLength >= (int)sizeof(out)) {
fprintf(stderr, RED "%s: that tune does not fit in 64K.\n" RESET, sourceName);
exit(1);
}
out[outLength++] = (unsigned char)byte;
}
static void putWord(int at, int value) {
out[at] = (unsigned char)((value >> 8) & 0xFF);
out[at + 1] = (unsigned char)(value & 0xFF);
}
int main(int argc, char **argv) {
while (argc > 2 && strcmp(argv[1], "-I") == 0) {
if (includeCount < MAX_INCLUDES) includes[includeCount++] = argv[2];
argv += 2;
argc -= 2;
}
if (argc < 3) {
fprintf(stderr,
"Usage: %s [-I <dir>] <tune> <output.tune>\n\n"
"Turns a written tune into the bytes SplitBit's player reads: a header, a table\n"
"of patches, a table of sequences, and an order list for each of four voices.\n\n"
"Patches come from SoundPatch --blob, which is the only thing that knows what a\n"
"soundThing patch means. They are looked for beside the tune and then on the -I\n"
"path, the way the assembler looks for an include.\n", argv[0]);
return 2;
}
sourceName = argv[1];
const char *slash = strrchr(sourceName, '/');
if (slash) {
size_t n = (size_t)(slash - sourceName);
if (n >= sizeof(sourceDirectory)) n = sizeof(sourceDirectory) - 1;
memcpy(sourceDirectory, sourceName, n);
sourceDirectory[n] = '\0';
}
FILE *in = fopen(sourceName, "r");
if (!in) { fprintf(stderr, RED "TuneC: cannot read %s\n" RESET, sourceName); return 1; }
readTokens(in);
fclose(in);
for (int v = 0; v < VOICES; v++) { voiceStart[v] = -1; orderLength[v] = 0; }
Token *t;
while ((t = next()) != NULL) {
if (!isDirective(t)) {
complain(t->line, "that is not inside anything", t->text);
continue;
}
if (strcmp(t->text, "#Tick") == 0) doTick();
else if (strcmp(t->text, "#Patch") == 0) doPatch();
else if (strcmp(t->text, "#Voice") == 0) doVoice();
else if (strcmp(t->text, "#Sequence") == 0) doSequence();
else if (strcmp(t->text, "#Order") == 0) doOrder();
else complain(t->line, "no such directive", t->text);
}
check();
if (problems) {
fprintf(stderr, RED "TuneC: %d problem%s, so nothing was written.\n" RESET,
problems, problems == 1 ? "" : "s");
return 1;
}
// The tables come first so that everything they name can be placed after them and its
// offset known as it lands.
outLength = HEADER_BYTES;
int patchTableAt = outLength; outLength += 2 * patchCount;
int sequenceTableAt = outLength; outLength += 2 * sequenceCount;
for (int i = 0; i < patchCount; i++) {
putWord(patchTableAt + 2 * i, outLength);
for (int b = 0; b < patches[i].length; b++) put(patches[i].bytes[b]);
}
for (int i = 0; i < sequenceCount; i++) {
putWord(sequenceTableAt + 2 * i, outLength);
for (int b = 0; b < sequences[i].length; b++) put(sequences[i].bytes[b]);
}
int orderAt[VOICES];
for (int v = 0; v < VOICES; v++) {
orderAt[v] = outLength;
for (int i = 0; i < orderLength[v]; i++) put(order[v][i]);
put(0xFF);
}
memcpy(out, "SBTU", 4);
out[4] = 1;
out[5] = (unsigned char)((tick >> 16) & 0xFF);
out[6] = (unsigned char)((tick >> 8) & 0xFF);
out[7] = (unsigned char)(tick & 0xFF);
out[8] = (unsigned char)patchCount;
out[9] = (unsigned char)sequenceCount;
putWord(10, patchTableAt);
putWord(12, sequenceTableAt);
for (int v = 0; v < VOICES; v++) putWord(14 + 2 * v, orderAt[v]);
// A voice with no part still needs a byte here, and nought is as good as any: the player
// loads it and never plays a note with it.
for (int v = 0; v < VOICES; v++) out[22 + v] = (unsigned char)(voiceStart[v] < 0 ? 0 : voiceStart[v]);
out[26] = 0;
out[27] = 0;
FILE *o = fopen(argv[2], "wb");
if (!o) { fprintf(stderr, RED "TuneC: cannot write %s\n" RESET, argv[2]); return 1; }
fwrite(out, 1, outLength, o);
fclose(o);
printf("Wrote %s: %d bytes, %d patch%s, %d sequence%s.\n", argv[2], outLength,
patchCount, patchCount == 1 ? "" : "es",
sequenceCount, sequenceCount == 1 ? "" : "s");
return 0;
}