# src/core/instrument/engine/filter — the per-voice resonant filter

## Scope

The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
VST3, no allocation, no I/O. Everything
here lives in `reasampler::instrument::engine::filter`, nested per the
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
merely tidy. Five files, one responsibility each:

- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
  drive depth, plus the exact inverses for cutoff and Q.
- `filter_coeffs` — the DSP domain: `SvfCoeffs` and the TPT coefficient solve from
  (cutoff Hz, Q, sample rate).
- `filter_morph` — the morph domain: `MorphLaw`, normalized position → per-tap weights under
  the selected law, and the fold of those weights into the three multipliers the kernel
  applies.
- `filter_saturate` — `softLimit`, the drive stage's shaper. Header-only inline; it sits
  inside the per-sample recursion.
- `voice_filter` — `FilterSettings` and `VoiceFilter`, the concrete per-voice type.
  `process()` is defined in the header.

### The cutoff is the only control that re-solves per frame, and it re-solves alone

`prepare()` is the full solve; `setCutoffNorm()` is the per-frame one. The split exists because
**a modulated corner must move continuously** — Daniel's ruling, replacing a retired 2048-step
quantizer that staircased the sweep in ~5.8-cent jumps — and a full `prepare()` per frame is the
wasteful way to buy that. Only `g = tan(pi*fc/sr)` depends on cutoff: Q's parabola, the morph's
`cos`/`sin`, and the folded mix (a function of the weights and `k` alone) do not, so
`setCutoffNorm` re-derives none of them and reuses the `q_` cached at `prepare()`.

Measured at 48 kHz, MSVC `/O2`, net of the sweep generator and the kernel:

| per frame | ns |
|---|---|
| kernel alone, no re-solve | 2.8 |
| full `prepare()` | 56.9 |
| `setCutoffNorm`, constant logs recomputed | 22.8 |
| `setCutoffNorm`, constant logs hoisted (shipped) | 15.5 |

The last row is 16 voices of continuously-swept filter at ~1.2% of one core — affordable, which
is why nothing approximates `tan` here. The hoist is in `filter_params.cpp`: the sweep endpoints
and the Q parabola are functions of compile-time constants, and recomputing those five
logarithms per frame cost more than the solve they fed. **Do not put a quantizer back on the
control value to save the solve** — make the solve cheaper instead.

## Invariants

### No vtable on the per-sample path

The Cortex-M4 source this began as was a virtual hierarchy (`FilterBase` → `Filter` →
`Biquad` → `{BiquadHP, BiquadLP}`) whose base class routed the channel loop through
pure-virtual `process_channel_frame` / `filter` / `update_feedback` so a `FilterDecorator`
chain could wrap it. **None of that came across, and none of it may come back.**
`VoiceFilter` is concrete, `process()` is inlined, and there is no `IFilter`, no decorator
seam, no virtual `tick()`, and no allocation in `process()` — root `CLAUDE.md`'s structural
heuristic 3 names this class of dispatch blowout directly.

### The rate enters ONLY through `g = tan(pi*fc/sr)`

There is no reference sample rate, calibration rate, or fallback rate anywhere in this
module, and introducing one is the specific regression to guard against. An earlier design
carried a `kFilterFeedbackDelaySeconds = 1/48000` tuning constant for a feedback tap; that
tap, its ring buffer, and the constant are all deleted. A non-positive rate yields `g == 0`
and a bypass mix (signal passes through) — never an invented rate.

### Why the high-pass feedback tap was right on Q15 hardware and wrong here

The ported firmware fed a saturated share of an earlier output back into the high-pass
input. Its stated rationale — that the HP numerator collapses toward zero at low cutoff,
taking the resonance with it — is **inverted**, and the comment asserting it has been
removed rather than carried forward. Measurement: the HP `b0` approaches **1** as cutoff
falls (0.99987 at 20 Hz); it is the **low-pass** `b0` that collapses (1.7e−06 at 20 Hz).

