Files
SplitBit-Emulator/Source/Emulator/synth.c
T
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

723 lines
29 KiB
C

// synth.c
// 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
#include "synth.h"
#include <math.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdio.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// ---- The seeds a retrigger goes back to ----
//
// Deliberately NOT keyed on the voice, unlike the seeds synthInit hands out. A retriggered
// one-shot has to sound the same whichever voice happens to be free for it, and a seed that
// varied per voice would make the same drum a different drum eight ways. Keyed on the
// oscillator, though, because osc 0 and osc 1 drawing one stream are one noise heard twice.
static uint32_t oscTriggerSeed(int o)
{
return (o == 0) ? 0x9E3779B9u : 0x7F4A7C15u;
}
static uint32_t lfoTriggerSeed(int l)
{
return 0x2545F491u + (uint32_t)l * 3266489917u;
}
static void lfoStateInit(LfoState *st, uint32_t seed)
{
st->phase = 0.0f;
st->noiseHeld = 0.0f;
st->noisePhase = 0.0f;
st->noiseState = seed;
}
void synthInit(Synth *s, float sampleRate)
{
s->sampleRate = sampleRate;
s->pitchBend = 0.0f;
s->pitchBendRange = 2.0f;
s->lastStolenVoice = 0;
s->volume = 0.8f;
for (int i = 0; i < VOICE_COUNT; i++) {
s->voices[i].freqHz = 440.0f;
s->voices[i].active = 0;
s->voices[i].midiNote = -1;
oscillatorInit(&s->voices[i].oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN);
oscillatorInit(&s->voices[i].oscillators[1], WAVE_TRIANGLE, 0.5f, 0.0f, 0.0f);
// A seed each, so that two noise oscillators sounding together are two noises rather
// than one heard twice. The constants are arbitrary and non-zero.
// Envelope 0 shapes the level, which is what it always did - the difference is that
// 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;
// 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;
envelopeInit(&s->voices[i].ampEnv,
0.005f, // attack
0.10f, // decay
0.70f, // sustain
0.50f); // release
envelopeInit(&s->voices[i].modEnv,
0.005f, // attack
0.50f, // decay
0.0f, // sustain
0.10f); // release
s->voices[i].filter.cutoff = 8000.0f;
s->voices[i].filter.resonance = 0.0f;
s->voices[i].filter.type = FILTER_LOWPASS;
s->voices[i].filter.active = 0;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
s->voices[i].filter.modRouting = MOD_SOURCE_NONE;
s->voices[i].filter.modDepth = 0.0f;
s->voices[i].filter.resModRouting = MOD_SOURCE_NONE;
s->voices[i].filter.resModDepth = 0.0f;
}
s->voices[0].oscillators[0].active = 1;
}
void synthResetPatch(Synth *s)
{
Voice *v = &s->voices[0];
// The level is shaped by envelope 0 unless a patch says otherwise, which is what it
// always was - now said out loud, so that a patch which routed it elsewhere does not
// leave the next one silent.
v->levelSource = MOD_SOURCE_AMP_ENV;
// Held up by the key unless a patch says otherwise, which is what every patch that
// exists was made against.
v->gate = VOICE_GATE;
oscillatorInit(&v->oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN);
v->oscillators[0].active = 1;
for (int m = 0; m < 3; m++) {
v->oscillators[0].modRouting[m] = MOD_SOURCE_NONE;
v->oscillators[0].modDepth[m] = 0.0f;
}
oscillatorInit(&v->oscillators[1], WAVE_TRIANGLE, 0.5f, 0.0f, 0.0f);
v->oscillators[1].active = 0;
for (int m = 0; m < 3; m++) {
v->oscillators[1].modRouting[m] = MOD_SOURCE_NONE;
v->oscillators[1].modDepth[m] = 0.0f;
}
envelopeInit(&v->ampEnv, 0.005f, 0.10f, 0.70f, 0.50f);
envelopeInit(&v->modEnv, 0.005f, 0.50f, 0.0f, 0.10f);
v->filter.cutoff = 8000.0f;
v->filter.resonance = 0.0f;
v->filter.type = FILTER_LOWPASS;
v->filter.active = 0;
v->filter.low = 0.0f;
v->filter.band = 0.0f;
v->filter.modRouting = MOD_SOURCE_NONE;
v->filter.modDepth = 0.0f;
v->filter.resModRouting = MOD_SOURCE_NONE;
v->filter.resModDepth = 0.0f;
for (int l = 0; l < LFO_COUNT; l++) {
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.
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;
}
void oscillatorInit(Oscillator *o, Waveform waveform, float dutyCycle, float detune, float gain)
{
o->phase = 0.0f;
o->waveform = waveform;
o->dutyCycle = dutyCycle;
o->detune = detune;
o->noiseHeld = 0.0f;
o->noisePhase = 0.0f;
o->noiseState = 0x9E3779B9u; // Non-zero, or xorshift stays at zero and makes silence.
o->gain = gain;
o->octave = 0;
}
// ---- Noise ----
//
// A plain 32-bit xorshift, which is all a noise source needs: it has to be the same sequence
// every run and it does not have to be a good one. A state of zero stays at zero and makes
// silence rather than noise, so every seed below is non-zero on purpose.
static float nextNoise(uint32_t *state) {
*state ^= *state << 13;
*state ^= *state >> 17;
*state ^= *state << 5;
return (float)(*state / 4294967296.0) * 2.0f - 1.0f;
}
static float getModValue(float ampEnv, float modEnv, float lfo0, float lfo1, ModSource source)
{
switch (source) {
case MOD_SOURCE_AMP_ENV: return ampEnv;
case MOD_SOURCE_MOD_ENV: return modEnv;
case MOD_SOURCE_LFO: return lfo0;
case MOD_SOURCE_LFO2: return lfo1;
default: return 0.0f;
}
}
float lfoTickState(const LFO *l, LfoState *st, float sampleRate)
{
if (!l->active) return 0.0f;
st->phase += l->rate / sampleRate;
if (st->phase >= 1.0f) st->phase -= 1.0f;
if (l->waveform == WAVE_NOISE) {
st->noisePhase += l->rate / sampleRate;
if (st->noisePhase >= 1.0f) {
st->noisePhase -= 1.0f;
st->noiseHeld = nextNoise(&st->noiseState);
}
}
return waveformSample(l->waveform, st->phase, 0.5f, st->noiseHeld);
}
// The LFO advancing its own cycle: the free-running one, the same for every voice.
float lfoTick(LFO *l, float sampleRate)
{
return lfoTickState(l, &l->run, sampleRate);
}
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate)
{
if (!f->active) return input;
if (cutoff < 20.0f) cutoff = 20.0f;
if (cutoff > sampleRate * 0.499f) cutoff = sampleRate * 0.499f;
if (resonance < 0.0f) resonance = 0.0f;
if (resonance > 0.99f) resonance = 0.99f;
// Andy Simper TPT SVF (bilinear integration - unconditionally stable)
float g = tanf((float)M_PI * cutoff / sampleRate);
float Q = 0.5f + resonance * 9.5f; // resonance 0..0.99 -> Q 0.5..10.0
float k = 1.0f / Q;
float a1 = 1.0f / (1.0f + g * (g + k));
float a2 = g * a1;
float a3 = g * a2;
// f->band = s1, f->low = s2 (integrator states)
float v3 = input - f->low;
float v1 = a1 * f->band + a2 * v3;
float v2 = f->low + a2 * f->band + a3 * v3;
f->band = 2.0f * v1 - f->band;
f->low = 2.0f * v2 - f->low;
switch (f->type) {
case FILTER_LOWPASS: return v2;
case FILTER_HIGHPASS: return input - k * v1 - v2;
case FILTER_BANDPASS: return v1;
default: return v2;
}
}
const char *filterTypeName(FilterType t)
{
switch (t) {
case FILTER_LOWPASS: return "LP";
case FILTER_HIGHPASS: return "HP";
case FILTER_BANDPASS: return "BP";
default: return "??";
}
}
// float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
// float dutyCycle, float detune, float gain)
// {
// float detuneMultiplier = powf(2.0f, o->detune / 1200.0f);
// float freq = freqHz * detuneMultiplier * bendMultiplier;
//
// // Advance phase
// o->phase += freq / sampleRate;
// if (o->phase >= 1.0f) o->phase -= 1.0f;
//
// // Clocked noise - draw a new random value once per cycle
// if (o->waveform == WAVE_NOISE) {
// o->noisePhase += freq / sampleRate;
// if (o->noisePhase >= 1.0f) {
// o->noisePhase -= 1.0f;
// o->noiseHeld = nextNoise(&o->noiseState);
// }
// }
//
// return waveformSample(o->waveform, o->phase, o->dutyCycle, o->noiseHeld) * o->gain;
// }
float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
float dutyCycle, float detune, float gain)
{
float detuneMultiplier = powf(2.0f, detune / 1200.0f);
float freq = freqHz * detuneMultiplier * bendMultiplier;
// Advance phase
o->phase += freq / sampleRate;
if (o->phase >= 1.0f) o->phase -= 1.0f;
// Clocked noise - draw a new random value once per cycle
if (o->waveform == WAVE_NOISE) {
o->noisePhase += freq / sampleRate;
if (o->noisePhase >= 1.0f) {
o->noisePhase -= 1.0f;
o->noiseHeld = nextNoise(&o->noiseState);
}
}
return waveformSample(o->waveform, o->phase, dutyCycle, o->noiseHeld) * gain;
}
float waveformSample(Waveform w, float phase, float dutyCycle, float noiseHeld)
{
switch (w) {
case WAVE_SINE:
return sinf(2.0f * (float)M_PI * phase);
case WAVE_TRIANGLE:
return (phase < 0.5f)
? ( 4.0f * phase - 1.0f)
: (-4.0f * phase + 3.0f);
case WAVE_SAW:
return 2.0f * phase - 1.0f;
case WAVE_RAMP:
return 1.0f - 2.0f * phase;
case WAVE_PULSE:
return (phase < dutyCycle) ? 1.0f : -1.0f;
case WAVE_NOISE:
return noiseHeld;
default:
return 0.0f;
}
}
const char *waveformName(Waveform w)
{
switch (w) {
case WAVE_SINE: return "Sine";
case WAVE_TRIANGLE: return "Triangle";
case WAVE_SAW: return "Saw";
case WAVE_RAMP: return "Ramp";
case WAVE_PULSE: return "Pulse";
case WAVE_NOISE: return "Noise";
default: return "???";
}
}
// Everything about a voice that a note begins rather than inherits.
//
// The envelopes are told, once, whether this note is waiting on a key - asked here rather
// than read live in the mixer so a note already sounding keeps the shape it began with.
//
// A TRIGGERED voice also starts its oscillators over. They are the larger half of why the
// same one-shot came out different every time: the envelopes restarted and the filter was
// cleared, but the oscillator phase carried on from wherever the last note left it, so a
// kick began a third of the way into its own cycle depending on what played before it. A
// gated voice is left alone, because a key being held is not a claim about phase and every
// 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 the other voices' cycles must not move.
static void voiceArm(Voice *v)
{
int oneShot = (v->gate == VOICE_TRIGGER);
v->ampEnv.oneShot = oneShot;
v->modEnv.oneShot = oneShot;
if (v->gate == VOICE_TRIGGER) {
for (int o = 0; o < OSC_COUNT; o++) {
v->oscillators[o].phase = 0.0f;
v->oscillators[o].noiseHeld = 0.0f;
v->oscillators[o].noisePhase = 0.0f;
v->oscillators[o].noiseState = oscTriggerSeed(o);
}
}
for (int l = 0; l < LFO_COUNT; l++)
if (v->lfos[l].mode == LFO_RETRIGGER)
lfoStateInit(&v->lfos[l].run, lfoTriggerSeed(l));
envelopeNoteOn(&v->ampEnv);
envelopeNoteOn(&v->modEnv);
}
void synthNoteOn(Synth *s, int midiNote)
{
float hz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f);
for (int i = 0; i < VOICE_COUNT; i++) {
if (!s->voices[i].active) {
s->voices[i].freqHz = hz;
s->voices[i].midiNote = midiNote;
s->voices[i].active = 1;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
voiceArm(&s->voices[i]);
return;
}
}
// Steal round-robin
int i = s->lastStolenVoice % VOICE_COUNT;
s->lastStolenVoice++;
s->voices[i].freqHz = hz;
s->voices[i].midiNote = midiNote;
s->voices[i].active = 1;
s->voices[i].filter.low = 0.0f;
s->voices[i].filter.band = 0.0f;
voiceArm(&s->voices[i]);
}
void synthNoteOff(Synth *s, int midiNote) {
for (int i = 0; i < VOICE_COUNT; i++) {
if (s->voices[i].active && s->voices[i].midiNote == midiNote) {
// A triggered voice plays its own length; the key coming up is not its business.
// Asked of the envelope rather than the voice, because that is what the note was
// started with and the switch may have moved since.
if (s->voices[i].ampEnv.oneShot) continue;
envelopeNoteOff(&s->voices[i].ampEnv);
envelopeNoteOff(&s->voices[i].modEnv);
}
}
}
// ---- Asked for by number, rather than allocated ----
//
// Channel two is channel two, it holds its patch between notes, and a program driving this as
// hardware can rely on both. synthNoteOn above steals a voice round-robin, which is right for
// a keyboard and wrong for anything addressing a fixed set of parts.
void synthChannelOn(Synth *s, int channel, int midiNote)
{
if (channel < 0 || channel >= VOICE_COUNT) {
return;
}
Voice *v = &s->voices[channel];
v->freqHz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f);
v->midiNote = midiNote;
v->active = 1;
// The filter's memory of the last note is not this note's business. A note beginning
// 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(v);
}
void synthChannelOff(Synth *s, int channel)
{
if (channel < 0 || channel >= VOICE_COUNT) {
return;
}
// Released rather than stopped: what happens next is the envelope's business, and a note
// that ended the instant a key came up would have no release at all.
envelopeNoteOff(&s->voices[channel].ampEnv);
envelopeNoteOff(&s->voices[channel].modEnv);
}
void synthFillBuffer(Synth *s, int16_t *out, int frames) {
const float sr = s->sampleRate;
const float bendMultiplier = powf(2.0f, (s->pitchBend * s->pitchBendRange) / 12.0f);
for (int i = 0; i < frames; i++) {
float mix = 0.0f;
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);
float mod = envelopeTick(&vv->modEnv, sr);
// A gated voice is over when envelope 0 is, which is after the key came up.
// A triggered one has no key to wait for, so it is over only when BOTH envelopes
// are - envelope 0 alone would cut a level that envelope 1 is still shaping.
int finished = (vv->ampEnv.stage == ENV_IDLE);
if (vv->ampEnv.oneShot)
finished = finished && (vv->modEnv.stage == ENV_IDLE);
if (finished) {
vv->active = 0;
continue;
}
float oscMix = 0.0f;
int activeOscs = 0;
for (int o = 0; o < OSC_COUNT; o++) {
Oscillator *osc = &vv->oscillators[o];
if (!osc->active) continue;
float dutyCycle = osc->dutyCycle +
getModValue(amp, mod, lfo0, lfo1, osc->modRouting[0]) * osc->modDepth[0];
float detune = osc->detune + (float)osc->octave * 1200.0f +
getModValue(amp, mod, lfo0, lfo1, osc->modRouting[1]) * osc->modDepth[1];
float gain = osc->gain +
getModValue(amp, mod, lfo0, lfo1, osc->modRouting[2]) * osc->modDepth[2];
if (dutyCycle < 0.05f) dutyCycle = 0.05f;
if (dutyCycle > 0.95f) dutyCycle = 0.95f;
if (gain < 0.0f) gain = 0.0f;
if (gain > OSC_MAX_GAIN) gain = OSC_MAX_GAIN;
oscMix += oscillatorTick(osc, vv->freqHz, bendMultiplier, sr, dutyCycle, detune, gain);
activeOscs++;
}
if (activeOscs > 0) oscMix /= activeOscs;
float cutoff = vv->filter.cutoff +
getModValue(amp, mod, lfo0, lfo1, vv->filter.modRouting) * vv->filter.modDepth;
float resonance = vv->filter.resonance +
getModValue(amp, mod, lfo0, lfo1, vv->filter.resModRouting) * vv->filter.resModDepth;
oscMix = filterTick(&vv->filter, oscMix, cutoff, resonance, sr);
// ---- How loud this voice is ----
//
// Envelope 0 used to be multiplied in here unconditionally, so routing it
// anywhere else meant it shaped the volume as well. Now the voice says which
// source shapes its level, and MOD_SOURCE_NONE means nothing does.
//
// Clamped at nothing, because an LFO swings either side of zero and the far side
// is not a negative volume, it is silence. Which makes an LFO here tremolo.
float level = 1.0f;
if (vv->levelSource != MOD_SOURCE_NONE) {
level = getModValue(amp, mod, lfo0, lfo1, vv->levelSource);
if (level < 0.0f) level = 0.0f;
}
mix += oscMix * level;
}
mix *= (0.2f / VOICE_COUNT) * 4.0f * s->volume;
int32_t sample = (int32_t)lrintf(mix * 32767.0f);
if (sample > 32767) sample = 32767;
if (sample < -32768) sample = -32768;
out[i] = (int16_t)sample;
}
}
void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLevel, float releaseSec)
{
e->stage = ENV_IDLE;
e->value = 0.0f;
e->attackSec = attackSec;
e->decaySec = decaySec;
e->sustainLevel = sustainLevel;
e->releaseSec = releaseSec;
e->oneShot = 0;
}
void envelopeNoteOn(Envelope *e)
{
e->value = 0.0f;
e->stage = ENV_ATTACK;
}
void envelopeNoteOff(Envelope *e)
{
// Only trigger release if we're actually playing
if (e->stage != ENV_IDLE)
e->stage = ENV_RELEASE;
}
float envelopeTick(Envelope *e, float sampleRate)
{
switch (e->stage) {
case ENV_ATTACK: {
float inc = (e->attackSec <= 0.0f) ? 1.0f : (1.0f / (e->attackSec * sampleRate));
e->value += inc;
if (e->value >= 1.0f) {
e->value = 1.0f;
e->stage = ENV_DECAY;
}
break;
}
case ENV_DECAY: {
float inc = (e->decaySec <= 0.0f) ? 1.0f : (1.0f / (e->decaySec * sampleRate));
e->value -= inc;
// Sustain is where the decay stops and waits for the key. A one-shot has no key
// to wait for, so it decays the whole way and is finished - and MUST, or a patch
// with a sustain above nothing would hold a triggered voice open forever.
float floorLevel = e->oneShot ? 0.0f : e->sustainLevel;
if (e->value <= floorLevel) {
e->value = floorLevel;
e->stage = e->oneShot ? ENV_IDLE : ENV_SUSTAIN;
}
break;
}
case ENV_SUSTAIN:
e->value = e->sustainLevel;
break;
case ENV_RELEASE: {
float inc = (e->releaseSec <= 0.0f) ? 1.0f : (1.0f / (e->releaseSec * sampleRate));
e->value -= inc;
if (e->value <= 0.0f) {
e->value = 0.0f;
e->stage = ENV_IDLE;
}
break;
}
case ENV_IDLE:
e->value = 0.0f;
break;
}
return e->value;
}
void synthSyncVoices(Synth *s)
{
for (int v = 1; v < VOICE_COUNT; v++) {
// Sync what shapes the level. Without this the voices below hold whatever synthInit
// gave them, so a patch that routes its level elsewhere is honoured by voice 0 and by
// nothing else - which sounds like it works until a second note is playing.
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;
s->voices[v].oscillators[o].dutyCycle = s->voices[0].oscillators[o].dutyCycle;
s->voices[v].oscillators[o].detune = s->voices[0].oscillators[o].detune;
s->voices[v].oscillators[o].gain = s->voices[0].oscillators[o].gain;
s->voices[v].oscillators[o].active = s->voices[0].oscillators[o].active;
s->voices[v].oscillators[o].octave = s->voices[0].oscillators[o].octave;
s->voices[v].oscillators[o].modRouting[0] = s->voices[0].oscillators[o].modRouting[0];
s->voices[v].oscillators[o].modRouting[1] = s->voices[0].oscillators[o].modRouting[1];
s->voices[v].oscillators[o].modRouting[2] = s->voices[0].oscillators[o].modRouting[2];
s->voices[v].oscillators[o].modDepth[0] = s->voices[0].oscillators[o].modDepth[0];
s->voices[v].oscillators[o].modDepth[1] = s->voices[0].oscillators[o].modDepth[1];
s->voices[v].oscillators[o].modDepth[2] = s->voices[0].oscillators[o].modDepth[2];
}
// Sync filter params but not state (low/band are per-voice)
s->voices[v].filter.cutoff = s->voices[0].filter.cutoff;
s->voices[v].filter.resonance = s->voices[0].filter.resonance;
s->voices[v].filter.type = s->voices[0].filter.type;
s->voices[v].filter.active = s->voices[0].filter.active;
s->voices[v].filter.modRouting = s->voices[0].filter.modRouting;
s->voices[v].filter.modDepth = s->voices[0].filter.modDepth;
s->voices[v].filter.resModRouting = s->voices[0].filter.resModRouting;
s->voices[v].filter.resModDepth = s->voices[0].filter.resModDepth;
// Sync envelope settings but NOT runtime state
// Each voice needs its own stage, value - just copy the parameters
s->voices[v].ampEnv.attackSec = s->voices[0].ampEnv.attackSec;
s->voices[v].ampEnv.decaySec = s->voices[0].ampEnv.decaySec;
s->voices[v].ampEnv.sustainLevel = s->voices[0].ampEnv.sustainLevel;
s->voices[v].ampEnv.releaseSec = s->voices[0].ampEnv.releaseSec;
// Sync the mod envelope, too.
s->voices[v].modEnv.attackSec = s->voices[0].modEnv.attackSec;
s->voices[v].modEnv.decaySec = s->voices[0].modEnv.decaySec;
s->voices[v].modEnv.sustainLevel = s->voices[0].modEnv.sustainLevel;
s->voices[v].modEnv.releaseSec = s->voices[0].modEnv.releaseSec;
}
}