The bang was guessed at directly in bytes and came out as a low gurgle, which is what guessing at bytes gets you: a cutoff of 40 looks small and is 57 Hz, so the filter sweep ended almost shut. Voyager's sound device is soundThing's voice engine with the editor taken off, so a patch designed in soundThing - where there is a screen, a keyboard and a pair of ears - makes the same sound here. What differs is only how it arrives. SoundPatch converts one into the other. Every parameter the device takes is a documented function of a natural value and all of them invert: times are squared into four seconds, cutoff and rate are exponential, depths and detune are centred on 128. It writes a table of a count and that many parameter and value pairs, and playPatch hands it to the device - so every sound after this costs a table and a call rather than forty lines of its own. Two things the conversion has to say out loud. soundThing has no field for the level routing, because there it is always the amplitude envelope, so the table says so explicitly - a channel keeps its patch between notes and a leftover from a previous one would otherwise be carried in. And a field the tool does not recognise STOPS it: a patch format that has moved on would otherwise produce a table that quietly means something else. THE BUILD DOES NOT DEPEND ON IT. soundThing lives in its own repository and is not needed to build anything here; the tables are checked in and the tool is for when a sound is being changed. Lander's crash now plays Kick808 as a stand-in until a bang is designed for it, and the difference is the point: the hand-guessed patch wandered between 1600 and 8200 for eight tenths of a second, and this decays 3492, 2743, 2037, 1515, 1040, 614, 87, nothing. The docs check caught the tool count in two manuals, which is what it is for.
227 lines
11 KiB
C
227 lines
11 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" },
|
|
{ "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" },
|
|
};
|
|
#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;
|
|
}
|
|
|
|
int count = 0;
|
|
for (int i = 0; i < MAPPING_COUNT; i++) if (seen[i]) count++;
|
|
// The level routing is soundThing's one blind spot: it has no field for it because there
|
|
// it is always the amplitude envelope. Said out loud here so a channel that was left set
|
|
// some other way by a previous patch does not carry it into this one.
|
|
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);
|
|
fprintf(out, " 0x50 0x01 ; the level is the amplitude envelope\n");
|
|
for (int i = 0; i < MAPPING_COUNT; i++) {
|
|
if (!seen[i]) continue;
|
|
fprintf(out, " 0x%02X 0d%-3d ; %s\n",
|
|
mappings[i].parameter, values[i], mappings[i].note);
|
|
}
|
|
if (out != stdout) fclose(out);
|
|
return 0;
|
|
}
|