Compare commits

..
2 Commits
Author SHA1 Message Date
Anachronaut d361ea1e46 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.
2026-09-04 22:20:26 -04:00
Anachronaut 3ca5f193e6 The warning's trill, and why it only sometimes came out
An LFO belongs to the DEVICE and not to a channel. There are two of them
against four voices, and a patch carries LFO settings the way it carries
everything else - so whichever patch was loaded last owns both of them,
for every voice at once.

The warning's trill is a saw LFO on the pitch. The patches on channel
three, the bang and the latch, carry an LFO that is switched off, and
they load at the moment they are used. So the first landing, docking or
crash of a run took the trill away and left a plain tone, and it was
right again next time the machine started. Correct until something
unrelated plays is the worst shape a fault can have.

The same thing had already happened silently at startup: the instruments
were set up in order, so the warning's LFO settings, written last, sat on
top of the rumble's and the rumble never had its own at all.

So nothing is set up once any more. Each sound loads its patch
immediately before its note - forty-odd writes at a moment already making
a sound - and is then whatever its patch says, whatever played before it.
Measured after a landing: 1056, 660, 516 hertz, then up to 1698 and down
again. Two sweeps of the saw, which is the trill.

What it does not fix, because it cannot: two sounds overlapping still
share the LFOs, so a warning going off mid-burn re-tunes the rumble for
as long as it lasts. With two between four that is the device.

