Files
SplitBit-Emulator/Source/Patch/SoundPatch.c
T
Anachronaut b2ff8d64e5 Fold soundThing's changes back down, and expose the two new switches
The three changes that went up came back as part of soundThing, along
with two more that they made possible. The engine here is now b73e5c0
character for character, except that em-dashes and arrows in comments are
written as ASCII because this tree is ASCII only - a local rule, not an
improvement, and not sent up.

So synth.h's "what was changed" list is gone. There is nothing to list:
what has to be kept current is only that if either copy changes, the
other one has to be told.

---- What came back ----

A VOICE CAN END ITSELF. Naming the level's source said what shapes a
voice; nothing said what ends one, so the only thing that could ever
finish one was a key coming up. A game is nearly all one-shots and not
one of them wants its length decided by how long a note was held. Exposed
as parameter 0x51: 0 gated, 1 triggered.

AND A ONE-SHOT IS THE SAME ONE-SHOT TWICE. A triggered voice re-arms its
oscillators, and an LFO can be told to start over with each voice -
parameter 3 of either LFO. Both halves are needed and the check proves
it: with the LFO left free, two triggered hits still differ.

Their note warned that whatever applies a patch to a channel has to set
these or they hold synthInit's defaults. Checked: Voyager never calls
synthSyncVoices, so their 0001 is a no-op here as they predicted, and
nothing reaches into an LFO's phase, so the struct split is safe.

---- What it is for ----

Lander's crash is a triggered voice now, so boomOff is gone. Nothing has
to remember to end a bang.

SoundPatch learnt voice_levelSource, voice_gate and lfo<N>_mode, which
the new soundThing writes - without that it would have refused every
patch saved from it, since an unknown field stops the tool on purpose. A
patch from before those fields still converts, and says in its own
comments that it predates the level routing.

Three checks, each seen to fail on its own break: a gated voice still
sounding with nothing holding it, a triggered one down to nothing with no
gate ever dropped, and two hits identical sample for sample.

One test bug worth keeping: the first version of the repeatability check
struck the second note while the first was still ringing, so what it
found and compared as "the second hit" was a point in the middle of the
first one's tail. It now looks for sound after SILENCE rather than sound
after an offset.
2026-09-04 16:02:40 -04:00

248 lines
12 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) {
if (argc < 3) {
fprintf(stderr,
"Usage: %s <patch.json> <label> [output.asm]\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", 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++;
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;
}