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.
255 lines
10 KiB
C
255 lines
10 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 once each way.
|
|
//
|
|
// ---- What was changed ----
|
|
//
|
|
// Nothing. This is soundThing's engine at b73e5c0, 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.
|
|
//
|
|
// 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; // the free-running cycle, read by every voice in LFO_FREE
|
|
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;
|
|
// 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];
|
|
} 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
|
|
LFO lfos[LFO_COUNT];
|
|
} 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
|