The tap was a Q15 fixed-point workaround. At 16-bit fixed point the low-cutoff biquad loses
a ~17-bit cancellation and the resonance really does die; the feedback injected it back by
another route. float32 survives that cancellation with 7 bits to spare, so on this target
the tap did not restore character — it *reduced* it (HP landed 0.4% off the analytic RBJ
target with the tap disabled, and 25% off with it enabled), and it introduced both level
dependence and rate dependence.

Daniel's ruling on the level-dependent resonance bloom it produced: *"was a feature on the
hardware (one knob colorful HP for master FX), wrong choice for this approach."* Drive is
now an explicit user-controlled stage instead of an emergent side effect.

### The morph is a blend of taps, never a coefficient switch

An SVF produces high, band, and low from the same state, which is the reason this topology
was chosen. `FilterMode` as a discrete enum is retired. HP at 0.0, LP at 1.0, continuous
throughout, and both endpoints are exact under either law — only the centre differs.

The crossfade is **equal-power** in both laws, and that is forced by the topology rather
than picked by ear. At the corner the taps are `HP = jQ`, `BP = Q`, `LP = -jQ` — adjacent
taps in exact quadrature and HP/LP in exact antiphase, relationships the bilinear transform
preserves exactly at the prewarped corner. A `cos`/`sin` pair therefore holds the crossfaded
power at unity across the whole sweep; a linear crossfade of a quadrature pair would sag to
`1/sqrt(2)` mid-leg, a 3 dB hole that reads as a defect rather than as character.

### The two morph laws, and why only one of them has a flat corner

`MorphLaw` is a two-value selector on `FilterSettings`, **defaulting to `HighBandLow`** —
that is the reviewed-and-measured law, and it is enumerator 0 so a zero-initialized or absent
persisted field lands on it rather than on the SEM leg.

- **`HighBandLow` (HP→BP→LP, the default).** Two equal-power legs crossfading **adjacent taps
  only**, BP at the centre. Because adjacent taps are in quadrature, the corner magnitude is
  algebraically `Q*sqrt(cos² + sin²) = Q` at every position — measured flat to 4e-6 across 65
  positions. **That flatness guarantee is specific to this law.** Do not weaken the assertion
  that pins it in order to accommodate the other law.
- **`HighNotchLow` (HP→notch→LP, the Oberheim SEM).** One equal-power crossfade weighting HP
  and LP **together** across the whole sweep, `bp == 0` throughout. The notch is not tuned in:
  HP and LP sit at exactly +90° and −90° at the corner, so equal weights cancel there by
  construction. Here the corner magnitude deliberately goes to **zero** at the centre —
  measured worst case −88 dB on the shipped `{250, 1000, 4000}` Hz cutoff grid, typically −110 to
  −145 dB. Over the full control range (20 Hz – 20 kHz, Q 0.1 – 10) the worst residual is
  shallower — −69.8 dB at 192 kHz / 30 Hz / Q=10 — from float conditioning in the folded
  `x − k·v1` term as `fc/sr → 1e-4` at high Q; it is Q-dependent (Q=0.1 holds −110 dB everywhere)
  and still an excellent notch, not a broadband defect. `test_filter.cpp`'s null test covers this
  full range with a Q-scaled threshold rather than the flat −74 dB the shipped grid alone would
  justify. The fold makes the centre's cancellation structural rather than a runtime near-miss:
  `m2 = lp - hp` is **exactly** `0.0f` at the centre, because `cos` and `sin` of π/4 differ by
  about an ulp of *double*, nine orders below float's spacing there, so they narrow to one float.

SEM's zero is at the **notch frequency**, not a broadband level sag — off the corner the pair
is still equal-power, so neither law's legs dip. Measuring that requires dividing by each
tap's own analytic response first: at `Q = 0.1` a 2-pole approaches its passband so slowly
that the pure low tap still reads 0.896 at 50 Hz, and a raw reading would report a 20% "sag"
that is the Q, not the morph.

