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.
This commit is contained in:
Anachronaut
2026-09-04 16:02:40 -04:00
parent a8b6b09a59
commit b2ff8d64e5
22 changed files with 532 additions and 107 deletions
+150 -56
View File
@@ -10,25 +10,22 @@
//
// It is copied rather than submoduled. Two files against tying this build to another
// repository's history is not a close call, and what a copy costs is that changes have to be
// carried across on purpose. So the list below is the whole of the difference, kept current.
// carried across on purpose - in BOTH directions, which has now happened once each way.
//
// ---- What was changed ----
//
// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to
// say otherwise, so routing it to a filter or an oscillator meant it shaped the volume as
// well whether that was wanted or not - which is most of the trouble with making
// percussion. Every other destination in this synth chooses its source; now this one does
// too, and MOD_SOURCE_NONE means the level is simply full.
// Nothing. This is soundThing's engine at b73e5c0, character for character, except that
// em-dashes and arrows in its comments are written as ASCII here because this tree is ASCII
// only. That rule is local and is not an improvement, so it was not sent upstream.
//
// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared
// with the whole process and varies between libraries - so the same program would sound
// different on different machines and every recorded result would be worthless. It is a
// generator inside the Synth now, and a machine that starts the same way sounds the same
// way.
// It did not start that way. Three changes were made here first - a routed voice level, a
// seeded noise generator, and channels asked for by number - and all three went up. What came
// back was those three plus what they made possible: a voice that can end itself rather than
// waiting for a key, and a triggered voice that re-arms its oscillators so a one-shot is the
// same one-shot twice. A game is nearly all one-shots, which is why the traffic went that way.
//
// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals
// round-robin, which is what a keyboard wants. A hardware channel is asked for by number.
// The old calls are still here and still do what they did.
// So the thing to keep current is no longer a list. It is this: if either copy changes, the
// other one has to be told.
//
// Written by Anachronaut
@@ -42,6 +39,30 @@
#define M_PI 3.14159265358979323846
#endif
// ---- The seeds a retrigger goes back to ----
//
// Deliberately NOT keyed on the voice, unlike the seeds synthInit hands out. A retriggered
// one-shot has to sound the same whichever voice happens to be free for it, and a seed that
// varied per voice would make the same drum a different drum eight ways. Keyed on the
// oscillator, though, because osc 0 and osc 1 drawing one stream are one noise heard twice.
static uint32_t oscTriggerSeed(int o)
{
return (o == 0) ? 0x9E3779B9u : 0x7F4A7C15u;
}
static uint32_t lfoTriggerSeed(int l)
{
return 0x2545F491u + (uint32_t)l * 3266489917u;
}
static void lfoStateInit(LfoState *st, uint32_t seed)
{
st->phase = 0.0f;
st->noiseHeld = 0.0f;
st->noisePhase = 0.0f;
st->noiseState = seed;
}
void synthInit(Synth *s, float sampleRate)
{
s->sampleRate = sampleRate;
@@ -58,14 +79,16 @@ void synthInit(Synth *s, float sampleRate)
oscillatorInit(&s->voices[i].oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN);
oscillatorInit(&s->voices[i].oscillators[1], WAVE_TRIANGLE, 0.5f, 0.0f, 0.0f);
// A seed each, so that two noise oscillators sounding together are two noises rather
// than one heard twice. Any spread will do as long as none of them is zero.
// than one heard twice. The constants are arbitrary and non-zero.
// Envelope 0 shapes the level, which is what it always did - the difference is that
// it is now said rather than assumed, and can be said differently.
s->voices[i].levelSource = MOD_SOURCE_AMP_ENV;
s->voices[i].gate = VOICE_GATE;
for (int l = 0; l < LFO_COUNT; l++)
lfoStateInit(&s->voices[i].lfoRun[l], lfoTriggerSeed(l));
s->voices[i].oscillators[0].noiseState = 0x9E3779B9u + (uint32_t)i * 2654435761u;
s->voices[i].oscillators[1].noiseState = 0x7F4A7C15u + (uint32_t)i * 2246822519u;
// Envelope 0 shapes the level, which is what it always did - the difference is that
// this now says so, and can be told not to.
s->voices[i].levelSource = MOD_SOURCE_AMP_ENV;
envelopeInit(&s->voices[i].ampEnv,
0.005f, // attack
0.10f, // decay
@@ -92,13 +115,13 @@ void synthInit(Synth *s, float sampleRate)
s->voices[0].oscillators[0].active = 1;
for (int l = 0; l < LFO_COUNT; l++) {
s->lfos[l].phase = 0.0f;
lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l));
s->lfos[l].rate = 1.0f;
s->lfos[l].waveform = WAVE_SINE;
s->lfos[l].active = 0;
s->lfos[l].noiseHeld = 0.0f;
s->lfos[l].noisePhase = 0.0f;
s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->lfos[l].mode = LFO_FREE;
}
}
@@ -107,9 +130,14 @@ void synthResetPatch(Synth *s)
Voice *v = &s->voices[0];
// The level is shaped by envelope 0 unless a patch says otherwise, which is what it
// always was - the difference is only that it can now be said otherwise.
// always was - now said out loud, so that a patch which routed it elsewhere does not
// leave the next one silent.
v->levelSource = MOD_SOURCE_AMP_ENV;
// Held up by the key unless a patch says otherwise, which is what every patch that
// exists was made against.
v->gate = VOICE_GATE;
oscillatorInit(&v->oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN);
v->oscillators[0].active = 1;
for (int m = 0; m < 3; m++) {
@@ -139,13 +167,13 @@ void synthResetPatch(Synth *s)
v->filter.resModDepth = 0.0f;
for (int l = 0; l < LFO_COUNT; l++) {
s->lfos[l].phase = 0.0f;
lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l));
s->lfos[l].rate = 1.0f;
s->lfos[l].waveform = WAVE_SINE;
s->lfos[l].active = 0;
s->lfos[l].noiseHeld = 0.0f;
s->lfos[l].noisePhase = 0.0f;
s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->lfos[l].mode = LFO_FREE;
}
s->volume = 0.8f;
@@ -191,19 +219,25 @@ static float getModValue(float ampEnv, float modEnv, float lfo0, float lfo1, Mod
}
}
float lfoTick(LFO *l, float sampleRate)
float lfoTickState(const LFO *l, LfoState *st, float sampleRate)
{
if (!l->active) return 0.0f;
l->phase += l->rate / sampleRate;
if (l->phase >= 1.0f) l->phase -= 1.0f;
st->phase += l->rate / sampleRate;
if (st->phase >= 1.0f) st->phase -= 1.0f;
if (l->waveform == WAVE_NOISE) {
l->noisePhase += l->rate / sampleRate;
if (l->noisePhase >= 1.0f) {
l->noisePhase -= 1.0f;
l->noiseHeld = nextNoise(&l->noiseState);
st->noisePhase += l->rate / sampleRate;
if (st->noisePhase >= 1.0f) {
st->noisePhase -= 1.0f;
st->noiseHeld = nextNoise(&st->noiseState);
}
}
return waveformSample(l->waveform, l->phase, 0.5f, l->noiseHeld);
return waveformSample(l->waveform, st->phase, 0.5f, st->noiseHeld);
}
// The LFO advancing its own cycle: the free-running one, the same for every voice.
float lfoTick(LFO *l, float sampleRate)
{
return lfoTickState(l, &l->run, sampleRate);
}
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate)
@@ -330,6 +364,43 @@ const char *waveformName(Waveform w)
}
}
// Everything about a voice that a note begins rather than inherits.
//
// The envelopes are told, once, whether this note is waiting on a key - asked here rather
// than read live in the mixer so a note already sounding keeps the shape it began with.
//
// A TRIGGERED voice also starts its oscillators over. They are the larger half of why the
// same one-shot came out different every time: the envelopes restarted and the filter was
// cleared, but the oscillator phase carried on from wherever the last note left it, so a
// kick began a third of the way into its own cycle depending on what played before it. A
// gated voice is left alone, because a key being held is not a claim about phase and every
// patch that exists was made against the old behaviour.
//
// Retriggered LFOs restart for THIS voice only, whatever the gate - an LFO starting fresh
// per note is wanted under held notes too, and it is the shared cycle that must not move.
static void voiceArm(Synth *s, Voice *v)
{
int oneShot = (v->gate == VOICE_TRIGGER);
v->ampEnv.oneShot = oneShot;
v->modEnv.oneShot = oneShot;
if (v->gate == VOICE_TRIGGER) {
for (int o = 0; o < OSC_COUNT; o++) {
v->oscillators[o].phase = 0.0f;
v->oscillators[o].noiseHeld = 0.0f;
v->oscillators[o].noisePhase = 0.0f;
v->oscillators[o].noiseState = oscTriggerSeed(o);
}
}
for (int l = 0; l < LFO_COUNT; l++)
if (s->lfos[l].mode == LFO_RETRIGGER)
lfoStateInit(&v->lfoRun[l], lfoTriggerSeed(l));
envelopeNoteOn(&v->ampEnv);
envelopeNoteOn(&v->modEnv);
}
void synthNoteOn(Synth *s, int midiNote)
{
float hz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f);
@@ -341,8 +412,7 @@ void synthNoteOn(Synth *s, int midiNote)
s->voices[i].active = 1;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
envelopeNoteOn(&s->voices[i].ampEnv);
envelopeNoteOn(&s->voices[i].modEnv);
voiceArm(s, &s->voices[i]);
return;
}
}
@@ -355,29 +425,28 @@ void synthNoteOn(Synth *s, int midiNote)
s->voices[i].active = 1;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
envelopeNoteOn(&s->voices[i].ampEnv);
envelopeNoteOn(&s->voices[i].modEnv);
voiceArm(s, &s->voices[i]);
}
void synthNoteOff(Synth *s, int midiNote) {
for (int i = 0; i < VOICE_COUNT; i++) {
if (s->voices[i].active && s->voices[i].midiNote == midiNote) {
// A triggered voice plays its own length; the key coming up is not its business.
// Asked of the envelope rather than the voice, because that is what the note was
// started with and the switch may have moved since.
if (s->voices[i].ampEnv.oneShot) continue;
envelopeNoteOff(&s->voices[i].ampEnv);
envelopeNoteOff(&s->voices[i].modEnv);
}
}
}
// ---- A channel is the channel you asked for ----
// ---- Asked for by number, rather than allocated ----
//
// synthNoteOn hunts for a free voice and steals round-robin, which is what a keyboard wants:
// eight fingers and no say in which voice serves which. A hardware channel is not like that.
// Channel two is channel two, it holds its patch between notes, and a program driving it
// knows perfectly well what it is doing - so these say which one and nothing is stolen.
//
// The two above are left exactly as they were, because the standalone synthesizer still wants
// them and a keyboard has not stopped being a keyboard.
// Channel two is channel two, it holds its patch between notes, and a program driving this as
// hardware can rely on both. synthNoteOn above steals a voice round-robin, which is right for
// a keyboard and wrong for anything addressing a fixed set of parts.
void synthChannelOn(Synth *s, int channel, int midiNote)
{
if (channel < 0 || channel >= VOICE_COUNT) {
@@ -391,8 +460,7 @@ void synthChannelOn(Synth *s, int channel, int midiNote)
// where the last one left off is how a click gets into the front of every sound.
v->filter.low = 0.0f;
v->filter.band = 0.0f;
envelopeNoteOn(&v->ampEnv);
envelopeNoteOn(&v->modEnv);
voiceArm(s, v);
}
void synthChannelOff(Synth *s, int channel)
@@ -413,8 +481,10 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) {
for (int i = 0; i < frames; i++) {
float mix = 0.0f;
float lfo0 = lfoTick(&s->lfos[0], sr);
float lfo1 = lfoTick(&s->lfos[1], sr);
// The shared cycle advances once per sample whatever is listening, so a free LFO is
// one sweep under everything and does not stall when nothing is sounding.
float freeLfo0 = lfoTick(&s->lfos[0], sr);
float freeLfo1 = lfoTick(&s->lfos[1], sr);
for (int v = 0; v < VOICE_COUNT; v++) {
Voice *vv = &s->voices[v];
@@ -423,11 +493,24 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) {
float amp = envelopeTick(&vv->ampEnv, sr);
float mod = envelopeTick(&vv->modEnv, sr);
if (vv->ampEnv.stage == ENV_IDLE) {
// A gated voice is over when envelope 0 is, which is after the key came up.
// A triggered one has no key to wait for, so it is over only when BOTH envelopes
// are - envelope 0 alone would cut a level that envelope 1 is still shaping.
int finished = (vv->ampEnv.stage == ENV_IDLE);
if (vv->ampEnv.oneShot)
finished = finished && (vv->modEnv.stage == ENV_IDLE);
if (finished) {
vv->active = 0;
continue;
}
// A retriggered LFO reads this voice's own cycle, started when it was struck.
// A free one reads the shared cycle above, exactly as it always did.
float lfo0 = (s->lfos[0].mode == LFO_RETRIGGER)
? lfoTickState(&s->lfos[0], &vv->lfoRun[0], sr) : freeLfo0;
float lfo1 = (s->lfos[1].mode == LFO_RETRIGGER)
? lfoTickState(&s->lfos[1], &vv->lfoRun[1], sr) : freeLfo1;
float oscMix = 0.0f;
int activeOscs = 0;
for (int o = 0; o < OSC_COUNT; o++) {
@@ -491,6 +574,7 @@ void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLev
e->decaySec = decaySec;
e->sustainLevel = sustainLevel;
e->releaseSec = releaseSec;
e->oneShot = 0;
}
void envelopeNoteOn(Envelope *e)
@@ -521,9 +605,13 @@ float envelopeTick(Envelope *e, float sampleRate)
case ENV_DECAY: {
float inc = (e->decaySec <= 0.0f) ? 1.0f : (1.0f / (e->decaySec * sampleRate));
e->value -= inc;
if (e->value <= e->sustainLevel) {
e->value = e->sustainLevel;
e->stage = ENV_SUSTAIN;
// Sustain is where the decay stops and waits for the key. A one-shot has no key
// to wait for, so it decays the whole way and is finished - and MUST, or a patch
// with a sustain above nothing would hold a triggered voice open forever.
float floorLevel = e->oneShot ? 0.0f : e->sustainLevel;
if (e->value <= floorLevel) {
e->value = floorLevel;
e->stage = e->oneShot ? ENV_IDLE : ENV_SUSTAIN;
}
break;
}
@@ -549,6 +637,12 @@ float envelopeTick(Envelope *e, float sampleRate)
void synthSyncVoices(Synth *s)
{
for (int v = 1; v < VOICE_COUNT; v++) {
// Sync what shapes the level. Without this the voices below hold whatever synthInit
// gave them, so a patch that routes its level elsewhere is honoured by voice 0 and by
// nothing else - which sounds like it works until a second note is playing.
s->voices[v].levelSource = s->voices[0].levelSource;
s->voices[v].gate = s->voices[0].gate;
// Sync oscillator settings
for (int o = 0; o < OSC_COUNT; o++) {
s->voices[v].oscillators[o].waveform = s->voices[0].oscillators[o].waveform;