Files
SplitBit-Emulator/Source/Patch/SoundPatch.c
T
AnachronautandClaude Opus 5 2808688fa1 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
2026-09-05 21:11:36 -04:00

289 lines
14 KiB
C

// SoundPatch.c
// Turns a soundThing patch into a table the sound device can be handed.
//
// ---- Why this exists ----
//
// soundThing is where a patch gets DESIGNED, because it has a screen, a keyboard and a pair
// of ears attached to it. Voyager's sound device is the same voice engine with the editor
// taken off, so the sound a patch makes is the same sound - but its settings arrive as bytes
// through a selector, and a byte is not seconds or hertz.
//
// The first sound written for a game here was guessed at in bytes: a cutoff of 40 looked
// small and is 57 Hz, so the bang came out as a low gurgle. That is what this is for. Design
// it where it can be heard, convert it, and the numbers stop being a matter of opinion.
//
// ---- What a byte means ----
//
// Every parameter the device takes is a documented function of a natural value, and all of
// them invert. Times are SQUARED into four seconds, because the difference between five and
// fifty milliseconds is the whole character of a percussive sound and the difference between
// three and four seconds is nothing anybody can hear. Cutoff and LFO rate are EXPONENTIAL,
// because pitch is logarithmic and so is where a filter sounds like it is. Depths and detune
// are CENTRED on 128, so half of nothing is no change and either side of it is a direction.
//
// Written by Anachronaut
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
// How a natural value becomes a byte. The inverse of soundParameter in sound.c, and the
// reason that file and this one have to be read together if either changes.
typedef enum {
AS_DIRECT, // already a byte: a waveform, a routing, a filter type
AS_BOOL, // nought or one
AS_RANGE, // linear from low to high
AS_SQUARED, // times, so that the short end has the resolution
AS_EXPONENTIAL, // cutoff and rate, because hearing is logarithmic
AS_SIGNED, // centred on 128, reaching `high` either way
AS_OCTAVE // a small signed number, offset by 128
} Shape;
typedef struct {
const char *field; // what soundThing calls it
unsigned char parameter; // what the device calls it
Shape shape;
double low, high;
const char *note;
} Mapping;
// ---- The whole of the correspondence ----
//
// Oscillator one is oscillator nought's parameters plus 0x10, and the modulation envelope is
// the amplitude one plus 0x10, which is why the parameter numbers are laid out the way they
// are. Written out in full anyway: a table that has to be understood before it can be read is
// worse than a long one.
static const Mapping mappings[] = {
{ "osc0_waveform", 0x00, AS_DIRECT, 0, 0, "oscillator 0, waveform" },
{ "osc0_gain", 0x01, AS_RANGE, 0, 4.0, "gain" },
{ "osc0_dutyCycle", 0x02, AS_RANGE, 0.05, 0.95, "duty" },
{ "osc0_detune", 0x03, AS_SIGNED, 0, 1200, "detune, in cents" },
{ "osc0_octave", 0x04, AS_OCTAVE, 0, 0, "octave" },
{ "osc0_active", 0x05, AS_BOOL, 0, 0, "on" },
{ "osc0_modRouting0", 0x06, AS_DIRECT, 0, 0, "what moves its width" },
{ "osc0_modDepth0", 0x07, AS_SIGNED, 0, 0.5, "and how far" },
{ "osc0_modRouting1", 0x08, AS_DIRECT, 0, 0, "what moves its pitch" },
{ "osc0_modDepth1", 0x09, AS_SIGNED, 0, 1200, "and how far" },
{ "osc0_modRouting2", 0x0A, AS_DIRECT, 0, 0, "what moves its gain" },
{ "osc0_modDepth2", 0x0B, AS_SIGNED, 0, 4.0, "and how far" },
{ "osc1_waveform", 0x10, AS_DIRECT, 0, 0, "oscillator 1, waveform" },
{ "osc1_gain", 0x11, AS_RANGE, 0, 4.0, "gain" },
{ "osc1_dutyCycle", 0x12, AS_RANGE, 0.05, 0.95, "duty" },
{ "osc1_detune", 0x13, AS_SIGNED, 0, 1200, "detune, in cents" },
{ "osc1_octave", 0x14, AS_OCTAVE, 0, 0, "octave" },
{ "osc1_active", 0x15, AS_BOOL, 0, 0, "on" },
{ "osc1_modRouting0", 0x16, AS_DIRECT, 0, 0, "what moves its width" },
{ "osc1_modDepth0", 0x17, AS_SIGNED, 0, 0.5, "and how far" },
{ "osc1_modRouting1", 0x18, AS_DIRECT, 0, 0, "what moves its pitch" },
{ "osc1_modDepth1", 0x19, AS_SIGNED, 0, 1200, "and how far" },
{ "osc1_modRouting2", 0x1A, AS_DIRECT, 0, 0, "what moves its gain" },
{ "osc1_modDepth2", 0x1B, AS_SIGNED, 0, 4.0, "and how far" },
{ "ampEnv_attack", 0x20, AS_SQUARED, 0, 4.0, "amplitude envelope, attack" },
{ "ampEnv_decay", 0x21, AS_SQUARED, 0, 4.0, "decay" },
{ "ampEnv_sustain", 0x22, AS_RANGE, 0, 1.0, "sustain" },
{ "ampEnv_release", 0x23, AS_SQUARED, 0, 4.0, "release" },
{ "modEnv_attack", 0x30, AS_SQUARED, 0, 4.0, "modulation envelope, attack" },
{ "modEnv_decay", 0x31, AS_SQUARED, 0, 4.0, "decay" },
{ "modEnv_sustain", 0x32, AS_RANGE, 0, 1.0, "sustain" },
{ "modEnv_release", 0x33, AS_SQUARED, 0, 4.0, "release" },
{ "filter_active", 0x40, AS_BOOL, 0, 0, "filter, on" },
{ "filter_type", 0x41, AS_DIRECT, 0, 0, "type: 0 low, 1 high, 2 band" },
{ "filter_cutoff", 0x42, AS_EXPONENTIAL, 20.0, 20000.0, "cutoff, in hertz" },
{ "filter_resonance", 0x43, AS_RANGE, 0, 0.99, "resonance" },
{ "filter_modRouting", 0x44, AS_DIRECT, 0, 0, "what moves the cutoff" },
{ "filter_modDepth", 0x45, AS_SIGNED, 0, 8000.0, "and how far, in hertz" },
{ "filter_resModRouting", 0x46, AS_DIRECT, 0, 0, "what moves the resonance" },
{ "filter_resModDepth", 0x47, AS_SIGNED, 0, 0.99, "and how far" },
{ "lfo0_active", 0x60, AS_BOOL, 0, 0, "LFO 0, on" },
{ "lfo0_waveform", 0x61, AS_DIRECT, 0, 0, "waveform" },
{ "lfo0_rate", 0x62, AS_EXPONENTIAL, 0.05, 20.0, "rate, in hertz" },
{ "lfo0_mode", 0x63, AS_DIRECT, 0, 0, "0 free, 1 starts with a voice" },
{ "lfo1_active", 0x70, AS_BOOL, 0, 0, "LFO 1, on" },
{ "lfo1_waveform", 0x71, AS_DIRECT, 0, 0, "waveform" },
{ "lfo1_rate", 0x72, AS_EXPONENTIAL, 0.05, 20.0, "rate, in hertz" },
{ "lfo1_mode", 0x73, AS_DIRECT, 0, 0, "0 free, 1 starts with a voice" },
// ---- The two that are about the voice as a whole ----
//
// soundThing gained a field for each of these at the same time the engine did. A patch
// written before that has neither, which is why the level source has a default below: it
// was always envelope 0 when there was nothing to say otherwise.
{ "voice_levelSource", 0x50, AS_DIRECT, 0, 0, "what shapes the level" },
{ "voice_gate", 0x51, AS_DIRECT, 0, 0, "0 gated, 1 triggered" },
};
#define MAPPING_COUNT ((int)(sizeof(mappings) / sizeof(mappings[0])))
// ---- What soundThing has and the device does not ----
//
// Named rather than ignored, so a field that is simply new shows up as unknown and a field
// that is deliberately dropped does not. Master volume is the editor's own output level and
// the device has one of its own; pitch bend has no wheel to come from.
static const char *ignored[] = { "master_volume", "master_pitchBendRange" };
#define IGNORED_COUNT ((int)(sizeof(ignored) / sizeof(ignored[0])))
static int clampByte(double raw) {
long v = lround(raw);
if (v < 0) return 0;
if (v > 255) return 255;
return (int)v;
}
static int toByte(const Mapping *m, double value) {
switch (m->shape) {
case AS_DIRECT: return clampByte(value);
case AS_BOOL: return value != 0.0 ? 1 : 0;
case AS_RANGE: return clampByte(255.0 * (value - m->low) / (m->high - m->low));
// The inverse of part*part*high, so the root comes back out.
case AS_SQUARED: return clampByte(255.0 * sqrt(value / m->high));
// And of low * (high/low)^part.
case AS_EXPONENTIAL:
if (value < m->low) value = m->low;
return clampByte(255.0 * log(value / m->low) / log(m->high / m->low));
case AS_SIGNED: return clampByte(128.0 + 128.0 * value / m->high);
case AS_OCTAVE: return clampByte(128.0 + value);
}
return 0;
}
int main(int argc, char **argv) {
// ---- Two ways out, one conversion ----
//
// Assembly for a program that includes the patch, and raw bytes for a tune that embeds
// one. THE SAME TABLE EITHER WAY: a count and that many parameter and value pairs, which
// is what the sound device is written with. What differs is only whether an assembler or
// a tune compiler is going to be the one holding it.
//
// It matters that this 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 last sound bug lived for a fortnight.
int raw = 0;
if (argc > 1 && strcmp(argv[1], "--blob") == 0) {
raw = 1;
argv++;
argc--;
}
if (argc < 3) {
fprintf(stderr,
"Usage: %s <patch.json> <label> [output.asm]\n"
" %s --blob <patch.json> <name> <output.patch>\n\n"
"Turns a soundThing patch into a table for the sound device: a count, then that\n"
"many parameter and value pairs. Hand the label to playPatch.\n\n"
"With --blob it writes those bytes and nothing else, for TuneC to embed.\n",
argv[0], argv[0]);
return 2;
}
const char *path = argv[1];
const char *label = argv[2];
FILE *in = fopen(path, "r");
if (!in) { fprintf(stderr, "SoundPatch: cannot read %s\n", path); return 1; }
int values[MAPPING_COUNT];
int seen[MAPPING_COUNT];
memset(seen, 0, sizeof(seen));
char line[512];
int unknown = 0;
while (fgets(line, sizeof(line), in)) {
char field[128];
double value;
// Every line of one of these is `"name": number`, with or without a trailing comma.
if (sscanf(line, " \"%127[^\"]\" : %lf", field, &value) != 2) continue;
int found = 0;
for (int i = 0; i < MAPPING_COUNT; i++) {
if (strcmp(field, mappings[i].field) == 0) {
values[i] = toByte(&mappings[i], value);
seen[i] = 1;
found = 1;
break;
}
}
if (found) continue;
for (int i = 0; i < IGNORED_COUNT; i++) {
if (strcmp(field, ignored[i]) == 0) { found = 1; break; }
}
if (!found) {
fprintf(stderr, "SoundPatch: %s has a field this does not know: %s\n", path, field);
unknown++;
}
}
fclose(in);
// A field this does not know is a patch format that has moved on, and writing a table
// that quietly leaves it out would make a sound nobody could account for.
if (unknown) {
fprintf(stderr, "SoundPatch: %d unknown field%s, so nothing was written.\n",
unknown, unknown == 1 ? "" : "s");
return 1;
}
// ---- A patch from before the level could be routed ----
//
// Those files have no voice_levelSource, and what it meant there was always envelope 0.
// Said out loud rather than left out, because a channel keeps its patch between notes and
// would otherwise carry a previous one's routing into this one.
int levelIndex = -1;
for (int i = 0; i < MAPPING_COUNT; i++) {
if (mappings[i].parameter == 0x50) { levelIndex = i; break; }
}
int assumedLevel = (levelIndex >= 0 && !seen[levelIndex]);
if (assumedLevel) {
values[levelIndex] = 1; // MOD_SOURCE_AMP_ENV
seen[levelIndex] = 1;
}
int count = 0;
for (int i = 0; i < MAPPING_COUNT; i++) if (seen[i]) count++;
// ---- Raw bytes, for something that is not an assembler ----
//
// A name is still asked for and still ignored here, so that the two forms are converted
// by the same command with the same arguments and a build can switch between them by
// adding a word.
if (raw) {
if (argc < 4) {
fprintf(stderr, "SoundPatch: --blob needs somewhere to write it.\n");
return 1;
}
FILE *blob = fopen(argv[3], "wb");
if (!blob) { fprintf(stderr, "SoundPatch: cannot write %s\n", argv[3]); return 1; }
fputc(count, blob);
for (int i = 0; i < MAPPING_COUNT; i++) {
if (!seen[i]) continue;
fputc(mappings[i].parameter, blob);
fputc(values[i], blob);
}
fclose(blob);
return 0;
}
FILE *out = stdout;
if (argc > 3) {
out = fopen(argv[3], "w");
if (!out) { fprintf(stderr, "SoundPatch: cannot write %s\n", argv[3]); return 1; }
}
fprintf(out, "; %s, converted from %s by SoundPatch.\n", label, path);
fprintf(out, "; Designed in soundThing, where it can be heard. Do not edit the numbers\n");
fprintf(out, "; here: change the patch and convert it again.\n\n");
// Data, and no base: a table of bytes belongs wherever the program including it has got
// to, the way every other included table does.
fprintf(out, "#Data\n\n");
fprintf(out, "%s:\n", label);
fprintf(out, " 0d%-24d; how many pairs follow\n", count);
for (int i = 0; i < MAPPING_COUNT; i++) {
if (!seen[i]) continue;
fprintf(out, " 0x%02X 0d%-3d ; %s%s\n",
mappings[i].parameter, values[i], mappings[i].note,
(i == levelIndex && assumedLevel) ? ", which this patch predates" : "");
}
if (out != stdout) fclose(out);
return 0;
}