**The toggle is free on the hot path, and must stay that way.** `morphWeights` runs at
`prepare()` cadence; the law is consumed there and nowhere else. The kernel, `svfCoeffs`, and
`morphMix`'s fold are identical between the laws — all a law selects is three floats the
kernel was already multiplying by. Verified at the machine-code level, not by inspection: the
same TU compiled `/O2` against the pre-toggle and post-toggle headers emits byte-identical
assembly for `process()` and `processFrame()`. `VoiceFilter` gained no member and `process()`
gained no branch. A design that puts the law selector inside the per-sample path is wrong —
rework it rather than paying for it.

### Drive is a contraction inside the loop, which is what makes it unconditionally stable

`softLimit(u, depth) = u / sqrt(1 + (depth*u)²)` shapes the **band-pass integrator state**.
Three properties carry the design:

- `depth == 0` makes it algebraically the identity (`x / sqrt(1) == x`, exact in IEEE), so
  drive 0 is **bit-exact** linear whether or not `softLimit` is actually called. The test
  asserts bit-identity against the same kernel with the limiter deleted.
- `process()` gates the call on `driven_` (`driveDepth_ != 0`, cached at `prepare()`) rather
  than calling `softLimit` unconditionally. `sqrt`/div sit on the per-sample recursive
  dependency chain, so out-of-order execution can't hide their latency, and at drive 0 that
  cost buys nothing. Measured: 11.2 ns/sample unconditional vs 4.1 ns gated — the gated form
  lands at the limiter-removed floor. `driven_` only changes at `prepare()`, so the branch
  predicts perfectly. The gate is a perf optimization on top of the bit-identity above, not a
  substitute for it — deleting the gate would still be correct, just 2.7x slower at rest.
- `|softLimit(u, d)| <= |u|` for every depth, so the state update can only shrink the state.
  The filter cannot gain energy from the drive stage: stability at any Q and any cutoff is
  structural, and self-oscillation is impossible. This is why the shaper must keep unit slope
  at the origin — a shaper with gain above 1 there turns the resonator into an oscillator.
- It shapes the **state**, not the zero-delay loop. A nonlinearity inside the loop would
  break the closed-form `a1`/`a2`/`a3` solve and need per-sample Newton iteration.

Placement is the resonance path because that is where the firmware's character came from,
and because the band-pass state sits at zero in the passband and at DC — so drive colours
the resonance and leaves the passband transparent (measured 0.98 at max drive). It is not a
distortion box in series with the signal; a caller wanting that has every other plugin.

**Drive × resonance interact by design.** What reaches the shaper is the resonance state,
already multiplied by roughly `2*Q`, so the same drive setting bites harder the more
resonance is dialled in — and harder on a hotter input. That level dependence is the
*point* of an explicit drive control; what Daniel rejected was level dependence nobody
asked for. At drive 0 there is none, to 0.0004% over a 1000:1 level range.

`kFilterDriveDepthMax` (4.0) was set against measurement, not feel: at max drive, full-scale
input and max resonance the resonant peak lands ~10 dB under the passband — plainly
crushed, which is the asked-for "extreme". Raising it further inverts the filter's shape
(21 dB under passband at depth 64), turning the peak the user dialled in into a notch.
There is deliberately **no makeup gain** — any law for it would be invented rather than
derived, and drive is due an ear pass against the radial dial.

### The cutoff control is sample-rate-free; the clamp is not

`filterCutoffHzFromNorm` sweeps a fixed 20 Hz – 20 kHz (three exact decades, so norm 1/3 is
200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the normalized
knob position, so a rate-derived endpoint would make one preset sound different at 44.1k and
96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the bilinear
transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `svfCoeffs` where the rate
is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the clamp never eats
live knob travel there; the source's hardcoded 23 kHz endpoint did exactly that at 44.1k.

### Q spans 0.1 → 10 with √2 at the center

Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at 0.707, no
center anchor) was **rewritten, not ported**. The curve is quadratic in log Q through the
three anchors rather than two spliced log segments — same anchors either way, but no slope
kink at the center detent. The quadratic term is nonzero only because √2 is not the
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it. The SVF consumes it as
`k = 1/Q`.

