An LFO belongs to its channel, not to the whole device

The two LFOs lived in the Synth, so four channels shared them and whichever patch
loaded last owned them for every voice at once. A sound with its LFO switched off
silenced the trill under a sound that was still playing - which is what made Lunar
Porter's low fuel warning intermittent: the first landing, docking or crash of a run
took its trill away, and it was right again next time the machine started.

The engine fix went upstream to soundThing and has come back. synth.c and synth.h
are re-vendored at 71e3cb2, character for character bar the ASCII transliteration,
and now carry two changes: the LFOs moved into the Voice, and synthSyncVoices
carries a free LFO's cycle down alongside its rate. That second hunk does nothing
here - it only matters to a caller that syncs voices, and this device never does,
because syncing would flatten four channels into one instrument. It is taken so the
vendored file stays identical in both trees, and it is commented as such.

Upstream also found a bug in the original patch, in patchLoad, which is soundThing's
own file and does not travel.

Downstream the LFO parameter groups 0x60 and 0x70 now read the selected channel like
every parameter beside them, so an LFO written to one channel is inaudible on the
other three. Everything else about the device is unchanged.

Lunar Porter keeps loading each patch immediately before its note, but for the
smaller reason that now applies: the bang and the latch share channel three, and a
channel used by two sounds has to be told which of them it is about to be. The
comment that said otherwise, and the manual's warning about sharing, are rewritten
as history rather than as a caveat.

Tests/sound.sh's shared-LFO check is inverted to assert the fixed behaviour, with a
third leg added: after proving another channel's patch leaves this one alone, it
switches this channel's OWN LFO off and requires the pitch to move. Without that,
both checks would pass on a device where writing an LFO did nothing at all. Routing
either group back to voice 0 is caught.

