4 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 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
AnachronautandClaude Opus 5 b0d06aa6e5 Make the vendored synth plain ASCII
soundThing's comments use em dashes and an arrow, and this repository is
plain ASCII throughout because the tooling around it does not do Unicode.
Tests/docs.sh caught it the first time it ran against the new file, which
is what that check is for.

Comments only; nothing the compiler sees has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:01 -04:00
AnachronautandClaude Opus 5 f58b0f93af Vendor the voice engine, with the level it always had and could not say
soundThing's synth.c and synth.h, and nothing else of it: the voice engine pulls in maths,
stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the interface, which
is what made it liftable. Copied rather than submoduled - two files against tying this build
to another repository's history is not a close call - so the header carries the whole of the
difference and is meant to stay current.

A VOICE'S LEVEL IS A ROUTING NOW. Envelope 0 was multiplied into the output unconditionally,
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. There was no flag to turn
that off and there never had been: Envelope is the only one of the four modulating things
without an active flag, where LFO, Filter and Oscillator all have one.

It could not take the filter's shape either. filterTick returning its input unchanged is the
right nothing-happens answer, but an envelope returning a constant would be useless as a mod
source - and being a mod source while NOT being the volume is exactly the thing wanted. So the
voice names the source that shapes its level, the way every other destination names its
source, and MOD_SOURCE_NONE means nothing shapes it. Two things fall out that were not
possible before: envelope 1 shaping the volume, and an LFO doing it, which is tremolo.

Envelope 0 still decides how long a voice lasts, because it still ticks. A voice with its
level unrouted will therefore cut rather than fade when the envelope reaches idle - fine or
wanted for percussion, a click if the oscillators are loud at that moment, and a one line
change to end on note-off instead if it turns out to matter.

NOISE COMES FROM A SEEDED GENERATOR, one to each thing that makes it. rand() is global state
shared with the whole process and varies between C libraries, so the same program sounded
different on different machines and no recorded result could have meant anything. One
generator each rather than one shared, because two noise sources drawing the same stream are
not two noise sources. Checked rather than assumed: two synths from the same start produce
identical samples, and 4,617 of 4,800 of them are non-zero, so it is noise and not silence.

AND A CHANNEL IS THE CHANNEL YOU ASKED FOR. synthNoteOn hunts for a free voice and steals
round-robin, which is what a keyboard wants and what the standalone synthesizer still does.
Channel two is channel two. Both old calls are untouched.

Nothing links it yet. It compiles clean and standalone under -std=c11 -pedantic, which is
what make strict already checks, and the 169 tests are unmoved because nothing calls it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:26:35 -04:00