### Denormal flushing: why conjunctive, honestly

`process()` flushes **both** integrators to exact zero once both are below
`kFilterDenormalFloor` (1e-30). The honest reason is narrower than it sounds: `isSilent()`
means "both integrators are exactly zero," so both have to reach zero for that check to mean
anything, and the conjunctive test is the cheapest way to guarantee it.

The stronger claim — that a per-variable flush limit-cycles at the floor — does **not**
reproduce on this topology. Measured (Q=10, fc=1kHz, 48k): shipped conjunctive goes silent at
sample 10783 with 0 subnormals; a per-variable independent flush goes silent ~180 samples
earlier and an either-below-zero-both flush ~970 samples earlier, both also 0 subnormals, no
limit cycle, and the same excited RMS. That claim WAS real on the retired Direct Form I state,
where the flushed variables (`y1`/`y2`) were the actual filter OUTPUT, so zeroing one injected
a discontinuity the resonance then amplified. Here `ic1`/`ic2` are integrator STATE, not
output: zeroing one only removes energy, a contraction rather than an injection, so the hazard
is structurally absent. The only demonstrable hazard is no flush at all, which never reaches
exact zero and grinds through subnormals for thousands of samples on a released voice.

Keep the conjunctive test regardless — it costs nothing extra and is the right guarantee for
`isSilent()` — but don't cite the limit-cycle rationale for TPT; it belongs to the retired
topology.

## Gotchas

- **TPT is what fixed the low-cutoff conditioning defect** — this is a topology change, not
  a relocation. Direct Form I encoded pole proximity in `a1 → -2`, `a2 → +1` and cancelled
  them against each other every sample; at `fc/sr ≈ 1e-4` that ~17-bit cancellation moved the
  measured 20 Hz / 192 kHz LP peak by **-27% on a true-peak scan, -57% measured at the
  analytic peak frequency** (the degraded pole itself moves, so the two methods diverge), and
  the error is non-monotone with rate rather than a fixed percentage (+5% high at 96 kHz).
  TPT encodes the same proximity in `a1`'s small deviation from 1, which float32 resolves:
  checked against an exact-double evaluation of the same difference equation (which matches
  the analytic target to within measurement noise), TPT's float32-narrowed coefficients are
  genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
  narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
  either way. Do not reintroduce a direct-form kernel.
- **A coefficient jump here produces no isolated output spike, measured.** Preserving state
  across `prepare()` is strong enough that even an instantaneous cutoff/Q/morph jump leaves the
  boundary frame inside the signal's own frame-to-frame range — a single-frame-spike metric
  cannot detect one. What the caller's per-frame glide prevents is therefore the *parameter*
  arriving as a step (and the zipper of repeated steps at control rate), not a click at the
  jump itself. A test claiming to prove the glide must measure how fast the output diverges,
  not how far one frame moves; `live_delivery_tests` does.
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
  click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
  `a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
  the state at all, so a stale nonzero `ic1`/`ic2` would otherwise latch `isSilent()` false
  forever with no audible effect either way — `prepare()` clears state on that path only,
  which costs nothing audibly since bypass ignores it.
- **The morph endpoints are asserted on the folded mix, exactly.** `morphWeights` snaps the
  leg endpoints instead of trusting `cos`/`sin` to land on 0 and 1, which they miss by ~1e-17
  — enough to leave a -324 dB neighbour tap in what is specified as a pure response.
- **A NaN morph position falls back per law, not to one shared value.** Every comparison
  against NaN is false, so it clamps to neither endpoint: `HighBandLow` lands on pure
  band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
  transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
  It re-solves the corner on **every frame the modulated cutoff actually moves — unquantized**,
  so the corner glides rather than staircasing.
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
  budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
  is ample at 48k and expires mid-decay at 96k and above.