Cost, measured: four channels sounding continuously for 400 seconds of audio takes
5.0 s of wall clock against 4.59 s before, about 9% of total emulator time. Half of
that is wasted on voices that cannot sound, since VOICE_COUNT is 8 and there are
four channels; recovering it would mean diverging the vendored file, which is not
worth it at this price.
This commit is contained in:
Anachronaut
2026-09-04 22:20:26 -04:00
parent 3ca5f193e6
commit d361ea1e46
7 changed files with 184 additions and 104 deletions
+76 -38
View File
@@ -10,11 +10,11 @@
//
// 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 - in BOTH directions, which has now happened once each way.
// carried across on purpose - in BOTH directions, which has now happened twice each way.
//
// ---- What was changed ----
//
// Nothing. This is soundThing's engine at b73e5c0, character for character, except that
// Nothing. This is soundThing's engine at 71e3cb2, 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.
//
@@ -24,6 +24,13 @@
// 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.
//
// The second round trip was the LFOs, which belonged to the Synth and so were shared by every
// voice at once. That is right for one instrument played polyphonically and wrong for four
// channels playing four different things: whichever patch loaded last owned the LFO for all of
// them. They now live in the Voice. It went up from here and came back with a bug fixed and
// one hunk that does nothing here - synthSyncVoices carrying the free cycle down, which only
// matters to a caller that syncs, and this device never does.
//
// 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.
//
@@ -84,8 +91,18 @@ void synthInit(Synth *s, float sampleRate)
// 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));
// Every voice is seeded from lfoTriggerSeed(l), which depends on the LFO and NOT on
// the voice: identical seeds, identical rates and identical arithmetic on every
// sample is what keeps the free cycles of a chord in step rather than merely near.
for (int l = 0; l < LFO_COUNT; l++) {
lfoStateInit(&s->voices[i].lfos[l].run, lfoTriggerSeed(l));
s->voices[i].lfos[l].rate = 1.0f;
s->voices[i].lfos[l].waveform = WAVE_SINE;
s->voices[i].lfos[l].active = 0;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->voices[i].lfos[l].mode = LFO_FREE;
}
s->voices[i].oscillators[0].noiseState = 0x9E3779B9u + (uint32_t)i * 2654435761u;
s->voices[i].oscillators[1].noiseState = 0x7F4A7C15u + (uint32_t)i * 2246822519u;
@@ -113,16 +130,6 @@ void synthInit(Synth *s, float sampleRate)
s->voices[i].filter.resModDepth = 0.0f;
}
s->voices[0].oscillators[0].active = 1;
for (int l = 0; l < LFO_COUNT; l++) {
lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l));
s->lfos[l].rate = 1.0f;
s->lfos[l].waveform = WAVE_SINE;
s->lfos[l].active = 0;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->lfos[l].mode = LFO_FREE;
}
}
void synthResetPatch(Synth *s)
@@ -167,15 +174,23 @@ void synthResetPatch(Synth *s)
v->filter.resModDepth = 0.0f;
for (int l = 0; l < LFO_COUNT; l++) {
lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l));
s->lfos[l].rate = 1.0f;
s->lfos[l].waveform = WAVE_SINE;
s->lfos[l].active = 0;
v->lfos[l].rate = 1.0f;
v->lfos[l].waveform = WAVE_SINE;
v->lfos[l].active = 0;
// Free unless a patch says otherwise, which is what every patch that exists was
// made against.
s->lfos[l].mode = LFO_FREE;
v->lfos[l].mode = LFO_FREE;
}
// The rest of this function sets voice 0 and lets synthSyncVoices carry it down. The
// cycles are the exception, because sync deliberately does not touch them: restarting
// only voice 0's would leave it a fraction of a turn from every other voice, and a free
// LFO under a chord would stop being one sweep. So they all restart together, which is
// what the single cycle this replaced did.
for (int i = 0; i < VOICE_COUNT; i++)
for (int l = 0; l < LFO_COUNT; l++)
lfoStateInit(&s->voices[i].lfos[l].run, lfoTriggerSeed(l));
s->volume = 0.8f;
s->pitchBendRange = 2.0f;
}
@@ -377,8 +392,8 @@ const char *waveformName(Waveform w)
// 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)
// per note is wanted under held notes too, and the other voices' cycles must not move.
static void voiceArm(Voice *v)
{
int oneShot = (v->gate == VOICE_TRIGGER);
v->ampEnv.oneShot = oneShot;
@@ -394,8 +409,8 @@ static void voiceArm(Synth *s, Voice *v)
}
for (int l = 0; l < LFO_COUNT; l++)
if (s->lfos[l].mode == LFO_RETRIGGER)
lfoStateInit(&v->lfoRun[l], lfoTriggerSeed(l));
if (v->lfos[l].mode == LFO_RETRIGGER)
lfoStateInit(&v->lfos[l].run, lfoTriggerSeed(l));
envelopeNoteOn(&v->ampEnv);
envelopeNoteOn(&v->modEnv);
@@ -412,7 +427,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;
voiceArm(s, &s->voices[i]);
voiceArm(&s->voices[i]);
return;
}
}
@@ -425,7 +440,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;
voiceArm(s, &s->voices[i]);
voiceArm(&s->voices[i]);
}
@@ -460,7 +475,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;
voiceArm(s, v);
voiceArm(v);
}
void synthChannelOff(Synth *s, int channel)
@@ -481,13 +496,19 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) {
for (int i = 0; i < frames; i++) {
float mix = 0.0f;
// 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];
// ---- Before the silent voices are skipped, and that is the load-bearing part ----
//
// A cycle that only advanced while its voice sounded would sit still between
// notes, and the voices of a chord would fall out of step the moment one of them
// ended. Advancing every voice's cycle on every sample is what makes a free LFO
// still one sweep under everything: identical seeds stepped by identical
// arithmetic the same number of times give identical numbers, not close ones.
float lfo0 = lfoTick(&vv->lfos[0], sr);
float lfo1 = lfoTick(&vv->lfos[1], sr);
if (!vv->active) continue;
float amp = envelopeTick(&vv->ampEnv, sr);
@@ -504,13 +525,6 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) {
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++) {
@@ -643,6 +657,30 @@ void synthSyncVoices(Synth *s)
s->voices[v].levelSource = s->voices[0].levelSource;
s->voices[v].gate = s->voices[0].gate;
// Sync the LFO settings but NOT the cycle, for the same reason as the envelopes and
// the filter below: what the patch says is shared, where the voice has got to is its
// own. Copying the cycle here would also stamp on a retriggered LFO every frame.
for (int l = 0; l < LFO_COUNT; l++) {
s->voices[v].lfos[l].rate = s->voices[0].lfos[l].rate;
s->voices[v].lfos[l].waveform = s->voices[0].lfos[l].waveform;
s->voices[v].lfos[l].active = s->voices[0].lfos[l].active;
s->voices[v].lfos[l].mode = s->voices[0].lfos[l].mode;
// ---- and, while it is free, the cycle too ----
//
// A free LFO is DEFINED as one sweep under everything, and until now that was
// true only for as long as every writer of voice 0 remembered to write the other
// seven. synthResetPatch remembered; patchLoad did not, and a chord came out of
// a patch load permanently out of phase with itself. An invariant that decays the
// first time someone forgets is better made structural: carry the cycle down and
// it cannot drift, whoever writes what.
//
// Only while free. A retriggered LFO's cycle is its voice's own business, and
// copying it here would stamp on it every frame.
if (s->voices[0].lfos[l].mode == LFO_FREE)
s->voices[v].lfos[l].run = s->voices[0].lfos[l].run;
}
// Sync oscillator settings
for (int o = 0; o < OSC_COUNT; o++) {
s->voices[v].oscillators[o].waveform = s->voices[0].oscillators[o].waveform;