v0.9.5 beta — full feature build ready for distribution

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>
This commit is contained in:
Anachronaut
2026-07-07 19:18:34 -04:00
parent 801ea22d3e
commit 75e0894ff8
70 changed files with 5910 additions and 237 deletions

View File

@@ -2,6 +2,7 @@
#include <math.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdio.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
@@ -9,26 +10,231 @@
void synthInit(Synth *s, float sampleRate)
{
s->sampleRate = sampleRate;
s->attackSec = 0.005f;
s->releaseSec = 0.050f;
s->dutyCycle = 0.5f;
s->waveform = WAVE_SINE;
s->lastStolenVoice = 0;
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].phase = 0.0f;
s->voices[i].freqHz = 440.0f;
s->voices[i].amp = 0.0f;
s->voices[i].targetAmp = 0.0f;
s->voices[i].active = 0;
s->voices[i].midiNote = -1;
s->pitchBend = 0.0f;
s->pitchBendRange = 2.0f;
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;
}
}
float waveformSample(Waveform w, float phase, float dutyCycle)
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:
@@ -44,7 +250,7 @@ float waveformSample(Waveform w, float phase, float dutyCycle)
case WAVE_PULSE:
return (phase < dutyCycle) ? 1.0f : -1.0f;
case WAVE_NOISE:
return ((float)rand() / (float)RAND_MAX) * 2.0f - 1.0f;
return noiseHeld;
default:
return 0.0f;
}
@@ -69,67 +275,95 @@ void synthNoteOn(Synth *s, int midiNote)
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].targetAmp = 1.0f;
s->voices[i].active = 1;
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;
}
}
// No free voice — steal round-robin
// Steal round-robin
int i = s->lastStolenVoice % VOICE_COUNT;
s->lastStolenVoice++;
s->voices[i].freqHz = hz;
s->voices[i].midiNote = midiNote;
s->voices[i].targetAmp = 1.0f;
s->voices[i].phase = 0.0f;
s->voices[i].active = 1;
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)
{
void synthNoteOff(Synth *s, int midiNote) {
for (int i = 0; i < VOICE_COUNT; i++) {
if (s->voices[i].active && s->voices[i].midiNote == midiNote)
s->voices[i].targetAmp = 0.0f;
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 attackInc = (s->attackSec <= 0.0f) ? 1.0f : (1.0f / (s->attackSec * sr));
const float releaseInc = (s->releaseSec <= 0.0f) ? 1.0f : (1.0f / (s->releaseSec * sr));
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;
if (vv->targetAmp > vv->amp) {
vv->amp += attackInc;
if (vv->amp > vv->targetAmp) vv->amp = vv->targetAmp;
} else if (vv->targetAmp < vv->amp) {
vv->amp -= releaseInc;
if (vv->amp < vv->targetAmp) vv->amp = vv->targetAmp;
}
float amp = envelopeTick(&vv->ampEnv, sr);
float mod = envelopeTick(&vv->modEnv, sr);
if (vv->amp <= 0.0f && vv->targetAmp == 0.0f) {
if (vv->ampEnv.stage == ENV_IDLE) {
vv->active = 0;
continue;
}
//vv->phase += vv->freqHz / sr;
float bendMultiplier = powf(2.0f, (s->pitchBend * s->pitchBendRange) / 12.0f);
vv->phase += (vv->freqHz * bendMultiplier) / sr;
if (vv->phase >= 1.0f) vv->phase -= 1.0f;
float oscMix = 0.0f;
int activeOscs = 0;
for (int o = 0; o < OSC_COUNT; o++) {
Oscillator *osc = &vv->oscillators[o];
if (!osc->active) continue;
mix += waveformSample(s->waveform, vv->phase, s->dutyCycle) * vv->amp;
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;
mix *= (0.2f / VOICE_COUNT) * 4.0f * s->volume;
int32_t sample = (int32_t)lrintf(mix * 32767.0f);
if (sample > 32767) sample = 32767;
@@ -137,3 +371,109 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames)
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;
}
}