# Sst Filters

> sst-filters: Implementing a New Filter

- Skill: `baconpaul/sst-filters` (Agent Skill)
- Install (CLI): `npx skillmds@latest add baconpaul/sst-filters`
- Raw SKILL.md: https://api.skillmd.com/api/skills/baconpaul/sst-filters/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: baconpaul (https://skillmd.com/u/baconpaul)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/baconpaul/sst-filters

---


# 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.

```bash
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 in `C[]`. It also computes `dC[] = (target - current) / blockSize` for smooth interpolation, and `tC[]` (target).
- **Sample rate** (per sample): a `FilterUnitQFPtr` function operates on a `QuadFilterUnitState` which holds SIMD versions of those 8 coefficients in `C[]` / `dC[]`, plus 16 SIMD state registers `R[]`. Each sample, it first advances `C[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`:

```cpp
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):
```cpp
// 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 approximation
- `fasttan(x)` — tan approximation
- `sst::basic_blocks::dsp::softclip_ps(x)` — soft clip

---

## Key Struct: QuadFilterUnitState

```cpp
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:

```cpp
#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 smoothing `C = C*0.8 + target*0.2` and 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`

```cpp
// 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`:

```cpp
#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`:

```cpp
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`

```cpp
#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`:
```cpp
MyFilter = 0xNN,   // pick next available hex slot
```

**Edit:** `include/sst/filters++/details/filter_payload.h`

Add at the bottom of the `#include` block:
```cpp
#include "../models/MyFilter.h"
```

And in BOTH `resolveLegacyTypeFor()` and `availableModelConfigurations()`, add the macro call:
```cpp
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:
```cpp
case FilterModel::MyFilter: return "MyFilter";
```

---

### Step 7: Write the test

**Create:** `tests/MyFilterTest.cpp`

```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.0f`
- `blockSize = 2048`
- `cutoffFreq = 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-2f` dB (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:
```cpp
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 math
- `float &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 by `FromDirect()`. You must call `C[i] += dC[i]` for every coefficient index you USE in process(), even if it doesn't change, so interpolation works correctly.
- **ModelConfig hashing** — `ModelConfig` is a tuple of `{Passband, Slope, DriveMode, FilterSubModel}`. Unused fields default to `UNSUPPORTED`. The `configMap_t` uses `std::unordered_map` so `ModelConfig` must 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.h` carefully.

---

## 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 the `K35_*` drive levels.
- **`FilterSubModel`** — model-specific variants, e.g. the vintage ladder's `RungeKutta`,
  `Huov`, `Huov2010` and 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.
