From 4021b9cdf7ccbd1d2bb59c4d13e4e60f46e9145c Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Tue, 8 Sep 2026 14:55:21 +0200 Subject: [PATCH] feat(filter): add filter bank and filter models --- Source/Filter.cpp | 252 ++++++++++++++++++++++++++++++++++++++++++ Source/Filter.h | 56 ++++++++++ Source/FilterBank.cpp | 79 +++++++++++++ Source/FilterBank.h | 45 ++++++++ 4 files changed, 432 insertions(+) create mode 100644 Source/Filter.cpp create mode 100644 Source/Filter.h create mode 100644 Source/FilterBank.cpp create mode 100644 Source/FilterBank.h diff --git a/Source/Filter.cpp b/Source/Filter.cpp new file mode 100644 index 0000000..484e7bf --- /dev/null +++ b/Source/Filter.cpp @@ -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 diff --git a/Source/Filter.h b/Source/Filter.h new file mode 100644 index 0000000..7043b5e --- /dev/null +++ b/Source/Filter.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#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 stage { { 0.0, 0.0, 0.0, 0.0 } }; + + // Comb delay line. + std::vector combLine; + int combWrite = 0; + double combDamp = 0.0; + + // Formant: three parallel bandpass SVFs (state pairs). + std::array, 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 diff --git a/Source/FilterBank.cpp b/Source/FilterBank.cpp new file mode 100644 index 0000000..dce04a8 --- /dev/null +++ b/Source/FilterBank.cpp @@ -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 diff --git a/Source/FilterBank.h b/Source/FilterBank.h new file mode 100644 index 0000000..2728c8e --- /dev/null +++ b/Source/FilterBank.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#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