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.
275 lines
12 KiB
C
275 lines
12 KiB
C
// synth.h
|
|
// The Voyager's sound, vendored from soundThing.
|
|
//
|
|
// ---- Where this came from ----
|
|
//
|
|
// soundThing is a polyphonic subtractive synthesizer written by Anachronaut, and lives in its
|
|
// own repository. What is here is its VOICE ENGINE and nothing else: synth.c pulls in maths,
|
|
// stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the user
|
|
// interface, which is what made it liftable at all.
|
|
//
|
|
// 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 twice each way.
|
|
//
|
|
// ---- What was changed ----
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// Written by Anachronaut
|
|
|
|
#ifndef SYNTH_H
|
|
#define SYNTH_H
|
|
|
|
#include <stdint.h>
|
|
|
|
#define VOICE_COUNT 8
|
|
#define OSC_COUNT 2
|
|
#define LFO_COUNT 2
|
|
#define OSC_MAX_GAIN 4.0f
|
|
|
|
typedef enum {
|
|
WAVE_SINE,
|
|
WAVE_TRIANGLE,
|
|
WAVE_SAW,
|
|
WAVE_RAMP,
|
|
WAVE_PULSE,
|
|
WAVE_NOISE,
|
|
WAVE_COUNT // handy for the modulo wrap on waveform switching
|
|
} Waveform;
|
|
|
|
// Envelope structures:
|
|
typedef enum {
|
|
ENV_IDLE,
|
|
ENV_ATTACK,
|
|
ENV_DECAY,
|
|
ENV_SUSTAIN,
|
|
ENV_RELEASE
|
|
} EnvStage;
|
|
|
|
typedef enum {
|
|
MOD_SOURCE_NONE = 0,
|
|
MOD_SOURCE_AMP_ENV = 1,
|
|
MOD_SOURCE_MOD_ENV = 2,
|
|
MOD_SOURCE_LFO = 3,
|
|
MOD_SOURCE_LFO2 = 4
|
|
} ModSource;
|
|
|
|
// ---- Whether the key holds the note up ----
|
|
//
|
|
// Gated is what a keyboard wants: the sound lasts as long as the finger does, and lifting it
|
|
// starts the release. Triggered is what a drum wants - the note is struck and then plays its
|
|
// own length, and the key coming up is not its business. Sustain and release have no meaning
|
|
// in a triggered voice, because both of them are answers to a question about the key.
|
|
typedef enum {
|
|
VOICE_GATE = 0,
|
|
VOICE_TRIGGER = 1
|
|
} VoiceGate;
|
|
|
|
typedef struct {
|
|
EnvStage stage;
|
|
float value; // current output value, 0.0 to 1.0
|
|
float attackSec;
|
|
float decaySec;
|
|
float sustainLevel;
|
|
float releaseSec;
|
|
// Set from the voice's gate at note-on, not read from it live, so that flipping the
|
|
// switch under a sounding note cannot strand it half-way through a shape it was not
|
|
// started in. One-shot runs the decay to nothing and finishes there.
|
|
int oneShot;
|
|
} Envelope;
|
|
|
|
// ---- Where an LFO is in its cycle ----
|
|
//
|
|
// Split out of the LFO itself because there is now more than one answer at a time. A free
|
|
// LFO has one cycle that every voice reads, which is what makes it a single sweep across a
|
|
// chord. A retriggered one has a cycle PER VOICE, restarted when that voice is struck - and
|
|
// a voice resetting the shared one would drag every note already sounding along with it.
|
|
typedef struct {
|
|
float phase;
|
|
float noiseHeld;
|
|
float noisePhase;
|
|
// Its own noise, seeded at init. rand() is global state shared with the whole process and
|
|
// varies between C libraries, so the same patch sounded different on different machines
|
|
// and no recorded result could mean anything. One generator EACH rather than one shared,
|
|
// because two noise sources drawing from the same stream are not two noise sources.
|
|
uint32_t noiseState;
|
|
} LfoState;
|
|
|
|
// ---- Whether an LFO keeps its own time or starts when struck ----
|
|
//
|
|
// Free is one cycle running under everything, which is what vibrato across a held chord
|
|
// wants. Retriggered starts at the beginning of its shape every time a voice begins, which
|
|
// is the only way a one-shot sounds the same twice - a free LFO is wherever the wall clock
|
|
// left it, so the same drum caught at a different moment is a different drum.
|
|
typedef enum {
|
|
LFO_FREE = 0,
|
|
LFO_RETRIGGER = 1
|
|
} LfoMode;
|
|
|
|
typedef struct {
|
|
LfoState run; // this LFO's own cycle, free-running or reset at note on
|
|
float rate; // Hz
|
|
Waveform waveform;
|
|
int active;
|
|
LfoMode mode;
|
|
} LFO;
|
|
|
|
typedef enum {
|
|
FILTER_LOWPASS,
|
|
FILTER_HIGHPASS,
|
|
FILTER_BANDPASS,
|
|
FILTER_COUNT
|
|
} FilterType;
|
|
|
|
typedef struct {
|
|
float cutoff; // Hz
|
|
float resonance; // 0.0 (flat) to 0.99 (near self-oscillation)
|
|
FilterType type;
|
|
int active;
|
|
float low, band; // TPT integrator states s1, s2
|
|
int modRouting;
|
|
float modDepth; // Hz
|
|
int resModRouting;
|
|
float resModDepth; // resonance units (-0.99..0.99)
|
|
} Filter;
|
|
|
|
typedef struct {
|
|
float phase;
|
|
float dutyCycle;
|
|
Waveform waveform;
|
|
float detune; // cents, 0 = no detune
|
|
float noiseHeld; // last drawn random value for clocked noise
|
|
float noisePhase; // tracks when to draw a new noise value
|
|
// Its own noise, seeded at init. rand() is global state shared with the whole process and
|
|
// varies between C libraries, so the same patch sounded different on different machines
|
|
// and no recorded result could mean anything. One generator EACH rather than one shared,
|
|
// because two noise sources drawing from the same stream are not two noise sources.
|
|
uint32_t noiseState;
|
|
float gain;
|
|
int active; // whether this oscillator contributes to output
|
|
int octave; // transposition in octaves, -2 to +2
|
|
// Modulation routing: one source per destination by design.
|
|
// Each parameter (pwm, detune, gain) has exactly one mod source and one depth.
|
|
int modRouting[3]; // 0=off, 1=env0, 2=env1, 3=lfo0, 4=lfo1
|
|
float modDepth[3]; // Depth of modulation parameter
|
|
} Oscillator;
|
|
|
|
typedef struct {
|
|
Oscillator oscillators[OSC_COUNT];
|
|
float freqHz;
|
|
int active;
|
|
int midiNote;
|
|
Envelope ampEnv;
|
|
Envelope modEnv;
|
|
Filter filter;
|
|
// ---- What shapes how loud this voice is ----
|
|
//
|
|
// Envelope 0 used to, always, with no way to say otherwise - so routing it to a filter or
|
|
// an oscillator meant it shaped the volume too, whether that was wanted or not. Every
|
|
// other destination here names its source; this one does now as well.
|
|
//
|
|
// NOT the base-and-depth pair the others use, because a level is not a deviation from a
|
|
// resting value - it is a shape from nothing to full, and multiplying is what an amplitude
|
|
// envelope does. So this names a source outright, and MOD_SOURCE_NONE means the voice is
|
|
// simply at full and whatever env0 is doing is somebody else's business.
|
|
//
|
|
// It also makes two things possible that were not: envelope 1 shaping the volume, and an
|
|
// LFO doing it, which is tremolo.
|
|
ModSource levelSource;
|
|
// ---- Whether the key holds this voice up ----
|
|
//
|
|
// The other half of what envelope 0 used to decide on its own. Naming the level's source
|
|
// 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;
|
|
// ---- 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 {
|
|
float sampleRate;
|
|
float pitchBend;
|
|
float pitchBendRange;
|
|
int lastStolenVoice;
|
|
Voice voices[VOICE_COUNT];
|
|
float volume; // 0.0 to 1.0, master output level
|
|
} Synth;
|
|
|
|
// Envelope functions:
|
|
void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLevel, float releaseSec);
|
|
void envelopeNoteOn(Envelope *e);
|
|
void envelopeNoteOff(Envelope *e);
|
|
float envelopeTick(Envelope *e, float sampleRate);
|
|
|
|
// Oscillator functions:
|
|
void oscillatorInit(Oscillator *o, Waveform waveform, float dutyCycle, float detune, float gain);
|
|
float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
|
|
float dutyCycle, float detune, float gain);
|
|
|
|
// Synth functions:
|
|
void synthInit(Synth *s, float sampleRate);
|
|
void synthResetPatch(Synth *s);
|
|
void synthNoteOn(Synth *s, int midiNote);
|
|
void synthNoteOff(Synth *s, int midiNote);
|
|
|
|
// ---- A channel asked for by number ----
|
|
//
|
|
// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants:
|
|
// a player presses keys and does not care which voice sounds them. Something driving this as
|
|
// HARDWARE does care - channel two is channel two, it keeps its patch between notes, and
|
|
// nothing may take it away. Both ways of asking are here and neither changes the other.
|
|
void synthChannelOn(Synth *s, int channel, int midiNote);
|
|
void synthChannelOff(Synth *s, int channel);
|
|
void synthFillBuffer(Synth *s, int16_t *out, int frames);
|
|
void synthSyncVoices(Synth *s);
|
|
|
|
// LFO functions:
|
|
float lfoTick(LFO *l, float sampleRate);
|
|
// The same advance against a cycle that is not the LFO's own, so a voice can run its own.
|
|
float lfoTickState(const LFO *l, LfoState *st, float sampleRate);
|
|
|
|
// Filter functions:
|
|
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate);
|
|
const char *filterTypeName(FilterType t);
|
|
|
|
// Waveform functions:
|
|
float waveformSample(Waveform w, float phase, float dutyCycle, float noiseHeld);
|
|
const char *waveformName(Waveform w);
|
|
|
|
#endif
|