sst-filters: Implementing a New Filter
Repo: surge-synthesizer/sst-filters. Header-only C++20; paths are relative to the
repo root. $B is your CMake configure directory.
cmake --build $B --target sst-filters-tests
$B/tests/sst-filters-tests [filter-name]
Which kind of filter are you adding?
Two different things live here, and the checklist below only covers the first.
A QuadFilterUnit model — 4-wide SIMD, control-rate coefficients, driven through the
FilterType / FilterSubType enums and exposed by the filters++ object model. This is
what most of the library is and what the rest of this document describes.
A standalone filter class — a self-contained struct with its own setCoeff /
processBlock, not wired into the enum dispatch at all. LinkwitzRiley.h,
ButterworthLPHP.h, FastTiltNoiseFilter.h, HalfRateFilter.h and BiquadFilter.h are
these. If the filter is a utility a host reaches for directly rather than a voice filter a
user selects, write one of these instead: a header in include/sst/filters/, a test in
tests/, and nothing else. No enum values, no dispatcher cases, no filters++ model.
Architecture in One Paragraph
Every filter is a 4-wide SIMD unit. Processing splits into two stages at different rates:
- Control rate (once per block):
FilterCoefficientMaker::MakeCoeffs()converts(freq, reso, type, subtype)into 8 floats stored inC[]. It also computesdC[] = (target - current) / blockSizefor smooth interpolation, andtC[](target). - Sample rate (per sample): a
FilterUnitQFPtrfunction operates on aQuadFilterUnitStatewhich holds SIMD versions of those 8 coefficients inC[]/dC[], plus 16 SIMD state registersR[]. Each sample, it first advancesC[i] += dC[i]for each used coefficient, then runs the filter math, then returns the filtered SIMD output.
The 4 SIMD lanes are 4 independent voices. The ++ API wraps this with one FilterCoefficientMaker per voice and an object-model configuration layer on top.
SIMD Macros
All SIMD operations use the abstraction layer from sst/basic-blocks:
SIMD_M128 // __m128 type
SIMD_MM(add_ps) // _mm_add_ps
SIMD_MM(sub_ps) // _mm_sub_ps
SIMD_MM(mul_ps) // _mm_mul_ps
SIMD_MM(div_ps) // _mm_div_ps
SIMD_MM(set1_ps) // _mm_set1_ps (broadcast scalar to all 4 lanes)
SIMD_MM(setzero_ps)// _mm_setzero_ps
SIMD_MM(load_ps) // _mm_load_ps (aligned)
SIMD_MM(store_ps) // _mm_store_ps (aligned)
SIMD_MM(max_ps) // _mm_max_ps
SIMD_MM(min_ps) // _mm_min_ps
SIMD_MM(and_ps) // _mm_and_ps
Common shorthand seen in filter implementations (locally defined within filter files):
// Often defined inside the filter namespace or as lambdas:
auto A = [](auto a, auto b) { return SIMD_MM(add_ps)(a, b); };
auto S = [](auto a, auto b) { return SIMD_MM(sub_ps)(a, b); };
auto M = [](auto a, auto b) { return SIMD_MM(mul_ps)(a, b); };
auto mset1 = [](float f) { return SIMD_MM(set1_ps)(f); };
Available fast math from sst/basic-blocks/dsp/:
fasttanhSSEclamped(x)— tanh approximationfasttan(x)— tan approximationsst::basic_blocks::dsp::softclip_ps(x)— soft clip
Key Struct: QuadFilterUnitState
struct alignas(16) QuadFilterUnitState {
SIMD_M128 C[8]; // n_cm_coeffs = 8: current coefficients (updated per sample via dC)
SIMD_M128 dC[8]; // per-sample deltas for interpolation
SIMD_M128 R[16]; // n_filter_registers = 16: filter state (z^-1 delays, accumulators)
float *DB[4]; // delay buffer pointers (comb filters only)
int active[4]; // 0xffffffff if voice active, 0 if not
int WP[4]; // write position for comb filters
float sampleRate;
float sampleRateInv;
};
Use R[0]..R[N-1] for your state variables. Keep a named enum for clarity.
Complete Implementation Checklist
Step 1: Write the filter header
Create include/sst/filters/MyFilter.h. Canonical structure:
#ifndef INCLUDE_SST_FILTERS_MYFILTER_H
#define INCLUDE_SST_FILTERS_MYFILTER_H
#include "QuadFilterUnit.h"
#include "FilterCoefficientMaker.h"
#include "sst/utilities/SincTable.h" // if needed
#include "sst/basic-blocks/dsp/FastMath.h" // for fasttanhSSEclamped, fasttan, etc.
namespace sst::filters::MyFilter
{
// ── Coefficient indices (must fit in [0, n_cm_coeffs) = [0, 8)) ──────────────
enum myfilter_coeffs {
mf_g = 0, // bilinear pre-warped cutoff
mf_R, // damping / Q
mf_gain, // output gain
n_mf_coeff // must be <= 8
};
// ── State register indices (must fit in [0, n_filter_registers) = [0, 16)) ───
enum myfilter_state {
mf_s1 = 0, // first-order state / z^-1
mf_s2, // second-order state
n_mf_state // must be <= 16
};
// ── Tuning helper (use this pattern for all cutoff frequency calculations) ───
template <typename TuningProvider>
inline float clampedFrequency(float pitch, float sampleRate, TuningProvider *provider)
{
// pitch is in "MIDI note - 69" space (0 = A440)
auto freq = 440.f * FilterCoefficientMaker<TuningProvider>::provider_note_to_pitch(provider, pitch);
return std::clamp(freq, 5.f, sampleRate * 0.49f);
}
// ── Coefficient setup (called at control rate) ────────────────────────────────
template <typename TuningProvider>
inline void makeCoefficients(FilterCoefficientMaker<TuningProvider> *cm,
float pitch, float reso,
float sampleRate, TuningProvider *provider)
{
float lC[sst::filters::n_cm_coeffs]{};
auto freq = clampedFrequency(pitch, sampleRate, provider);
// freq is now in Hz; convert to bilinear pre-warped form:
auto wd = freq * 2.f * (float)M_PI;
auto wa = (2.f * sampleRate) * sst::basic_blocks::dsp::fasttan(wd * 0.5f / sampleRate);
auto g = wa / (2.f * sampleRate);
lC[mf_g] = g / (1.f + g); // TPT "alpha"
lC[mf_R] = std::clamp(2.f * (1.f - reso), 0.02f, 2.f);
lC[mf_gain] = 1.f;
cm->FromDirect(lC); // Sets C[], tC[], dC[] and handles smoothing
}
// ── Sample-rate processing function (called per sample, 4-wide SIMD) ─────────
inline SIMD_M128 process(QuadFilterUnitState *__restrict f, SIMD_M128 in)
{
// 1. Advance all coefficients by their delta (interpolation step)
for (int i = 0; i < n_mf_coeff; ++i)
f->C[i] = SIMD_MM(add_ps)(f->C[i], f->dC[i]);
// 2. Unpack coefficients into named locals for readability
auto g = f->C[mf_g];
auto R = f->C[mf_R];
auto gain = f->C[mf_gain];
// 3. Filter topology (example: Chamberlin SVF lowpass)
// s1, s2 are the two state registers
auto &s1 = f->R[mf_s1];
auto &s2 = f->R[mf_s2];
auto hp = SIMD_MM(sub_ps)(SIMD_MM(sub_ps)(in, SIMD_MM(mul_ps)(R, s1)), s2);
auto bp = SIMD_MM(add_ps)(s1, SIMD_MM(mul_ps)(g, hp));
auto lp = SIMD_MM(add_ps)(s2, SIMD_MM(mul_ps)(g, bp));
// Update state
s1 = SIMD_MM(add_ps)(bp, SIMD_MM(mul_ps)(g, hp)); // next s1
s2 = SIMD_MM(add_ps)(lp, SIMD_MM(mul_ps)(g, bp)); // next s2
return SIMD_MM(mul_ps)(lp, gain);
}
} // namespace sst::filters::MyFilter
#endif // INCLUDE_SST_FILTERS_MYFILTER_H
Key rules:
- Always advance ALL used coefficient indices at the start (not a subset).
FromDirect(lC)handles the smoothingC = C*0.8 + target*0.2and delta computation automatically.- State registers
R[]persist across samples — never reset them inside the process function. - The
active[]mask is applied by the infrastructure; you don't need to handle inactive voices.
Step 2: Add enum values to FilterConfiguration.h
File: include/sst/filters/FilterConfiguration.h
// In enum FilterType, before num_filter_types:
fut_myfilter,
// In enum FilterSubType, add subtypes if needed:
st_myfilter_lp = ...,
st_myfilter_hp = ...,
Check existing highest values to avoid collisions. num_filter_types is the sentinel — insert before it.
Step 3: Wire up the coefficient maker dispatcher
File: include/sst/filters/FilterCoefficientMaker_Impl.h
In FilterCoefficientMaker::MakeCoeffs(), add a case to the switch on Type:
#include "MyFilter.h" // add near other includes at top of file
// Inside the switch(Type):
case fut_myfilter:
MyFilter::makeCoefficients(this, Freq, Reso, sampleRate, provider);
break;
Step 4: Wire up the sample-rate dispatcher
File: include/sst/filters/QuadFilterUnit_Impl.h
Add #include "MyFilter.h" near the top with the other filter includes.
In GetCompensatedQFPtrFilterUnit<Compensated>(), add a case to the switch on type:
case fut_myfilter:
switch (subtype) {
case st_myfilter_lp:
return MyFilter::process_lp;
case st_myfilter_hp:
return MyFilter::process_hp;
default:
return MyFilter::process; // or nullptr if invalid
}
break;
If you only have one variant, just return MyFilter::process; directly without a subtype switch.
Step 5: Add to the filters++ API
Create: include/sst/filters++/models/MyFilter.h
#ifndef INCLUDE_SST_FILTERS_PLUS_PLUS_MODELS_MYFILTER_H
#define INCLUDE_SST_FILTERS_PLUS_PLUS_MODELS_MYFILTER_H
#include "sst/filters.h"
namespace sst::filtersplusplus::models::myfilter
{
inline const details::FilterPayload::configMap_t &getModelConfigurations()
{
namespace sft = sst::filters;
static details::FilterPayload::configMap_t configs{
// { {Passband, [Slope], [DriveMode], [SubModel]}, {FilterType, FilterSubType} }
{{Passband::LP, Slope::Slope_12dB, DriveMode::Standard},
{sft::FilterType::fut_myfilter, sft::FilterSubType::st_myfilter_lp}},
{{Passband::HP, Slope::Slope_12dB, DriveMode::Standard},
{sft::FilterType::fut_myfilter, sft::FilterSubType::st_myfilter_hp}},
};
return configs;
}
} // namespace sst::filtersplusplus::models::myfilter
#endif // INCLUDE_SST_FILTERS_PLUS_PLUS_MODELS_MYFILTER_H
No #pragma once anywhere in this repo — every header uses
INCLUDE_SST_FILTERS_<PATH>_H style guards. Match the neighbouring files exactly.
Edit: include/sst/filters++/enums.h — add to enum struct FilterModel:
MyFilter = 0xNN, // pick next available hex slot
Edit: include/sst/filters++/details/filter_payload.h
Add at the bottom of the #include block:
#include "../models/MyFilter.h"
And in BOTH resolveLegacyTypeFor() and availableModelConfigurations(), add the macro call:
FILTER_MODEL_CASE(FilterModel::MyFilter, models::myfilter);
Edit: include/sst/filters++/details/filter_impl.h
In availableModels(), add FilterModel::MyFilter to the returned vector.
Step 6: Add toString support
File: include/sst/filters++/enums_to_string.h (or wherever toString(FilterModel) is defined)
Add:
case FilterModel::MyFilter: return "MyFilter";
Step 7: Write the test
Create: tests/MyFilterTest.cpp
#include "TestUtils.h"
TEST_CASE("MyFilter")
{
using namespace TestUtils;
namespace sfpp = sst::filtersplusplus;
SECTION("Lowpass") {
// First run with printRMSs = true in TestUtils.h to generate expected values,
// then paste them in here and set printRMSs back to false.
runTest({FilterType::fut_myfilter,
FilterSubType::st_myfilter_lp,
{-X.Xf, -X.Xf, -X.Xf, -X.Xf, -X.Xf}}); // RMS dB at 80,200,440,1000,10000 Hz
runTest(sfpp::FilterModel::MyFilter,
{sfpp::Passband::LP, sfpp::Slope::Slope_12dB, sfpp::DriveMode::Standard},
0.f, 0.5f,
{-X.Xf, -X.Xf, -X.Xf, -X.Xf, -X.Xf});
}
}
Edit: tests/CMakeLists.txt — add MyFilterTest.cpp to the test sources.
To capture golden values: Set constexpr bool printRMSs = true; in TestUtils.h, run the test, copy the printed arrays, paste them into the test, set back to false.
Test Setup Parameters
Tests use these constants (from TestUtils.h):
sampleRate = 48000.0fblockSize = 2048cutoffFreq = 0.0f(MIDI note offset, i.e. A440 = note 69, so 0 means A440)resonance = 0.5f- Test frequencies:
{80, 200, 440, 1000, 10000}Hz - Tolerance:
1e-2fdB (Approx margin)
The runTest() helper in TestUtils.h runs both the classic API and (optionally) the ++ API and checks results against expected dB values.
Notes on Converting a Scalar Implementation
Given scalar code like:
float process(float in, float &s1, float &s2, float g, float R) {
float hp = in - R*s1 - s2;
float bp = s1 + g*hp;
float lp = s2 + g*bp;
s1 = bp + g*hp;
s2 = lp + g*bp;
return lp;
}
Each scalar operation becomes a SIMD operation:
a + b→SIMD_MM(add_ps)(a, b)a - b→SIMD_MM(sub_ps)(a, b)a * b→SIMD_MM(mul_ps)(a, b)float f→SIMD_MM(set1_ps)(f)when used as a scalar constant in SIMD mathfloat &s1→SIMD_M128 &s1 = f->R[mf_s1]float g→auto g = f->C[mf_g](already SIMD from updateState)std::clamp(x, lo, hi)→SIMD_MM(max_ps)(SIMD_MM(min_ps)(x, hi_vec), lo_vec)std::tanh(x)→fasttanhSSEclamped(x)std::tan(x)→sst::basic_blocks::dsp::fasttan(x)(scalar, used in makeCoefficients only)
Coefficient computation (makeCoefficients) stays scalar — it runs at control rate where SIMD isn't needed.
Common Pitfalls
- n_cm_coeffs = 8 always — you can't use more than 8 coefficients. If you need more, pack multiple parameters into one coefficient, or recompute some inside the process function from others.
- n_filter_registers = 16 — max 16 SIMD state registers.
- Don't reset R[] in process() — state is persistent across samples by design.
- Coefficient interpolation —
dC[]is automatically set byFromDirect(). You must callC[i] += dC[i]for every coefficient index you USE in process(), even if it doesn't change, so interpolation works correctly. - ModelConfig hashing —
ModelConfigis a tuple of{Passband, Slope, DriveMode, FilterSubModel}. Unused fields default toUNSUPPORTED. TheconfigMap_tusesstd::unordered_mapsoModelConfigmust be hashable (it is, via the existing hash specialization). - FilterSubType values — these are plain integers; new values must not overlap with existing ones. Check
FilterConfiguration.hcarefully.
The ModelConfig enums
include/sst/filters++/enums.h defines four enums, all sparse hex so new values slot in
without renumbering. Read the file for current values — these are the shapes:
FilterModel— one per model (CytomicSVF,K35,DiodeLadder,OBXD_*,VintageLadder,CutoffWarp,ResonanceWarp,TriPole,Comb,SampleAndHold,VemberClassic,VemberLadder).Passband— LP, HP, BP, Notch, and friends.Slope—Slope_6dB/12dB/18dB/24dB, the asymmetric pole-mixing combinations (Slope_6dB12dB,Slope_12dB6dB, …),Slope_Morph, and the comb mixes.DriveMode—Standard,Clean,Driven,NotchMild, plus model-specific families such as theK35_*drive levels.FilterSubModel— model-specific variants, e.g. the vintage ladder'sRungeKutta,Huov,Huov2010and their compensated forms.
A ModelConfig is {Passband, Slope, DriveMode, FilterSubModel} with UNSUPPORTED for
whichever axes a model does not use. getModelConfigurations() maps each valid combination
to its legacy {FilterType, FilterSubType} pair — that map is the definition of what the
model supports, so a combination missing from it is a combination the UI will not offer.
enums.h carries the reminder that adding a FilterModel means adding it to
Filter::availableModels in details/filter_impl.h too. Both places, every time.
Legacy FilterType and FilterSubType live in include/sst/filters/FilterConfiguration.h.
FilterSubType values are plain integers shared across filters — check for collisions.
Current FilterModel high watermark: SampleAndHold = 0x85 — next slot would be 0x90 or similar.