feat(filter): add filter bank and filter models

This commit is contained in:
2026-09-08 14:55:21 +02:00
parent 44960b9acb
commit 4021b9cdf7
4 changed files with 432 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
#include "Filter.h"
namespace serum
{
namespace
{
constexpr double kPi = 3.14159265358979323846;
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, int maxBlockSize)
{
sr = sampleRate;
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
combWrite = 0;
combDamp = 0.0;
reset();
(void) maxBlockSize;
}
void Filter::reset()
{
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);
std::fill (combLine.begin(), combLine.end(), 0.0f);
combWrite = 0;
combDamp = 0.0;
}
void Filter::updateSvf (double g, double k) noexcept
{
if (g == lastG && k == lastK)
return;
lastG = g;
lastK = k;
a1 = 1.0 / (1.0 + g * (g + k));
a2 = g * a1;
a3 = g * a2;
}
double Filter::svfLow (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 v2;
}
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::svfHigh (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 in - k * v1 - v2;
}
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;
return (double) y;
}
double Filter::formant (double in, double morph, double res) noexcept
{
// morph (0..1) sweeps the three bandpass centres to produce vowel-like spectra.
const double m = clampD (morph, 0.0, 1.0);
const double base[3] = { 400.0, 1200.0, 2600.0 };
const double k = clampD (2.0 * (1.0 - res), 0.05, 2.0);
double out = 0.0;
const double gains[3] = { 1.0, 0.8, 0.5 };
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);
const double a1 = 1.0 / (1.0 + g * (g + k));
const double a2 = g * a1;
const double a3 = g * a2;
const double v3 = in - formantState[(size_t) i][1];
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3;
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * 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 cutoffHz, double res, double drive) noexcept
{
const double g = std::tan (kPi * clampD (cutoffHz, 30.0, sr * 0.45) / sr);
const double k = clampD (2.0 * (1.0 - res), 0.05, 2.0);
const double band = svfBand (in, g, k);
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);
// Ladder stages are cascaded one-poles, which need an exponential coefficient
// (always in (0,1]) for unconditional stability. The TPT SVF (formant/screamer)
// computes its own tan()-based g internally.
const double fc = clampD (cutoffHz, 20.0, sr * 0.45);
const double g = 1.0 - std::exp (-2.0 * kPi * fc / sr);
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, maps::hzToCutoff (cutoffHz), res);
break;
case FilterModel::Screamer:
out = screamer (in, cutoffHz, res, drive);
break;
default:
break;
}
return (float) clampD (out, -8.0, 8.0);
}
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;
// 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);
const float cutoffHz = clampF (baseHz * keyFactor, 20.0f, 18000.0f);
for (int i = 0; i < numSamples; ++i)
samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope);
}
} // namespace serum
+56
View File
@@ -0,0 +1,56 @@
#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, int maxBlockSize);
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;
private:
double sr = 44100.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;
// 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 } } } };
void updateSvf (double g, double k) noexcept;
double svfLow (double in, double g, double k) noexcept;
double svfBand (double in, double g, double k) noexcept;
double svfHigh (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, double morph, double res) noexcept;
double screamer (double in, double cutoffHz, double res, double drive) noexcept;
};
} // namespace serum
+79
View File
@@ -0,0 +1,79 @@
#include "FilterBank.h"
namespace serum
{
void FilterBank::prepare (double sampleRate, int maxBlockSize)
{
f1L.prepare (sampleRate, maxBlockSize);
f1R.prepare (sampleRate, maxBlockSize);
f2L.prepare (sampleRate, maxBlockSize);
f2R.prepare (sampleRate, maxBlockSize);
}
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.
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, maps::cutoffToHz (p.f1Cutoff), p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
f1r = f1R.processSample (f1r, maps::cutoffToHz (p.f1Cutoff), p.f1Res, p.f1Drive, p.f1Type, p.f1Slope);
}
if (p.f2On)
{
f2l = f2L.processSample (f2l, maps::cutoffToHz (p.f2Cutoff), p.f2Res, p.f2Drive, p.f2Type, p.f2Slope);
f2r = f2R.processSample (f2r, maps::cutoffToHz (p.f2Cutoff), 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.
if (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, int maxBlockSize);
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