refactor(source): move every implementation into its module directory

- 36 files move into the module whose AGENTS.md already claimed them:
  Engine/, Synth/, Modulation/, Params/, Plugin/, GUI/, EffectUnits/, Resources/
- Source/engine, modulation, params, plugin and synth become Engine,
  Modulation, Params, Plugin and Synth, matching the directories that already
  hold code
- ExactCompare.h stays at the Source/ root as the one shared header
- SERUMALT_INCLUDE_DIRS adds one include directory per module, so the 31
  parent-relative includes become plain names with no path prefix
- the module AGENTS.md files list owned files instead of describing a move
  that had not happened, and README.md and AGENTS.md describe the real tree

Verified: the harness passes CTest, the Linux VST3 and Standalone link, the
Windows cross-build passes its module checks, each configure exposes only its
own targets, no first-party warning line appears, and the generated Windows
manifest is unchanged.
This commit is contained in:
biggy committed 2026-10-06 09:17:49 +02:00
1 parent facd5c5b5e
commit 4b0db50271
70 files changed
+115 -111

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# synth module
## Owned files
- Wavetable.h, Wavetable.cpp
- Oscillator.h, Oscillator.cpp
- SubOscillator.h, SubOscillator.cpp
- NoiseOscillator.h, NoiseOscillator.cpp
- Envelope.h, Envelope.cpp
- LFO.h, LFO.cpp
- Filter.h, Filter.cpp
- FilterBank.h, FilterBank.cpp
- SynthVoice.h, SynthVoice.cpp
## Rules
- Use `noexcept` on every per-sample DSP path and cheap getter.
- Use `std::array` for fixed-size state and `std::vector<float>` for delay lines and table buffers.
- Size every buffer and delay line in `prepare()`, never in `process` or `render`.
- Clamp filter and envelope state so extreme settings cannot produce NaN or inf.
- Use `juce::MathConstants<T>::pi` and `twoPi` instead of raw pi literals.
- Use `(size_t)` casts when indexing containers with int loop variables.
- Pass global per-block state into voices as a `const RenderContext&`.
- Never allocate memory in the audio path.
- Keep oscillator phase when changing held-note parameters. LFO phase offsets are separate from the running phase.
## IF-THEN
- Wavetables are prebuilt during Engine construction. Audio and GUI code read them without triggering generation.
- IF a parameter is read every block THEN snapshot it into a struct (`OscParams`, `RenderContext`) instead of reading the APVTS per sample.
## Examples
```cpp
// BAD: lazy table build reached from the render path allocates on the audio thread
const Wavetable& wt = library.getTable (wave);
```
```cpp
// GOOD: build once in the Engine constructor, then only read const references
library.prebuild(); // in the Engine constructor
const Wavetable& wt = library.getTable (wave); // lookup, no allocation
```
```cpp
// BAD: raw pi literal duplicated across files
const double step = 6.28318530717958647692 * freq / sr;
```
```cpp
// GOOD: single source
const double step = juce::MathConstants<double>::twoPi * freq / sr;
```
This file overrides /AGENTS.md where they conflict.
+116
View File
@@ -0,0 +1,116 @@
#include "Envelope.h"
namespace serum
{
void Envelope::reset()
{
stage = Stage::Idle;
value = 0.0f;
startValue = endValue = 0.0f;
elapsed = duration = 0.0;
}
void Envelope::noteOn()
{
stage = Stage::Attack;
elapsed = 0.0;
duration = attackSamples;
startValue = value; // retrigger from current level
endValue = 1.0f;
}
void Envelope::noteOff()
{
if (stage == Stage::Idle)
return;
stage = Stage::Release;
elapsed = 0.0;
duration = releaseSamples;
startValue = value;
}
void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, float releaseSec, float curve)
{
sustain = juce::jlimit (0.0f, 1.0f, sustainLevel);
curve = juce::jlimit (0.0f, 1.0f, curve);
attackSamples = std::max (1.0, (double) attackSec * sr);
decaySamples = std::max (1.0, (double) decaySec * sr);
releaseSamples = std::max (1.0, (double) releaseSec * sr);
// curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay
attackShape = (double) attackShapeFor (curve);
decayShape = (double) decayShapeFor (curve);
shape.attack = attackSec;
shape.decay = decaySec;
shape.sustain = sustain;
shape.release = releaseSec;
shape.curve = curve;
}
float Envelope::process() noexcept
{
switch (stage)
{
case Stage::Idle:
value = 0.0f;
return 0.0f;
case Stage::Attack:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = endValue;
stage = Stage::Decay;
elapsed = 0.0;
duration = decaySamples;
startValue = value;
endValue = sustain;
}
else
{
const double p = elapsed / duration;
value = startValue + (endValue - startValue) * (float) std::pow (p, attackShape);
}
return value;
case Stage::Decay:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = sustain;
stage = Stage::Sustain;
}
else
{
const double p = elapsed / duration;
value = endValue + (startValue - endValue) * (float) std::pow (1.0 - p, decayShape);
}
return value;
case Stage::Sustain:
value = sustain;
return value;
case Stage::Release:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = 0.0f;
stage = Stage::Idle;
}
else
{
const double p = elapsed / duration;
value = startValue * (float) std::pow (1.0 - p, decayShape);
}
return value;
}
return 0.0f;
}
} // namespace serum
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <JuceHeader.h>
namespace serum
{
// Curve-to-shape mapping shared by the envelope DSP and the GUI preview, so the
// two cannot drift apart.
inline float attackShapeFor (float curve) noexcept { return 0.3f + curve * 2.7f; } // 0.3 .. 3.0
inline float decayShapeFor (float curve) noexcept { return 3.0f - curve * 2.7f; } // 3.0 .. 0.3
// ===========================================================================
// ADSR envelope with curve tension. Durations are in seconds; the sustain
// level is 0..1. Retriggering (noteOn while active) starts from the current
// value for click-free legato behaviour.
// ===========================================================================
class Envelope
{
public:
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void noteOn();
void noteOff();
void setParams (float attackSec, float decaySec, float sustain, float releaseSec, float curve);
float process() noexcept; // advance one sample and return the value
float getValue() const noexcept { return value; }
bool isActive() const noexcept { return stage != Stage::Idle; }
// Snapshot for GUI rendering.
struct Shape { float attack = 0, decay = 0, sustain = 0, release = 0, curve = 0.5f; };
Shape getShape() const noexcept { return shape; }
private:
enum class Stage { Idle, Attack, Decay, Sustain, Release };
Stage stage = Stage::Idle;
double sr = 44100.0;
float value = 0.0f;
float startValue = 0.0f, endValue = 0.0f;
double elapsed = 0.0, duration = 0.0;
double attackShape = 1.0, decayShape = 1.0;
float sustain = 0.7f;
double attackSamples = 0.0, decaySamples = 0.0, releaseSamples = 0.0;
Shape shape;
};
} // namespace serum
+290
View File
@@ -0,0 +1,290 @@
#include "Filter.h"
#include "ExactCompare.h"
namespace serum
{
namespace
{
constexpr double kPi = juce::MathConstants<double>::pi;
inline float clampF (float v, float lo, float hi) noexcept
{
return v < lo ? lo : (v > hi ? hi : v);
}
inline double clampD (double v, double lo, double hi) noexcept
{
return v < lo ? lo : (v > hi ? hi : v);
}
}
void Filter::prepare (double sampleRate)
{
sr = sampleRate;
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
combWrite = 0;
combDamp = 0.0;
reset();
}
void Filter::reset()
{
lastCutoffHz = lastRes = -1.0f;
lastType = -1;
ladderG = 0.0;
ic1eq = ic2eq = 0.0;
lastG = lastK = 0.0;
a1 = a2 = a3 = 0.0;
stage.fill (0.0);
for (auto& s : formantState) s.fill (0.0);
// combLine is one second of samples per filter, and a preset change now
// resets on the audio thread. It only needs clearing when the comb has
// actually written to it.
if (combDirty)
{
std::fill (combLine.begin(), combLine.end(), 0.0f);
combDirty = false;
}
combWrite = 0;
combDamp = 0.0;
}
void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
{
// Cache key: rebuild coefficients only when a parameter actually changes.
if (! changed (cutoffHz, lastCutoffHz) && ! changed (res, lastRes) && type == lastType)
return;
lastCutoffHz = cutoffHz;
lastRes = res;
lastType = type;
// Ladder stages are cascaded one-poles, which need an exponential coefficient
// (always in (0,1]) for unconditional stability. The TPT SVF (formant/screamer)
// uses cached tan()-based coefficients instead.
switch ((FilterModel) type)
{
case FilterModel::LadderLP:
case FilterModel::LadderHP:
case FilterModel::LadderBP:
case FilterModel::Diode:
{
const double fc = clampD (cutoffHz, 20.0, sr * 0.45);
ladderG = 1.0 - std::exp (-2.0 * kPi * fc / sr);
break;
}
case FilterModel::Formant:
{
// morph (0..1) sweeps the three bandpass centres to produce vowel-like spectra.
const double m = clampD (maps::hzToCutoff (cutoffHz), 0.0, 1.0);
const double base[3] = { 400.0, 1200.0, 2600.0 };
const double k = clampD (2.0 * (1.0 - (double) res), 0.05, 2.0);
for (int i = 0; i < 3; ++i)
{
const double fc = base[i] * (0.7 + 1.6 * m) * (i == 2 ? 0.9 : 1.0);
const double g = std::tan (kPi * clampD (fc, 30.0, sr * 0.45) / sr);
auto& c = formantCoefficients[(size_t) i];
c[0] = 1.0 / (1.0 + g * (g + k));
c[1] = g * c[0];
c[2] = g * c[1];
}
break;
}
case FilterModel::Screamer:
{
const double g = std::tan (kPi * clampD (cutoffHz, 30.0, sr * 0.45) / sr);
const double k = clampD (2.0 * (1.0 - (double) res), 0.05, 2.0);
updateSvf (g, k);
break;
}
case FilterModel::Comb:
case FilterModel::Count:
default:
break;
}
}
void Filter::updateSvf (double g, double k) noexcept
{
// Cache key: the SVF coefficients only change with g and k.
if (! changed (g, lastG) && ! changed (k, lastK))
return;
lastG = g;
lastK = k;
a1 = 1.0 / (1.0 + g * (g + k));
a2 = g * a1;
a3 = g * a2;
}
double Filter::svfBand (double in, double g, double k) noexcept
{
updateSvf (g, k);
const double v3 = in - ic2eq;
const double v1 = a1 * ic1eq + a2 * v3;
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
ic1eq = 2.0 * v1 - ic1eq;
ic2eq = 2.0 * v2 - ic2eq;
return v1;
}
double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept
{
stages = juce::jlimit (1, 4, stages);
res = clampD (res, 0.0, 0.97);
double x;
if (diode)
{
// Diode ladder: softer, asymmetric feedback and diode clipping.
x = in - 3.0 * res * (stage[(size_t) (stages - 1)] - in * 0.5);
x = x / (1.0 + std::abs (x)); // diode curve
x = x * (1.0 + drive * 3.0);
}
else
{
x = in - 4.0 * res * stage[(size_t) (stages - 1)];
x = std::tanh (x * (1.0 + drive * 6.0)); // Moog-ish input saturation
}
for (int i = 0; i < stages; ++i)
{
stage[(size_t) i] += g * (x - stage[(size_t) i]);
x = stage[(size_t) i];
}
x = clampD (x, -8.0, 8.0);
return x;
}
double Filter::comb (double in, double freqHz, double res, double drive) noexcept
{
const int len = (int) combLine.size();
if (len < 4)
return in;
const double freq = clampD (freqHz, 20.0, sr * 0.45);
double delay = sr / freq;
delay = clampD (delay, 2.0, (double) len - 2.0);
int readPos = combWrite - (int) delay;
if (readPos < 0) readPos += len;
const int readPos2 = (readPos + 1) % len;
const float frac = (float) (delay - std::floor (delay));
const float y0 = combLine[(size_t) readPos];
const float y1 = combLine[(size_t) readPos2];
float y = y0 + (y1 - y0) * frac;
// Damping lowpass in the feedback path (drive controls damping).
const double dampCoef = 1.0 - clampD (drive, 0.0, 0.99);
combDamp = y * (1.0 - dampCoef) + combDamp * dampCoef;
const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f);
combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback;
combWrite = (combWrite + 1) % len;
combDirty = true;
return (double) y;
}
double Filter::formant (double in) noexcept
{
double out = 0.0;
const double gains[3] = { 1.0, 0.8, 0.5 };
for (int i = 0; i < 3; ++i)
{
const auto& c = formantCoefficients[(size_t) i];
const double c0 = c[0];
const double c1 = c[1];
const double c2 = c[2];
const double v3 = in - formantState[(size_t) i][1];
const double v1 = c0 * formantState[(size_t) i][0] + c1 * v3;
const double v2 = formantState[(size_t) i][1] + c1 * formantState[(size_t) i][0] + c2 * v3;
formantState[(size_t) i][0] = 2.0 * v1 - formantState[(size_t) i][0];
formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1];
out += v1 * gains[i];
}
return clampD (out * 0.5, -8.0, 8.0);
}
double Filter::screamer (double in, double drive) noexcept
{
const double band = svfBand (in, lastG, lastK);
const double driven = std::tanh (band * (1.0 + drive * 12.0));
return driven * (1.0 - drive * 0.4);
}
float Filter::processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept
{
res = clampF (res, 0.0f, 0.98f);
drive = clampF (drive, 0.0f, 1.0f);
updateCoefficients (cutoffHz, res, type);
const double g = ladderG;
double out = (double) in;
switch ((FilterModel) type)
{
case FilterModel::LadderLP:
{
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
out = ladder (in, g, res, drive, stages, false);
break;
}
case FilterModel::LadderHP:
{
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
out = (double) in - ladder (in, g, res, drive, stages, false);
break;
}
case FilterModel::LadderBP:
{
int stages = (slope == 0) ? 2 : (slope == 1) ? 2 : 4;
out = ladder (in, g, res, drive, stages, false);
out = stage[0] - stage[(size_t) (stages - 1)];
break;
}
case FilterModel::Diode:
{
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
out = ladder (in, g, res, drive, stages, true);
break;
}
case FilterModel::Comb:
out = comb (in, cutoffHz, res, drive);
break;
case FilterModel::Formant:
out = formant (in);
break;
case FilterModel::Screamer:
out = screamer (in, drive);
break;
case FilterModel::Count:
default:
break;
}
return (float) clampD (out, -8.0, 8.0);
}
float Filter::getCutoffHz (float cutoffNorm, float keytrack, float noteHz) noexcept
{
// Keytrack shifts the cutoff with note pitch.
const float noteNumber = (noteHz > 0.0f) ? (69.0f + 12.0f * std::log2f (noteHz / 440.0f)) : 60.0f;
const float baseHz = maps::cutoffToHz (cutoffNorm);
const float keyFactor = std::pow (2.0f, keytrack * (noteNumber - 60.0f) / 12.0f);
return clampF (baseHz * keyFactor, 20.0f, 18000.0f);
}
void Filter::process (float* samples, int numSamples, float cutoffNorm, float res,
float drive, float keytrack, float noteHz, int type, int slope) noexcept
{
if (samples == nullptr || numSamples <= 0)
return;
const float cutoffHz = getCutoffHz (cutoffNorm, keytrack, noteHz);
for (int i = 0; i < numSamples; ++i)
samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope);
}
} // namespace serum
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Mono filter with 7 models (Ladder LP/HP/BP, Diode, Comb, Formant, Screamer),
// 6/12/24 dB slopes (ladder family) and per-sample drive + keytrack. All state
// is clamped to prevent NaN/inf at extreme settings.
// ===========================================================================
class Filter
{
public:
void prepare (double sampleRate);
void reset();
// Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz).
void process (float* samples, int numSamples, float cutoffNorm, float res,
float drive, float keytrack, float noteHz, int type, int slope) noexcept;
// Single-sample version (used by the comb/naive paths where convenient).
float processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept;
static float getCutoffHz (float cutoffNorm, float keytrack, float noteHz) noexcept;
private:
double sr = 44100.0;
float lastCutoffHz = -1.0f, lastRes = -1.0f;
int lastType = -1;
double ladderG = 0.0;
// TPT SVF state (also reused by formant/screamer).
double ic1eq = 0.0, ic2eq = 0.0;
double lastG = 0.0, lastK = 0.0;
double a1 = 0.0, a2 = 0.0, a3 = 0.0;
// Ladder stage state.
std::array<double, 4> stage { { 0.0, 0.0, 0.0, 0.0 } };
// Comb delay line.
std::vector<float> combLine;
int combWrite = 0;
double combDamp = 0.0;
bool combDirty = false; // set once comb() has written to combLine
// Formant: three parallel bandpass SVFs (state pairs).
std::array<std::array<double, 2>, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } };
std::array<std::array<double, 3>, 3> formantCoefficients {};
void updateCoefficients (float cutoffHz, float res, int type) noexcept;
void updateSvf (double g, double k) noexcept;
double svfBand (double in, double g, double k) noexcept;
double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept;
double comb (double in, double freqHz, double res, double drive) noexcept;
double formant (double in) noexcept;
double screamer (double in, double drive) noexcept;
};
} // namespace serum
+83
View File
@@ -0,0 +1,83 @@
#include "FilterBank.h"
#include "ExactCompare.h"
namespace serum
{
void FilterBank::prepare (double sampleRate)
{
f1L.prepare (sampleRate);
f1R.prepare (sampleRate);
f2L.prepare (sampleRate);
f2R.prepare (sampleRate);
}
void FilterBank::reset()
{
f1L.reset(); f1R.reset(); f2L.reset(); f2R.reset();
}
void FilterBank::applySlot (Filter& f, float* buf, int n, bool on, int type, float cutoff,
float res, float drive, float key, int slope, float noteHz) noexcept
{
if (on)
f.process (buf, n, cutoff, res, drive, key, noteHz, type, slope);
}
void FilterBank::process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept
{
const bool anyFilter = p.f1On || p.f2On;
if (! anyFilter)
return;
if (p.route == (int) FilterRoute::Serial)
{
applySlot (f1L, l, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
applySlot (f1R, r, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
applySlot (f2L, l, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
}
else if (p.route == (int) FilterRoute::Parallel)
{
// Run both filters on copies and crossfade.
const float cutoff1 = p.f1On ? Filter::getCutoffHz (p.f1Cutoff, p.f1Key, noteHz) : 0.0f;
const float cutoff2 = p.f2On ? Filter::getCutoffHz (p.f2Cutoff, p.f2Key, noteHz) : 0.0f;
float f1l = 0.0f, f1r = 0.0f, f2l = 0.0f, f2r = 0.0f;
for (int i = 0; i < numSamples; ++i)
{
f1l = l[i]; f1r = r[i];
f2l = l[i]; f2r = r[i];
if (p.f1On)
{
f1l = f1L.processSample (f1l, cutoff1, p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
f1r = f1R.processSample (f1r, cutoff1, p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
}
if (p.f2On)
{
f2l = f2L.processSample (f2l, cutoff2, p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
f2r = f2R.processSample (f2r, cutoff2, p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
}
const float m = p.mix;
l[i] = f1l * (1.0f - m) + f2l * m;
r[i] = f1r * (1.0f - m) + f2r * m;
}
}
else // Split: filter 1 -> left, filter 2 -> right
{
applySlot (f1L, l, numSamples, p.f1On, p.f1Type, p.f1Cutoff, p.f1Res, p.f1Drive, p.f1Key, p.f1Slope, noteHz);
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
}
// Post-filter output level. Skip the multiply only at exact unity so the
// bypass never changes the rendered samples.
if (! exactly (p.out, 1.0f))
{
for (int i = 0; i < numSamples; ++i)
{
l[i] *= p.out;
r[i] *= p.out;
}
}
}
} // namespace serum
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
#include "Filter.h"
namespace serum
{
// ===========================================================================
// Snapshot of the two-slot filter section.
// ===========================================================================
struct FilterBankParams
{
bool f1On = false, f2On = false;
int f1Type = 0, f2Type = 0;
float f1Cutoff = 0.5f, f2Cutoff = 0.5f;
float f1Res = 0.0f, f2Res = 0.0f;
float f1Drive = 0.0f, f2Drive = 0.0f;
float f1Key = 0.0f, f2Key = 0.0f;
int f1Slope = 2, f2Slope = 2;
int route = 0;
float mix = 0.5f;
float out = 1.0f;
};
// ===========================================================================
// Two filter slots with serial / parallel / split routing, processed stereo.
// ===========================================================================
class FilterBank
{
public:
void prepare (double sampleRate);
void reset();
void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept;
private:
Filter f1L, f1R, f2L, f2R;
static void applySlot (Filter& f, float* buf, int n, bool on, int type, float cutoff,
float res, float drive, float key, int slope, float noteHz) noexcept;
};
} // namespace serum
+152
View File
@@ -0,0 +1,152 @@
#include "LFO.h"
#include "ExactCompare.h"
namespace serum
{
void LFO::reset()
{
phase = 0.0;
value = 0.0f;
delayCounter = 0.0;
fadeCounter = 0.0;
fadeVal = 1.0f;
holdValue = rng.nextFloat() * 2.0f - 1.0f;
prevDelayParam = -1.0f;
prevFadeParam = -1.0f;
if (shapeBuffer.empty())
{
shapeBuffer.resize ((size_t) kShapePoints);
// default step sequence
for (int i = 0; i < kShapePoints; ++i)
shapeBuffer[(size_t) i] = (i % 2 == 0) ? 1.0f : -1.0f;
}
}
void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
float fade, float delay)
{
sync = s;
beat = b;
shape = juce::jlimit (0, (int) LfoShape::Count - 1, shp);
phaseOffset = juce::jlimit (0.0f, 1.0f, ph);
if (sync)
rateHz = (tempo / 60.0) / maps::beatToMultiplier (beat);
else
rateHz = maps::rateToHz (rateNorm);
delaySeconds = juce::jlimit (0.0f, 1.0f, delay) * 4.0;
fadeSeconds = juce::jlimit (0.0f, 1.0f, fade) * 8.0;
// Only restart the delay/fade timing when those knobs actually change,
// so repeated block-rate setParams calls don't keep resetting the LFO.
if (changed (delay, prevDelayParam) || changed (fade, prevFadeParam))
{
delayCounter = delaySeconds * sr;
fadeCounter = 0.0;
fadeVal = (fadeSeconds <= 0.0) ? 1.0f : 0.0f;
prevDelayParam = delay;
prevFadeParam = fade;
}
value = delayCounter > 0.0 ? 0.0f : shapeValue() * fadeVal;
}
void LFO::setShapeData (const std::vector<float>& data, int steps)
{
if (data.empty())
return;
shapeSteps = juce::jlimit (2, kShapePoints, steps);
for (size_t i = 0; i < shapeBuffer.size(); ++i)
shapeBuffer[i] = i < data.size() && std::isfinite (data[i])
? juce::jlimit (-1.0f, 1.0f, data[i]) : 0.0f;
}
float lfoShapeValue (float phase, LfoShape shape, int steps,
const float* data, int dataSize, float sampleHoldValue) noexcept
{
const int numSteps = juce::jlimit (2, LFO::kShapePoints, steps);
const int numData = data != nullptr ? juce::jlimit (0, LFO::kShapePoints, dataSize) : 0;
auto point = [data, numData] (int index) noexcept
{
return numData > 0 ? data[(size_t) juce::jlimit (0, numData - 1, index)] : 0.0f;
};
switch (shape)
{
case LfoShape::Sine:
return std::sin (phase * juce::MathConstants<float>::twoPi);
case LfoShape::Triangle:
return 1.0f - 4.0f * std::abs (phase - 0.5f);
case LfoShape::Saw:
return 2.0f * phase - 1.0f;
case LfoShape::Square:
return (phase < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold:
return sampleHoldValue;
case LfoShape::StepSeq:
return point (juce::jlimit (0, numSteps - 1, (int) (phase * (float) numSteps)));
case LfoShape::Freehand:
{
const float pos = phase * (float) (numSteps - 1);
const int i0 = (int) pos;
const int i1 = juce::jmin (i0 + 1, numSteps - 1);
const float frac = pos - (float) i0;
return point (i0) * (1.0f - frac) + point (i1) * frac;
}
case LfoShape::Count:
default:
return 0.0f;
}
}
float LFO::shapeValue() noexcept
{
return lfoShapeValue (getPhase(), (LfoShape) shape, shapeSteps,
shapeBuffer.data(), (int) shapeBuffer.size(), holdValue);
}
float LFO::process() noexcept
{
return advance (1);
}
float LFO::advance (int numSamples) noexcept
{
if (numSamples <= 0)
return value;
// Start delay.
if (delayCounter > 0.0)
{
const int skipped = (int) juce::jmin ((double) numSamples, std::ceil (delayCounter));
delayCounter = juce::jmax (0.0, delayCounter - skipped);
numSamples -= skipped;
if (numSamples == 0)
return value = 0.0f;
}
// Fade-in ramp.
if (fadeVal < 1.0f)
{
fadeCounter += numSamples;
if (fadeSeconds > 0.0)
fadeVal = (float) juce::jlimit (0.0, 1.0, fadeCounter / (fadeSeconds * sr));
else
fadeVal = 1.0f;
}
phase += rateHz * numSamples / sr;
const double cycles = std::floor (phase);
phase -= cycles;
if (shape == (int) LfoShape::SampleHold)
for (int i = 0; i < (int) cycles; ++i)
holdValue = rng.nextFloat() * 2.0f - 1.0f;
value = shapeValue() * fadeVal;
return value;
}
} // namespace serum
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Free-running LFO with tempo sync, seven shapes (sine/tri/saw/square/S&H/
// step-sequencer/freehand), adjustable phase, fade-in and start delay.
// ===========================================================================
class LFO
{
public:
static constexpr int kShapePoints = 64;
LFO() { reset(); }
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void setParams (float rateNorm, bool sync, float beat, int shape, float phase,
float fade, float delay);
void setTempo (double bpm) noexcept { tempo = bpm; }
void setShapeData (const std::vector<float>& data, int steps);
float process() noexcept; // advance and return current value
float advance (int numSamples) noexcept;
float getPhase() const noexcept { return (float) (phase + phaseOffset - std::floor (phase + phaseOffset)); }
float getValue() const noexcept { return value; }
const std::vector<float>& getShapeData() const noexcept { return shapeBuffer; }
int getShapeSteps() const noexcept { return shapeSteps; }
private:
double sr = 44100.0;
double tempo = 120.0;
double phase = 0.0;
double phaseOffset = 0.0;
double rateHz = 1.0;
float value = 0.0f;
int shape = 0;
bool sync = false;
float beat = 0.25f;
double delaySeconds = 0.0;
double fadeSeconds = 0.0;
double delayCounter = 0.0; // samples remaining before start
double fadeCounter = 0.0; // samples elapsed in fade
float fadeVal = 1.0f;
float prevDelayParam = -1.0f;
float prevFadeParam = -1.0f;
std::vector<float> shapeBuffer;
int shapeSteps = 16;
juce::Random rng;
float holdValue = 0.0f;
float shapeValue() noexcept;
};
// ===========================================================================
// Pure shape evaluation shared by the audio render (LFO::shapeValue) and the
// GUI preview (LFODisplay::valueAt), so the drawn shape cannot drift from the
// played one. `sampleHoldValue` supplies the current held level for
// LfoShape::SampleHold, which is stateful in the audio path.
// ===========================================================================
float lfoShapeValue (float phase, LfoShape shape, int steps,
const float* data, int dataSize, float sampleHoldValue) noexcept;
} // namespace serum
+36
View File
@@ -0,0 +1,36 @@
#include "NoiseOscillator.h"
namespace serum
{
void NoiseOscillator::reset()
{
pinkB0 = pinkB1 = pinkB2 = 0.0f;
}
void NoiseOscillator::noteOn (juce::uint32 seed)
{
rng.setSeed (seed);
}
void NoiseOscillator::processAdd (int type, float level, float& out) noexcept
{
if (level <= 0.0f)
return;
const float white = rng.nextFloat() * 2.0f - 1.0f;
float s = white;
if (type == (int) NoiseType::Pink)
{
// Paul Kellet's economy pink-noise filter.
pinkB0 = 0.99765f * pinkB0 + white * 0.0990460f;
pinkB1 = 0.96300f * pinkB1 + white * 0.2965164f;
pinkB2 = 0.57000f * pinkB2 + white * 1.0526913f;
s = pinkB0 + pinkB1 + pinkB2 + white * 0.1848f;
}
out += s * level;
}
} // namespace serum
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Per-voice white/pink noise generator.
// ===========================================================================
class NoiseOscillator
{
public:
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void noteOn (juce::uint32 seed);
// Accumulate into out.
void processAdd (int type, float level, float& out) noexcept;
private:
double sr = 44100.0;
juce::Random rng;
float pinkB0 = 0.0f, pinkB1 = 0.0f, pinkB2 = 0.0f;
};
} // namespace serum
+166
View File
@@ -0,0 +1,166 @@
#include "Oscillator.h"
#include "ExactCompare.h"
namespace serum
{
void Oscillator::reset()
{
for (auto& v : voices)
v = SubVoice {};
activeUnison = 1;
initializedUnison = 0;
paramsValid = false;
}
void Oscillator::noteOn (const OscParams& p, juce::uint32 seed)
{
rng.setSeed (seed);
initializedUnison = 0;
paramsValid = false;
setParams (p);
}
void Oscillator::setParams (const OscParams& p) noexcept
{
const int uni = juce::jlimit (1, kMaxUnison, p.unison);
const bool countChanged = ! paramsValid || uni != activeUnison;
const bool detuneChanged = countChanged || changed (p.detune, cachedParams.detune);
const bool panChanged = countChanged || changed (p.pan, cachedParams.pan)
|| changed (p.spread, cachedParams.spread);
if (! detuneChanged && ! panChanged)
return;
const double basePhase = (double) p.phase * kTwoPi;
for (int v = initializedUnison; v < uni; ++v)
{
// Even phase spacing distributes the initial unison phases.
double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0;
double random = rng.nextFloat() * (double) p.randPhase * kTwoPi;
voices[(size_t) v].phase = basePhase + offset + random;
}
initializedUnison = juce::jmax (initializedUnison, uni);
const float unisonLevel = 1.0f / std::sqrt ((float) uni);
for (int v = 0; v < uni; ++v)
{
auto& sv = voices[(size_t) v];
if (detuneChanged)
{
// Detune: linear spread in cents, 0..50 cents at full depth.
double detuneCents = 0.0;
if (uni > 1)
detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0));
sv.detuneRatio = std::pow (2.0, detuneCents / 1200.0);
}
if (panChanged)
{
// Stereo spread.
float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f;
panPos = juce::jlimit (-1.0f, 1.0f, panPos + p.pan);
// Constant-power pan.
const float panAngle = (panPos + 1.0f) * 0.5f * juce::MathConstants<float>::halfPi;
sv.panL = std::cos (panAngle);
sv.panR = std::sin (panAngle);
}
if (countChanged)
{
// Gain scaling with a centre emphasis for odd unison counts.
float lvl = unisonLevel;
if ((uni & 1) && v == uni / 2)
lvl *= 1.3f;
sv.level = lvl;
}
}
activeUnison = uni;
cachedParams = p;
paramsValid = true;
}
float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
{
const float amt = p.warpAmt;
switch ((WarpMode) p.warp)
{
case WarpMode::None: return phase;
case WarpMode::BendPlus: return phase + amt * 0.5f * std::sin (phase * (float) kTwoPi);
case WarpMode::BendMinus: return phase - amt * 0.5f * std::sin (phase * (float) kTwoPi);
case WarpMode::Sync: return std::fmod (phase * (1.0f + amt * 7.0f), 1.0f);
case WarpMode::Asym:
if (amt < 0.001f) return phase;
return std::pow (phase, 1.0f + amt * 3.0f);
case WarpMode::Mirror:
{
const float mirror = 2.0f * std::abs (phase - 0.5f);
return phase + (mirror - phase) * amt;
}
case WarpMode::PWM: return phase;
case WarpMode::Fold: return phase;
case WarpMode::Count: return phase;
default: return phase;
}
}
float Oscillator::warpSample (float sample, const OscParams& p) const noexcept
{
const float amt = p.warpAmt;
switch ((WarpMode) p.warp)
{
case WarpMode::PWM:
{
const float threshold = (2.0f * amt - 1.0f) * 0.9f;
return std::tanh ((sample - threshold) * 4.0f);
}
case WarpMode::Fold:
return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f);
case WarpMode::None:
case WarpMode::BendPlus:
case WarpMode::BendMinus:
case WarpMode::Sync:
case WarpMode::Asym:
case WarpMode::Mirror:
case WarpMode::Count:
default:
return sample;
}
}
void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
float& outL, float& outR) noexcept
{
if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0)
return;
const int uni = activeUnison;
const float framePos = p.wtPos * 255.0f;
const double phaseInc = kTwoPi * freqHz / sr;
float accL = 0.0f;
float accR = 0.0f;
for (int v = 0; v < uni; ++v)
{
auto& sv = voices[(size_t) v];
sv.phase += phaseInc * sv.detuneRatio;
sv.phase -= std::floor (sv.phase * (1.0 / kTwoPi)) * kTwoPi;
if (sv.phase >= kTwoPi) sv.phase -= kTwoPi;
if (sv.phase < 0.0) sv.phase += kTwoPi;
float phase01 = (float) (sv.phase * (1.0 / kTwoPi));
float sample = wt.readSafe (framePos, warpPhase (phase01, p));
sample = warpSample (sample, p);
const float gain = sv.level * p.level;
accL += sample * gain * sv.panL;
accR += sample * gain * sv.panR;
}
outL += accL;
outR += accR;
}
} // namespace serum
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
#include "Wavetable.h"
namespace serum
{
// ===========================================================================
// Per-oscillator parameters (snapshot read from the APVTS each block).
// ===========================================================================
struct OscParams
{
bool enabled = true;
int wave = 0;
float wtPos = 0.0f;
int warp = 0;
float warpAmt = 0.0f;
int coarse = 0;
int fine = 0;
float level = 1.0f;
float pan = 0.0f;
int unison = 1;
float detune = 0.0f;
float spread = 0.0f;
float phase = 0.0f;
float randPhase = 0.0f;
};
// ===========================================================================
// A polyphonic oscillator with wavetable morphing, warp modes and unison
// (1..16 sub-voices). Per-voice state (phase) lives here; params are read each
// block from the OscParams snapshot.
// ===========================================================================
class Oscillator
{
public:
Oscillator() = default;
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
// Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain.
void noteOn (const OscParams& p, juce::uint32 seed);
void setParams (const OscParams& p) noexcept;
// Accumulate this oscillator's contribution into outL/outR.
void processAdd (const Wavetable& wt, const OscParams& p, double freqHz,
float& outL, float& outR) noexcept;
int getActiveUnison() const noexcept { return activeUnison; }
private:
struct SubVoice
{
double phase = 0.0;
double detuneRatio = 1.0;
float panL = 0.70710678f;
float panR = 0.70710678f;
float level = 1.0f;
};
std::array<SubVoice, kMaxUnison> voices;
int activeUnison = 1;
int initializedUnison = 0;
bool paramsValid = false;
OscParams cachedParams;
double sr = 44100.0;
juce::Random rng;
static constexpr double kTwoPi = juce::MathConstants<double>::twoPi;
float warpPhase (float phase, const OscParams& p) const noexcept;
float warpSample (float sample, const OscParams& p) const noexcept;
};
} // namespace serum
+35
View File
@@ -0,0 +1,35 @@
#include "SubOscillator.h"
namespace serum
{
void SubOscillator::noteOn()
{
phase = 0.0;
}
void SubOscillator::processAdd (double freqHz, int shape, float level, float& out) noexcept
{
if (level <= 0.0f || freqHz <= 0.0)
return;
// The octave shift is baked into freqHz by the voice; here we just phase-accumulate.
const double inc = juce::MathConstants<double>::twoPi * freqHz / sr;
phase += inc;
phase -= std::floor (phase * (1.0 / juce::MathConstants<double>::twoPi)) * juce::MathConstants<double>::twoPi;
float s = 0.0f;
if (shape == (int) SubShape::Triangle)
{
const float p = (float) (phase * (1.0 / juce::MathConstants<double>::twoPi));
s = 1.0f - 4.0f * std::abs (p - 0.5f);
}
else
{
s = (float) std::sin (phase);
}
out += s * level;
}
} // namespace serum
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Simple sub oscillator: sine or triangle, one/two octaves down or unison.
// ===========================================================================
class SubOscillator
{
public:
void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; }
void reset() { phase = 0.0; }
void noteOn();
// Accumulate into out (mono; the caller pans/levels it).
void processAdd (double freqHz, int shape, float level, float& out) noexcept;
private:
double sr = 44100.0;
double phase = 0.0;
};
} // namespace serum
+245
View File
@@ -0,0 +1,245 @@
#include "SynthVoice.h"
namespace serum
{
namespace
{
inline float clampF (float v, float lo, float hi) noexcept
{
return v < lo ? lo : (v > hi ? hi : v);
}
}
void SynthVoice::prepare (double sampleRate, int maxBlockSize)
{
oscA.prepare (sampleRate);
oscB.prepare (sampleRate);
sub.prepare (sampleRate);
noise.prepare (sampleRate);
filters.prepare (sampleRate);
for (auto& e : env)
e.prepare (sampleRate);
scratch.setSize (2, maxBlockSize, false, false, true);
reset();
}
void SynthVoice::reset()
{
oscA.reset(); oscB.reset();
sub.reset(); noise.reset();
filters.reset();
for (auto& e : env)
e.reset();
note = -1;
velocity = 0.0f;
baseFreq = 0.0;
active = released = false;
noteId = 0;
oscillatorsNeedNoteOn = false;
noteRandom = 0.5f;
scratch.clear();
}
void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::uint32 noteSeed)
{
note = noteNumber;
velocity = velocity01;
baseFreq = freqHz;
active = true;
released = false;
seed = noteSeed;
noteId = noteSeed;
oscillatorsNeedNoteOn = true; // initialise fresh oscillator phases on first render
juce::Random rng (noteSeed);
noteRandom = rng.nextFloat();
sub.noteOn();
noise.noteOn (noteSeed);
for (auto& e : env)
e.noteOn();
}
void SynthVoice::noteOff()
{
if (! active || released)
return;
released = true;
for (auto& e : env)
e.noteOff();
}
float SynthVoice::getModulationValue (ModSource source) const noexcept
{
if (source >= ModSource::Env1 && source <= ModSource::Env4)
return env[(size_t) ((int) source - (int) ModSource::Env1)].getValue();
if (source == ModSource::Velocity) return velocity;
if (source == ModSource::Note) return clampF ((float) note / 127.0f, 0.0f, 1.0f);
if (source == ModSource::Random) return noteRandom;
return 0.0f;
}
void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept
{
jassert (numSamples <= scratch.getNumSamples());
if (! active || numSamples <= 0)
return;
// 1. Refresh envelope parameters (cheap; also lets UI edits affect held notes).
for (int i = 0; i < kNumEnvelopes; ++i)
env[(size_t) i].setParams (maps::toSeconds (ctx.envAttack[i]),
maps::toSeconds (ctx.envDecay[i]),
ctx.envSustain[i],
maps::toSeconds (ctx.envRelease[i]),
ctx.envCurve[i]);
// 2. Block-start envelope values (control-rate modulation sources).
float envStart[kNumEnvelopes];
for (int i = 0; i < kNumEnvelopes; ++i)
envStart[i] = env[(size_t) i].getValue();
// 3. Per-voice modulation source values.
float src[kNumModSources];
src[(int) ModSource::Lfo1] = ctx.lfoValues[0];
src[(int) ModSource::Lfo2] = ctx.lfoValues[1];
src[(int) ModSource::Lfo3] = ctx.lfoValues[2];
src[(int) ModSource::Lfo4] = ctx.lfoValues[3];
src[(int) ModSource::Env1] = envStart[0];
src[(int) ModSource::Env2] = envStart[1];
src[(int) ModSource::Env3] = envStart[2];
src[(int) ModSource::Env4] = envStart[3];
src[(int) ModSource::Velocity] = velocity;
src[(int) ModSource::Note] = clampF ((float) note / 127.0f, 0.0f, 1.0f);
src[(int) ModSource::ModWheel] = ctx.modWheel;
src[(int) ModSource::PitchBend]= ctx.pitchBend;
src[(int) ModSource::Macro1] = ctx.macroValues[0];
src[(int) ModSource::Macro2] = ctx.macroValues[1];
src[(int) ModSource::Macro3] = ctx.macroValues[2];
src[(int) ModSource::Macro4] = ctx.macroValues[3];
src[(int) ModSource::Random] = noteRandom;
// 4. Accumulate modulation offsets (block rate).
std::array<float, kNumModTargets> mod { { } };
if (ctx.matrix != nullptr)
{
for (const auto& c : ctx.matrix->connections)
{
float v = src[(int) c.source];
if (c.bipolar && ! isBipolarSource (c.source))
v = v * 2.0f - 1.0f;
mod[(size_t) c.target] += v * c.depth;
}
}
if (ctx.macros != nullptr)
{
for (int m = 0; m < kNumMacros; ++m)
for (const auto& a : ctx.macros->assignments[(size_t) m])
mod[(size_t) a.target] += ctx.macroValues[m] * a.depth;
}
// 5. Modulated oscillator parameters.
OscParams a = ctx.oscA;
OscParams b = ctx.oscB;
a.level = clampF (a.level + mod[(int) ModTarget::OscALevel], 0.0f, 1.0f);
a.pan = clampF (a.pan + mod[(int) ModTarget::OscAPan], -1.0f, 1.0f);
a.wtPos = clampF (a.wtPos + mod[(int) ModTarget::OscAWtPos], 0.0f, 1.0f);
a.unison = (int) std::llround (clampF ((float) a.unison + mod[(int) ModTarget::OscAUnison] * 16.0f, 1.0f, 16.0f));
a.detune = clampF (a.detune + mod[(int) ModTarget::OscADetune], 0.0f, 1.0f);
a.spread = clampF (a.spread + mod[(int) ModTarget::OscASpread], 0.0f, 1.0f);
a.warpAmt = clampF (a.warpAmt+ mod[(int) ModTarget::OscAWarpAmt], 0.0f, 1.0f);
b.level = clampF (b.level + mod[(int) ModTarget::OscBLevel], 0.0f, 1.0f);
b.pan = clampF (b.pan + mod[(int) ModTarget::OscBPan], -1.0f, 1.0f);
b.wtPos = clampF (b.wtPos + mod[(int) ModTarget::OscBWtPos], 0.0f, 1.0f);
b.unison = (int) std::llround (clampF ((float) b.unison + mod[(int) ModTarget::OscBUnison] * 16.0f, 1.0f, 16.0f));
b.detune = clampF (b.detune + mod[(int) ModTarget::OscBDetune], 0.0f, 1.0f);
b.spread = clampF (b.spread + mod[(int) ModTarget::OscBSpread], 0.0f, 1.0f);
b.warpAmt = clampF (b.warpAmt+ mod[(int) ModTarget::OscBWarpAmt], 0.0f, 1.0f);
// 6. Frequency (pitch bend + mod matrix pitch + per-osc coarse/fine).
const double semis = ctx.pitchBend * ctx.pitchBendRange + mod[(int) ModTarget::Pitch] * 24.0;
const double bent = baseFreq * std::pow (2.0, semis / 12.0);
const double freqA = bent * std::pow (2.0, (double) a.coarse / 12.0 + (double) a.fine / 1200.0);
const double freqB = bent * std::pow (2.0, (double) b.coarse / 12.0 + (double) b.fine / 1200.0);
// Initialise phases only for a fresh note; update held-note unison parameters
// without resetting existing phases when modulated at control rate.
if (oscillatorsNeedNoteOn)
{
oscA.noteOn (a, seed);
oscB.noteOn (b, seed + 1);
oscillatorsNeedNoteOn = false;
}
else
{
oscA.setParams (a);
oscB.setParams (b);
}
// 7. Modulated filter parameters.
FilterBankParams fb = ctx.filters;
fb.f1Cutoff = clampF (fb.f1Cutoff + mod[(int) ModTarget::Filter1Cutoff], 0.0f, 1.0f);
fb.f1Res = clampF (fb.f1Res + mod[(int) ModTarget::Filter1Res], 0.0f, 1.0f);
fb.f1Drive = clampF (fb.f1Drive + mod[(int) ModTarget::Filter1Drive], 0.0f, 1.0f);
fb.f2Cutoff = clampF (fb.f2Cutoff + mod[(int) ModTarget::Filter2Cutoff], 0.0f, 1.0f);
fb.f2Res = clampF (fb.f2Res + mod[(int) ModTarget::Filter2Res], 0.0f, 1.0f);
fb.f2Drive = clampF (fb.f2Drive + mod[(int) ModTarget::Filter2Drive], 0.0f, 1.0f);
fb.mix = clampF (fb.mix + mod[(int) ModTarget::FilterMix], 0.0f, 1.0f);
fb.out = clampF (fb.out + mod[(int) ModTarget::FilterOut], 0.0f, 1.5f);
// 8. Amp modulation + velocity.
const float ampMod = 1.0f + mod[(int) ModTarget::Amp];
const float velGain = velocity * velocity + 0.001f;
// 9. Generate oscillators/sub/noise into scratch.
float* sL = scratch.getWritePointer (0);
float* sR = scratch.getWritePointer (1);
const Wavetable& wtA = ctx.wavetables->getTable (a.wave);
const Wavetable& wtB = ctx.wavetables->getTable (b.wave);
const double subMult = (ctx.subOct == -2) ? 0.25 : (ctx.subOct == -1) ? 0.5 : 1.0;
const float subLevel = clampF (ctx.subLevel + mod[(int) ModTarget::SubLevel], 0.0f, 1.0f);
const float noiseLevel = clampF (ctx.noiseLevel + mod[(int) ModTarget::NoiseLevel], 0.0f, 1.0f);
for (int i = 0; i < numSamples; ++i)
{
float l = 0.0f, r = 0.0f;
oscA.processAdd (wtA, a, freqA, l, r);
oscB.processAdd (wtB, b, freqB, l, r);
float mono = 0.0f;
if (ctx.subOn)
sub.processAdd (bent * subMult, ctx.subShape, subLevel, mono);
if (ctx.noiseOn)
noise.processAdd (ctx.noiseType, noiseLevel, mono);
l += mono;
r += mono;
sL[i] = l;
sR[i] = r;
}
// 10. Filter bank (control rate).
filters.process (sL, sR, numSamples, fb, (float) baseFreq);
// 11. Amp envelope (per sample) and advance the remaining envelopes.
for (int i = 0; i < numSamples; ++i)
{
const float amp = env[0].process();
env[1].process();
env[2].process();
env[3].process();
const float gain = amp * ampMod * velGain;
outL[i] += sL[i] * gain;
outR[i] += sR[i] * gain;
}
// 12. Release completes when the amp envelope has fully decayed.
if (released && ! env[0].isActive())
active = false;
}
} // namespace serum
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
#include "Wavetable.h"
#include "Oscillator.h"
#include "SubOscillator.h"
#include "NoiseOscillator.h"
#include "FilterBank.h"
#include "Envelope.h"
#include "ModulationMatrix.h"
#include "MacroControls.h"
namespace serum
{
// ===========================================================================
// Everything a voice needs to render one block. Global (non per-voice) values
// are provided by the engine; the voice adds its own per-voice modulation.
// ===========================================================================
struct RenderContext
{
double sampleRate = 44100.0;
const WavetableLibrary* wavetables = nullptr;
float lfoValues[kNumLfos] { 0.0f, 0.0f, 0.0f, 0.0f };
float macroValues[kNumMacros] { 0.0f, 0.0f, 0.0f, 0.0f };
float modWheel = 0.0f;
float pitchBend = 0.0f;
float pitchBendRange = 2.0f;
const ModulationMatrix* matrix = nullptr;
const MacroControls* macros = nullptr;
OscParams oscA;
OscParams oscB;
bool subOn = false;
int subShape = 0;
int subOct = -1;
float subLevel = 0.0f;
bool noiseOn = false;
int noiseType = 0;
float noiseLevel = 0.0f;
FilterBankParams filters;
float envAttack[4] { 0.01f, 0.01f, 0.01f, 0.01f };
float envDecay[4] { 0.2f, 0.2f, 0.2f, 0.2f };
float envSustain[4]{ 0.7f, 0.7f, 0.7f, 0.7f };
float envRelease[4]{ 0.3f, 0.3f, 0.3f, 0.3f };
float envCurve[4] { 0.5f, 0.5f, 0.5f, 0.5f };
};
// ===========================================================================
// One synthesis voice: two oscillators + sub + noise -> filter bank -> amp
// envelope. Four envelopes provide per-voice modulation sources.
// ===========================================================================
class SynthVoice
{
public:
void prepare (double sampleRate, int maxBlockSize);
void reset();
void noteOn (int noteNumber, float velocity, double freqHz, juce::uint32 seed);
void noteOff();
void render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept;
bool isActive() const noexcept { return active; }
bool isReleased() const noexcept { return released; }
int getNote() const noexcept { return note; }
juce::uint64 getNoteId() const noexcept { return noteId; }
float getModulationValue (ModSource source) const noexcept;
private:
Oscillator oscA, oscB;
SubOscillator sub;
NoiseOscillator noise;
FilterBank filters;
std::array<Envelope, 4> env;
int note = -1;
float velocity = 0.0f;
double baseFreq = 0.0;
bool active = false;
bool released = false;
juce::uint64 noteId = 0;
bool oscillatorsNeedNoteOn = false;
juce::uint32 seed = 0;
float noteRandom = 0.5f;
juce::AudioBuffer<float> scratch; // 2 channels
};
} // namespace serum
+342
View File
@@ -0,0 +1,342 @@
#include "Wavetable.h"
namespace serum
{
// ---------------------------------------------------------------------------
// Wavetable
// ---------------------------------------------------------------------------
void Wavetable::clear()
{
frames.clear();
name = {};
}
void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int numHarmonics)
{
name = n;
frames.assign (kFrames, std::vector<float> (kTableSize, 0.0f));
const float invN = 1.0f / (float) kTableSize;
for (int f = 0; f < kFrames; ++f)
{
auto& frame = frames[(size_t) f];
// Precompute per-harmonic rotation step (cos/sin of the angular increment).
std::vector<double> cosStep ((size_t) numHarmonics + 1, 0.0);
std::vector<double> sinStep ((size_t) numHarmonics + 1, 0.0);
std::vector<double> mag ((size_t) numHarmonics + 1, 0.0);
std::vector<double> phase ((size_t) numHarmonics + 1, 0.0);
double maxAmp = 1e-9;
for (int h = 1; h <= numHarmonics; ++h)
{
const float a = ampFn (f, h);
const double m = std::abs ((double) a);
mag[(size_t) h] = m;
phase[(size_t) h] = (a < 0.0f) ? juce::MathConstants<double>::twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base
const double ang = juce::MathConstants<double>::twoPi * (double) h * (double) invN;
cosStep[(size_t) h] = std::cos (ang);
sinStep[(size_t) h] = std::sin (ang);
maxAmp = std::max (maxAmp, m);
}
double peak = 1e-9;
for (int h = 1; h <= numHarmonics; ++h)
{
if (mag[(size_t) h] < 1e-9)
continue;
// Start the rotating phasor at the harmonic's phase offset.
double s = std::sin (phase[(size_t) h]);
double c = std::cos (phase[(size_t) h]);
const double cs = cosStep[(size_t) h];
const double ss = sinStep[(size_t) h];
const double m = mag[(size_t) h];
for (int i = 0; i < kTableSize; ++i)
{
frame[(size_t) i] += (float) (m * s);
const double s2 = s * cs + c * ss;
c = c * cs - s * ss;
s = s2;
}
}
// Normalise each frame to unity peak so morphing keeps a stable level.
for (int i = 0; i < kTableSize; ++i)
peak = std::max (peak, (double) std::abs (frame[(size_t) i]));
if (peak > 1e-6)
{
const float g = (float) (1.0 / peak);
for (int i = 0; i < kTableSize; ++i)
frame[(size_t) i] *= g;
}
}
}
float Wavetable::read (float framePos, float phase) const noexcept
{
if (frames.empty())
return 0.0f;
framePos = juce::jlimit (0.0f, (float) (kFrames - 1), framePos);
int f0 = (int) framePos;
float frac = framePos - (float) f0;
int f1 = juce::jmin (f0 + 1, kFrames - 1);
float p = phase * (float) kTableSize;
int i0 = (int) p;
if (i0 < 0) i0 = 0;
float t = p - (float) i0;
int i1 = (i0 + 1) & (kTableSize - 1);
i0 &= (kTableSize - 1);
const auto& a = frames[(size_t) f0];
const auto& b = frames[(size_t) f1];
const float s0 = lerp (a[(size_t) i0], a[(size_t) i1], t);
const float s1 = lerp (b[(size_t) i0], b[(size_t) i1], t);
return lerp (s0, s1, frac);
}
void Wavetable::copyFrame (int frameIndex, float* dest, int numSamples) const
{
if (frames.empty() || dest == nullptr)
return;
frameIndex = juce::jlimit (0, kFrames - 1, frameIndex);
const auto& frame = frames[(size_t) frameIndex];
const int n = juce::jmin (numSamples, kTableSize);
for (int i = 0; i < n; ++i)
dest[i] = frame[(size_t) i];
}
// ---------------------------------------------------------------------------
// Library
// ---------------------------------------------------------------------------
void WavetableLibrary::prebuild()
{
for (int i = 0; i < kNumWavetables; ++i)
getTable (i);
}
const Wavetable& WavetableLibrary::getTable (int index) const
{
index = juce::jlimit (0, kNumWavetables - 1, index);
if (!built[(size_t) index])
buildTable (index);
return tables[(size_t) index];
}
void WavetableLibrary::buildTable (int index) const
{
switch (index)
{
case 0: tables[(size_t) index] = makeBasic(); break;
case 1: tables[(size_t) index] = makeSawPwm(); break;
case 2: tables[(size_t) index] = makeSquareSync(); break;
case 3: tables[(size_t) index] = makeTriangleFold(); break;
case 4: tables[(size_t) index] = makeVowel(); break;
case 5: tables[(size_t) index] = makeOrgan(); break;
case 6: tables[(size_t) index] = makeWarmSaw(); break;
case 7: tables[(size_t) index] = makeDigital(); break;
case 8: tables[(size_t) index] = makeGlass(); break;
case 9: tables[(size_t) index] = makeBass(); break;
default: tables[(size_t) index] = makeBasic(); break;
}
built[(size_t) index] = true;
}
namespace
{
inline float normT (int frame) { return (float) frame / 255.0f; }
}
// sine -> saw morph
Wavetable WavetableLibrary::makeBasic()
{
Wavetable wt;
wt.buildHarmonic ("Basic Shapes", [] (int f, int h)
{
const float t = normT (f);
const float sine = (h == 1) ? 1.0f : 0.0f;
const float saw = 1.0f / (float) h;
return sine + (saw - sine) * t;
}, 48);
return wt;
}
// narrow pulse -> wide pulse
Wavetable WavetableLibrary::makeSawPwm()
{
Wavetable wt;
wt.buildHarmonic ("Saw PWM", [] (int f, int h)
{
const float t = normT (f);
const float duty = 0.08f + 0.42f * t; // 8% -> 50%
const float a = std::sin (juce::MathConstants<float>::pi * (float) h * duty);
return (2.0f / ((float) h * juce::MathConstants<float>::pi)) * a;
}, 48);
return wt;
}
// hard-sync style comb sweep
Wavetable WavetableLibrary::makeSquareSync()
{
Wavetable wt;
wt.buildHarmonic ("Square Sync", [] (int f, int h)
{
const float t = normT (f);
const float ratio = 1.0f + 3.0f * t;
const float comb = 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants<float>::pi * (float) h * ratio);
return (1.0f / std::pow ((float) h, 0.8f)) * comb;
}, 48);
return wt;
}
// triangle -> folded/saw-ish
Wavetable WavetableLibrary::makeTriangleFold()
{
Wavetable wt;
wt.buildHarmonic ("Triangle Fold", [] (int f, int h)
{
const float t = normT (f);
// Triangle: odd harmonics with alternating sign, 1/h^2.
float tri = 0.0f;
if ((h & 1) == 1)
{
const int k = (h - 1) / 2;
const float sign = ((k & 1) == 0) ? 1.0f : -1.0f;
tri = sign * 8.0f / (juce::MathConstants<float>::pi * juce::MathConstants<float>::pi * (float) (h * h));
}
const float saw = 1.0f / (float) h;
return tri + (saw - tri) * t;
}, 48);
return wt;
}
// formant morph a -> e -> i
Wavetable WavetableLibrary::makeVowel()
{
Wavetable wt;
wt.buildHarmonic ("Vowel", [] (int f, int h)
{
const float t = normT (f);
// Three formant sets (Hz) with f0 = 55 Hz.
const float f0 = 55.0f;
const float aa[3] = { 730.0f, 1090.0f, 2440.0f };
const float ee[3] = { 530.0f, 1840.0f, 2480.0f };
const float ii[3] = { 270.0f, 2290.0f, 3010.0f };
float from[3], to[3];
if (t < 0.5f)
{
for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; }
}
else
{
for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; }
}
float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f;
float sum = 0.0f;
const float sigma = 1.8f; // formant bandwidth in harmonic units
for (int k = 0; k < 3; ++k)
{
const float fc = from[k] + (to[k] - from[k]) * tt;
const float center = fc / f0;
const float d = ((float) h - center) / sigma;
sum += std::exp (-0.5f * d * d) * (k == 0 ? 1.0f : 0.6f);
}
return sum;
}, 48);
return wt;
}
// drawbar organ morph
Wavetable WavetableLibrary::makeOrgan()
{
Wavetable wt;
wt.buildHarmonic ("Organ", [] (int f, int h)
{
const float t = normT (f);
// two drawbar registrations (16', 8', 5 1/3', 4', 2 2/3', 2', ...)
const float regA[8] = { 0.0f, 1.0f, 0.0f, 0.35f, 0.0f, 0.2f, 0.0f, 0.1f };
const float regB[8] = { 0.5f, 1.0f, 0.4f, 0.6f, 0.25f, 0.5f, 0.2f, 0.35f };
if (h > 8) return 0.0f;
const float a = regA[h - 1];
const float b = regB[h - 1];
return a + (b - a) * t;
}, 8);
return wt;
}
// warm lowpassed saw -> brighter
Wavetable WavetableLibrary::makeWarmSaw()
{
Wavetable wt;
wt.buildHarmonic ("Warm Saw", [] (int f, int h)
{
const float t = normT (f);
const float cutoff = 10.0f + 34.0f * t; // harmonic rolloff centre
const float rolloff = std::exp (-((float) h / cutoff) * ((float) h / cutoff));
const float body = 1.0f / std::pow ((float) h, 0.9f);
return body * (0.4f + 0.6f * rolloff);
}, 48);
return wt;
}
// additive, brighter and slightly combed
Wavetable WavetableLibrary::makeDigital()
{
Wavetable wt;
wt.buildHarmonic ("Digital", [] (int f, int h)
{
const float t = normT (f);
const float comb = 0.5f + 0.5f * std::cos ((float) h * 0.9f * (1.0f + t));
return (1.0f / std::pow ((float) h, 0.6f)) * (0.5f + 0.5f * comb);
}, 48);
return wt;
}
// inharmonic bell-like partial clusters
Wavetable WavetableLibrary::makeGlass()
{
Wavetable wt;
wt.buildHarmonic ("Glass", [] (int f, int h)
{
const float t = normT (f);
const float c1 = 1.0f + t * 2.5f;
const float c2 = 3.6f + t * 3.4f;
const float c3 = 6.2f + t * 4.0f;
const float sigma = 0.9f;
float sum = 0.0f;
const float centers[3] = { c1, c2, c3 };
const float gains[3] = { 1.0f, 0.7f, 0.4f };
for (int k = 0; k < 3; ++k)
{
const float d = ((float) h - centers[k]) / sigma;
sum += gains[k] * std::exp (-0.5f * d * d);
}
return sum;
}, 48);
return wt;
}
// sub-heavy -> fuller bass
Wavetable WavetableLibrary::makeBass()
{
Wavetable wt;
wt.buildHarmonic ("Bass", [] (int f, int h)
{
const float t = normT (f);
const float sub = (h == 1) ? 1.0f : ((h == 2) ? 0.4f : ((h == 3) ? 0.12f : 0.0f));
const float full = 1.0f / std::pow ((float) h, 1.1f);
return sub + (full - sub) * t;
}, 48);
return wt;
}
} // namespace serum
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// A single wavetable: 256 frames of 2048 samples each. Frames morph smoothly
// from a simple base shape to a complex one. Lookups interpolate bilinearly
// (across frames and within a frame). Tables are RAM-resident and generated
// lazily by the WavetableLibrary.
// ===========================================================================
class Wavetable
{
public:
static constexpr int kFrames = 256;
static constexpr int kTableSize = 2048;
Wavetable() = default;
juce::String name;
std::vector<std::vector<float>> frames; // [frame][sample]
bool isValid() const noexcept { return (int) frames.size() == kFrames; }
void clear();
// Build a table from a per-frame harmonic amplitude function.
// ampFn(frameIndex, harmonicNumber) -> amplitude (harmonic 1 = fundamental)
using HarmonicAmpFn = std::function<float (int, int)>;
void buildHarmonic (const juce::String& name, HarmonicAmpFn ampFn, int numHarmonics = 64);
// Read the table at a fractional frame position and a phase in [0,1).
float read (float framePos, float phase) const noexcept;
// Read with phase wrapped and clamped frame position.
float readSafe (float framePos, float phase) const noexcept
{
phase -= std::floor (phase);
return read (juce::jlimit (0.0f, (float) kFrames - 1.001f, framePos), phase);
}
// Copy raw samples of a frame into a destination buffer (for the GUI).
void copyFrame (int frameIndex, float* dest, int numSamples) const;
private:
static float lerp (float a, float b, float t) noexcept { return a + (b - a) * t; }
};
// ===========================================================================
// Lazily-built library of factory wavetables. Indices map to kWavetableNames.
// ===========================================================================
class WavetableLibrary
{
public:
const Wavetable& getTable (int index) const;
int size() const noexcept { return kNumWavetables; }
// Force generation of all tables (used at prepare time).
void prebuild();
private:
mutable std::array<Wavetable, kNumWavetables> tables;
mutable std::array<bool, kNumWavetables> built { { false } };
void buildTable (int index) const;
static Wavetable makeBasic();
static Wavetable makeSawPwm();
static Wavetable makeSquareSync();
static Wavetable makeTriangleFold();
static Wavetable makeVowel();
static Wavetable makeOrgan();
static Wavetable makeWarmSaw();
static Wavetable makeDigital();
static Wavetable makeGlass();
static Wavetable makeBass();
};
} // namespace serum