// 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 #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