Core additions since initial commit: - Platform-split MIDI (midi_linux.c / midi_windows.c) - Patch system: save/load/scan, subdirectory navigation, favourites - Config system: last MIDI device and CC mappings persisted to disk - Custom embedded pixel font and sprite sheet (no loose asset files at runtime) - Window icon embedded at runtime (SetWindowIcon) and in Windows .exe (windres) - chdirToExeDir() in platform.c so double-click launch finds patches/ correctly - About screen accessible from Master panel UI polish: - TITLE_BAR_H constant (20 px) — sprite buttons now fit inside title bars - All title bar text, button overlay text, and close/active labels vertically centred - Full-screen dim overlay computed from camera transform (no more partial coverage) - Patch browser: folder navigation, breadcrumb title, 256-slot limit lifted Build: - Makefile auto-discovers sources; embeds sprites/font/icon via xxd rules - Windows cross-compile: make PLATFORM=windows (mingw-w64 + windres) - windows.h isolated in platform.c to avoid Rectangle/CloseWindow conflicts with raylib.h - WIN_RES uses lazy = assignment so OBJ_DIR expands correctly for windres output path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
480 lines
16 KiB
C
480 lines
16 KiB
C
#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);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
void synthResetPatch(Synth *s)
|
|
{
|
|
Voice *v = &s->voices[0];
|
|
|
|
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->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->gain = gain;
|
|
o->octave = 0;
|
|
}
|
|
|
|
|
|
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 = ((float)rand() / (float)RAND_MAX) * 2.0f - 1.0f;
|
|
}
|
|
}
|
|
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 = ((float)rand() / (float)RAND_MAX) * 2.0f - 1.0f;
|
|
// }
|
|
// }
|
|
//
|
|
// 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 = ((float)rand() / (float)RAND_MAX) * 2.0f - 1.0f;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
mix += oscMix * amp;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|