Three checks at the device level, where the trap can be stated exactly: a
routed LFO bends a pitch (184 Hz against the 262 the note asked for),
another channel's patch takes it away (272, the note itself), and saying
it again gets it back (184). Written up in the Programming Manual beside
the LFO mode, since the next program to want two sounds will meet it too.
2026-09-04 19:44:02 -04:00
17 changed files with 272 additions and 86 deletions
+35 -22
View File
@@ -80,8 +80,6 @@ start:
CALL putLander
CALL putGauge
CALL putOrbital
CALL putThrust ; The instruments, once, the way the tiles are.
CALL putAlarm
INIA 0x01
OUTA 0x02 ; Key mode.
@@ -2796,20 +2794,36 @@ runPend:
BNA runPend
RET
; ---- The thruster, which is a bang and then a rumble ----
; ---- Why every sound loads its patch at the moment it plays ----
;
; Both on their own channels because one is struck and the other is HELD: the rumble's note
; goes on when a thruster lights and its gate does not come up until every thruster is out.
putThrust:
RSTA
OUTA 0x41
SETD.0 ThrustOnPatch
CALL playPatch
INIA 0x01
OUTA 0x41
SETD.0 ThrustHoldPatch
CALL playPatch
RET
; CHANNEL THREE IS TWO INSTRUMENTS. The bang and the latch share it, and a patch is the whole
; voice, so the channel has to be told which of them it is about to be. Loading at the moment
; of the note costs fifty writes at a moment already doing more than fifty, and means a sound
; is what its patch says whatever played before it.
;
; The other three channels have one sound each and could be set up once at startup. They are
; not, because a single rule is worth more here than the writes it saves: every sound loads
; its own patch, so there is no question of which sounds are safe to set up early and which
; are not.
;
; ---- What this used to be working around ----
;
; The LFOs belonged to the DEVICE and not to a channel: two of them against four voices, with
; a patch carrying LFO settings the way it carries everything else, so whichever patch loaded
; last owned them for every channel at once.
;
; That is what made the low fuel warning sound right sometimes and not others. Its trill is a
; saw LFO on the pitch, and the patches on channel three carry an LFO that is switched OFF, so
; the first landing, docking or crash of a run took the warning's trill away and left a plain
; tone. Before any of them it was right, which is exactly the shape an intermittent fault has.
;
; Loading at the moment of the note made each sound correct AS IT STARTED, which was as far as
; a program could get on its own. It could not fix two sounds OVERLAPPING - a warning going off
; mid-burn still re-tuned the rumble's LFO for as long as it lasted.
;
; The device was fixed instead: an LFO now belongs to the channel it was written to, so four
; channels are four independent instruments in this respect as in every other. Overlapping
; sounds no longer interfere, and the paragraph above is history rather than a caveat.
; ---- Lit or not, and only the CHANGES matter ----
;
@@ -2852,6 +2866,8 @@ thrustHaveSet:
BRQ thrustNoPop ; Nothing newly lit, whatever else is still burning.
RSTA
OUTA 0x41
SETD.0 ThrustOnPatch
CALL playPatch
INIA 0d36
OUTA 0x44 ; The bang of it catching.
thrustNoPop:
@@ -2870,6 +2886,8 @@ thrustNoPop:
INIA 0x01
STA.0
OUTA 0x41
SETD.0 ThrustHoldPatch
CALL playPatch ; After the pop's, so the held sound owns the LFO while it runs.
INIA 0d60
OUTA 0x44 ; Held, until the gate comes up.
BRI thrustRemember
@@ -2965,16 +2983,11 @@ watchFuelDone:
; Effects count down from the top: the bang has three and this has two. They are separate
; channels rather than one reused, because a crash while the warning is still sounding should
; not cut it off - and on a machine with four voices there is no reason to be clever about it.
putAlarm:
INIA 0x02
OUTA 0x41
SETD.0 AlarmPatch
CALL playPatch
RET
alarm:
INIA 0x02
OUTA 0x41
SETD.0 AlarmPatch
CALL playPatch ; Its own LFO back, whatever the last sound left there.
INIA 0d72 ; An octave over middle C, which is where a warning lives.
OUTA 0x44
RET
File diff suppressed because one or more lines are too long
+12 -4
View File
@@ -237,10 +237,18 @@ static void soundParameter(uint8_t value) {
v->gate = value ? VOICE_TRIGGER : VOICE_GATE;
}
break;
// The LFOs belong to the device rather than to a channel, so whichever channel is
// selected makes no difference to these.
case SP_LFO0: setLfo(&synth.lfos[0], which, value); break;
case SP_LFO1: setLfo(&synth.lfos[1], which, value); break;
// ---- The LFOs belong to the channel ----
//
// They used to belong to the device, so whichever channel was selected made no
// difference: two channels playing two different sounds shared one pair of LFOs, and
// whichever loaded its settings last owned them for both. A sound with its LFO
// switched off silenced the trill under a sound that was still playing.
//
// Four channels are four independent things, which is the rule the rest of this
// device already followed. So these read the selected channel like every parameter
// above them, and an LFO written to one channel is inaudible on the other three.
case SP_LFO0: setLfo(&v->lfos[0], which, value); break;
case SP_LFO1: setLfo(&v->lfos[1], which, value); break;
default:
// A parameter number nothing answers to does nothing. A sound device is a poor
// place to stop the machine, the same as a screen.
+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;
+28 -8
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.
//
@@ -119,7 +126,7 @@ typedef enum {
} LfoMode;
typedef struct {
LfoState run; // the free-running cycle, read by every voice in LFO_FREE
LfoState run; // this LFO's own cycle, free-running or reset at note on
float rate; // Hz
Waveform waveform;
int active;
@@ -194,10 +201,24 @@ typedef struct {
// said what shapes the sound; this says who ends it. Without it a voice can only ever
// finish because a key came up, which is no use to a drum.
VoiceGate gate;
// One cycle per LFO per voice, used only by the LFOs set to LFO_RETRIGGER. Runtime state
// like the envelope stages and the filter's integrators, so synthSyncVoices leaves it
// alone - it is where this voice is, not what the patch says.
LfoState lfoRun[LFO_COUNT];
// ---- Its own LFOs, like its own envelopes and its own filter ----
//
// They used to belong to the Synth, which is right for ONE INSTRUMENT played polyphonically
// and wrong for anything else: a patch carries LFO settings, so loading a patch changed
// what every other voice heard. That is invisible here, because every voice is given the
// same patch - and it is the whole difficulty for anything driving these voices as separate
// parts, where two patches disagree about the rate and the last one loaded wins for both.
//
// A chord is unaffected. synthSyncVoices copies these like everything else, so every voice
// runs identical settings, and their cycles are started together in synthInit and advanced
// every sample whether the voice is sounding or not - so they do not merely stay close,
// they stay bit for bit identical, and one sweep still runs under the whole chord.
//
// Each LFO's own cycle lives inside it, like the envelope stages and the filter's
// integrators, so synthSyncVoices copies the settings and leaves the cycle alone - it is
// where this voice is, not what the patch says. LFO_RETRIGGER resets that same cycle at
// note on, which is what it always did, only now to a cycle nothing else is listening to.
LFO lfos[LFO_COUNT];
} Voice;
typedef struct {
@@ -207,7 +228,6 @@ typedef struct {
int lastStolenVoice;
Voice voices[VOICE_COUNT];
float volume; // 0.0 to 1.0, master output level
LFO lfos[LFO_COUNT];
} Synth;
// Envelope functions:
+28 -3
View File
@@ -1075,9 +1075,9 @@ Anywhere a setting asks *what modulates* something, the answer is one of these:
| 3 | LFO 0. |
| 4 | LFO 1. |
**The two LFOs belong to the device and not to a channel**, so writing 0x60 to 0x7F ignores
whichever channel is selected. That is what makes them useful: a vibrato that every voice
shares is one wobble rather than four that drift apart.
**Each channel has its own pair of LFOs**, so writing 0x60 to 0x7F sets them on whichever
channel is selected, exactly like every parameter above. Four channels are four independent
instruments, and an LFO is part of how an instrument sounds.
### What A Byte Means:
@@ -1148,6 +1148,31 @@ The consequence is worth knowing rather than fixing: a retriggered noise source
repeatable, so every hit is literally the same noise, the way a sampler is. On a hi-hat that
can read as machine-gunny. Where variation is wanted, leave that LFO or that voice free.
### They Used To Be Shared, And The Fault That Caused:
Worth knowing, because it is the shape of bug this device is built to avoid and the manual
said the opposite for a while.
The two LFOs used to belong to the **device**, so setting one from a patch meant for channel
three changed what channel nought heard, immediately and for as long as nothing set it back.
A patch naturally carries LFO settings along with everything else, so a program that loaded
its instruments once at startup ended up with whichever of them was written *last*, for all
of them.
The failure that produces is unpleasant to diagnose, because it is intermittent by nature: a
sound is correct until some unrelated thing plays, and correct again next time the machine
starts. A game here lost a warning's trill after the first landing of each run, because the
landing's patch happened to carry an LFO that was switched off.
A program could work around the first half of that by loading each patch immediately before
the note that needed it. It could not work around the second: two sounds **overlapping** still
interfered, and no amount of care in the program could separate them. So the device was
changed rather than the programs, and an LFO now belongs to the channel it was written to.
**Loading a patch immediately before its note is still good practice**, for the different
reason that a channel used by two sounds has to be told which of them it is about to be. A
patch is forty-odd writes and costs nothing at a moment already making a sound.
### Knowing When It Has Finished:
The status port's bit 0 is set while any channel is still sounding, so a routine can wait for
+1 -1
View File
@@ -25,7 +25,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -15,7 +15,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -32,7 +32,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -22,7 +22,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -22,7 +22,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -108,7 +108,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -27,7 +27,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -15,7 +15,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -13,7 +13,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+1 -1
View File
@@ -22,7 +22,7 @@ Mode.sbx 48
Flip.sbx 173
Sprite.sbx 442
Depth.sbx 672
Lander.sbx 9220
Lander.sbx 9201
Pad.sbx 264
Crash.sbx 632
vars.script 50
+82
View File
@@ -496,6 +496,88 @@ PY
&& result ok "and two hits are the same hit" "sample for sample, noise and all" \
|| result no "and two hits are the same hit" "$SAME"
# ---- An LFO belongs to its CHANNEL, and a patch carries LFO settings ----
#
# This used to be the other way round, and it was a trap with teeth: four voices shared two
# LFOs, so a patch loaded onto channel ONE re-tuned what channel NOUGHT heard. The symptom was
# the worst kind - a sound that is right until some unrelated thing plays, and right again
# next time the machine starts. In the game it was a warning whose trill vanished after the
# first landing of a run, because the landing's patch carries an LFO switched off.
#
# Three notes on channel nought, all identical in what THEY were told, and the third leg is
# what keeps this check honest. Between the first and second, a patch is dropped on channel
# ONE that switches the LFO off - which must now do nothing here. Between the second and third
# the same thing is written to channel NOUGHT itself - which must still work, or the two
# checks above would pass just as well on a device where writing an LFO does nothing at all.
{ printf '#Program\nstart:\n'; port 0x41 0x00; loud
param 0x00 1 # triangle, so the LFO's work on the pitch is plain
param 0x51 1 # triggered, so each note ends itself
param 0x20 0x00; param 0x21 40; param 0x22 0x00
param 0x08 3 # pitch follows LFO 0
param 0x09 200 # and a long way
param 0x60 0x01; param 0x61 2; param 0x62 40; param 0x63 0x01
port 0x44 60; pause one 250
port 0x41 0x01 # somebody else's instrument, which says the LFO is off
param 0x60 0x00
port 0x41 0x00
port 0x44 60; pause two 250
port 0x41 0x00 # and now its OWN LFO switched off, which must be heard
param 0x60 0x00
port 0x44 60; spinForever
printf '#Vectors\n Boot start\n'; } | run lfoshared 8000000 || exit 1
read -r FIRST SECOND THIRD <<EOT
$(python3 - "$BUILD/lfoshared.raw" <<'PY'
import struct, sys
data = open(sys.argv[1], "rb").read()
v = struct.unpack("<%dh" % (len(data) // 2), data)
# Three notes, found as sound after silence.
at, sounding, quiet = [], False, 0
for i, x in enumerate(v):
if abs(x) > 300:
if not sounding:
at.append(i); sounding = True
quiet = 0
elif sounding:
quiet += 1
if quiet > 3000: sounding = False
if len(at) < 3:
print("0 0 0"); raise SystemExit
# ---- The PITCH of each, not how far it travels inside one ----
#
# The LFO here is slower than the note is long, so within one note it barely moves - what it
# does is hold the pitch somewhere other than where the note asked for. Which is the honest
# thing to measure anyway: a retriggered LFO starts at the same phase every time, so two notes
# that agree had the same LFO and one that disagrees did not.
def hz(start):
w = v[start:start + 3000]
peak = max((abs(x) for x in w), default=0)
if not peak: return 0
gate, cross, side = peak // 10, 0, 0
for x in w:
if side <= 0 and x > gate: side, cross = 1, cross + 1
elif side >= 0 and x < -gate: side, cross = -1, cross + 1
return cross * 48000 // (2 * len(w))
print(hz(at[0]), hz(at[1]), hz(at[2]))
PY
)
EOT
# The note asks for middle C, about 262. With the LFO on it is bent well away from that.
[ "$FIRST" -gt 0 ] && [ "$SECOND" -gt 0 ] && [ "$THIRD" -gt 0 ] \
&& result ok "an LFO bends the pitch it is routed to" "$FIRST hertz against the 262 asked for" \
|| result no "an LFO bends the pitch it is routed to" "$FIRST $SECOND $THIRD"
# Switched off by a patch meant for ANOTHER CHANNEL, and nothing here changes. This is the
# whole point: the two used to share, and a sound could not protect itself from its neighbour.
[ "$FIRST" = "$SECOND" ] \
&& result ok "another channel's patch leaves it alone" "$FIRST hertz before and after" \
|| result no "another channel's patch leaves it alone" "$FIRST became $SECOND, so they are shared"
# Switched off on its OWN channel, and the note comes back unbent - so the check above is
# measuring an LFO that really is reachable, rather than one nothing can write to.
[ "$(( THIRD - FIRST ))" -gt 40 ] \
&& result ok "and its own channel's patch does take it away" "$FIRST became $THIRD, which is the note itself" \
|| result no "and its own channel's patch does take it away" "$FIRST then $THIRD"
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS sound checks passed."