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
This commit is contained in:
co-authored by
Claude Opus 5
parent
33afc20abc
commit
f58b0f93af
@@ -0,0 +1,590 @@
|
|||||||
|
// 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. So the list below is the whole of the difference, kept current.
|
||||||
|
//
|
||||||
|
// ---- What was changed ----
|
||||||
|
//
|
||||||
|
// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to
|
||||||
|
// say otherwise, 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. Every other destination in this synth chooses its source; now this one does
|
||||||
|
// too, and MOD_SOURCE_NONE means the level is simply full.
|
||||||
|
//
|
||||||
|
// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared
|
||||||
|
// with the whole process and varies between libraries - so the same program would sound
|
||||||
|
// different on different machines and every recorded result would be worthless. It is a
|
||||||
|
// generator inside the Synth now, and a machine that starts the same way sounds the same
|
||||||
|
// way.
|
||||||
|
//
|
||||||
|
// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals
|
||||||
|
// round-robin, which is what a keyboard wants. A hardware channel is asked for by number.
|
||||||
|
// The old calls are still here and still do what they did.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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. Any spread will do as long as none of them is zero.
|
||||||
|
s->voices[i].oscillators[0].noiseState = 0x9E3779B9u + (uint32_t)i * 2654435761u;
|
||||||
|
s->voices[i].oscillators[1].noiseState = 0x7F4A7C15u + (uint32_t)i * 2246822519u;
|
||||||
|
|
||||||
|
// Envelope 0 shapes the level, which is what it always did - the difference is that
|
||||||
|
// this now says so, and can be told not to.
|
||||||
|
s->voices[i].levelSource = MOD_SOURCE_AMP_ENV;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
for (int l = 0; l < LFO_COUNT; l++) {
|
||||||
|
s->lfos[l].phase = 0.0f;
|
||||||
|
s->lfos[l].rate = 1.0f;
|
||||||
|
s->lfos[l].waveform = WAVE_SINE;
|
||||||
|
s->lfos[l].active = 0;
|
||||||
|
s->lfos[l].noiseHeld = 0.0f;
|
||||||
|
s->lfos[l].noisePhase = 0.0f;
|
||||||
|
s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 - the difference is only that it can now be said otherwise.
|
||||||
|
v->levelSource = MOD_SOURCE_AMP_ENV;
|
||||||
|
|
||||||
|
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++) {
|
||||||
|
s->lfos[l].phase = 0.0f;
|
||||||
|
s->lfos[l].rate = 1.0f;
|
||||||
|
s->lfos[l].waveform = WAVE_SINE;
|
||||||
|
s->lfos[l].active = 0;
|
||||||
|
s->lfos[l].noiseHeld = 0.0f;
|
||||||
|
s->lfos[l].noisePhase = 0.0f;
|
||||||
|
s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 lfoTick(LFO *l, float sampleRate)
|
||||||
|
{
|
||||||
|
if (!l->active) return 0.0f;
|
||||||
|
l->phase += l->rate / sampleRate;
|
||||||
|
if (l->phase >= 1.0f) l->phase -= 1.0f;
|
||||||
|
if (l->waveform == WAVE_NOISE) {
|
||||||
|
l->noisePhase += l->rate / sampleRate;
|
||||||
|
if (l->noisePhase >= 1.0f) {
|
||||||
|
l->noisePhase -= 1.0f;
|
||||||
|
l->noiseHeld = nextNoise(&l->noiseState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return waveformSample(l->waveform, l->phase, 0.5f, l->noiseHeld);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "???";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
envelopeNoteOn(&s->voices[i].ampEnv);
|
||||||
|
envelopeNoteOn(&s->voices[i].modEnv);
|
||||||
|
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;
|
||||||
|
envelopeNoteOn(&s->voices[i].ampEnv);
|
||||||
|
envelopeNoteOn(&s->voices[i].modEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void synthNoteOff(Synth *s, int midiNote) {
|
||||||
|
for (int i = 0; i < VOICE_COUNT; i++) {
|
||||||
|
if (s->voices[i].active && s->voices[i].midiNote == midiNote) {
|
||||||
|
envelopeNoteOff(&s->voices[i].ampEnv);
|
||||||
|
envelopeNoteOff(&s->voices[i].modEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- A channel is the channel you asked for ----
|
||||||
|
//
|
||||||
|
// synthNoteOn hunts for a free voice and steals round-robin, which is what a keyboard wants:
|
||||||
|
// eight fingers and no say in which voice serves which. A hardware channel is not like that.
|
||||||
|
// Channel two is channel two, it holds its patch between notes, and a program driving it
|
||||||
|
// knows perfectly well what it is doing - so these say which one and nothing is stolen.
|
||||||
|
//
|
||||||
|
// The two above are left exactly as they were, because the standalone synthesizer still wants
|
||||||
|
// them and a keyboard has not stopped being a keyboard.
|
||||||
|
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;
|
||||||
|
envelopeNoteOn(&v->ampEnv);
|
||||||
|
envelopeNoteOn(&v->modEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
float lfo0 = lfoTick(&s->lfos[0], sr);
|
||||||
|
float lfo1 = lfoTick(&s->lfos[1], sr);
|
||||||
|
|
||||||
|
for (int v = 0; v < VOICE_COUNT; v++) {
|
||||||
|
Voice *vv = &s->voices[v];
|
||||||
|
if (!vv->active) continue;
|
||||||
|
|
||||||
|
float amp = envelopeTick(&vv->ampEnv, sr);
|
||||||
|
float mod = envelopeTick(&vv->modEnv, sr);
|
||||||
|
|
||||||
|
if (vv->ampEnv.stage == ENV_IDLE) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (e->value <= e->sustainLevel) {
|
||||||
|
e->value = e->sustainLevel;
|
||||||
|
e->stage = 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// 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. So the list below is the whole of the difference, kept current.
|
||||||
|
//
|
||||||
|
// ---- What was changed ----
|
||||||
|
//
|
||||||
|
// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to
|
||||||
|
// say otherwise, 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. Every other destination in this synth chooses its source; now this one does
|
||||||
|
// too, and MOD_SOURCE_NONE means the level is simply full.
|
||||||
|
//
|
||||||
|
// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared
|
||||||
|
// with the whole process and varies between libraries - so the same program would sound
|
||||||
|
// different on different machines and every recorded result would be worthless. It is a
|
||||||
|
// generator inside the Synth now, and a machine that starts the same way sounds the same
|
||||||
|
// way.
|
||||||
|
//
|
||||||
|
// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals
|
||||||
|
// round-robin, which is what a keyboard wants. A hardware channel is asked for by number.
|
||||||
|
// The old calls are still here and still do what they did.
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
EnvStage stage;
|
||||||
|
float value; // current output value, 0.0 to 1.0
|
||||||
|
float attackSec;
|
||||||
|
float decaySec;
|
||||||
|
float sustainLevel;
|
||||||
|
float releaseSec;
|
||||||
|
} Envelope;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
float phase;
|
||||||
|
float rate; // Hz
|
||||||
|
Waveform waveform;
|
||||||
|
int active;
|
||||||
|
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 program 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;
|
||||||
|
} 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 program 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;
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
// ---- 3. A channel is asked for by number ----
|
||||||
|
//
|
||||||
|
// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants
|
||||||
|
// and what the standalone synthesizer still does. A hardware channel is not allocated: it is
|
||||||
|
// the third one, and it is the third one every time.
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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
|
||||||
Reference in New Issue
Block a user