Add an Oberheim-SEM morph law to the SVF filter: HP->notch->LP alongside HP->BP->LP, selected at prepare() time, free on the per-sample path

This commit is contained in:
2026-07-30 10:22:49 -04:00
parent f12700c997
commit d2364eb5ac
10 changed files with 971 additions and 483 deletions
+52 -16
View File
@@ -3,7 +3,8 @@
## Scope
The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
HP→BP→LP morph and a drive stage. No REAPER, no VST3, no allocation, no I/O. Everything
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 — this keeps `FilterSettings` and friends out of
`reasampler::instrument::engine` proper, where `zone_params.h` lives, since this module has
@@ -14,8 +15,9 @@ responsibility each:
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: normalized position → per-tap weights, and the fold of
those weights into the three multipliers the kernel applies.
- `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.
@@ -63,21 +65,50 @@ 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, BP at 0.5, LP at 1.0,
continuous throughout, and the three endpoints are exact.
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 between adjacent taps**, and both halves of that are forced
by the topology rather than picked by ear:
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.
- At the corner the taps are `HP = jQ`, `BP = Q`, `LP = -jQ` — adjacent taps in exact
quadrature, which the bilinear transform preserves exactly at the prewarped corner. A
`cos`/`sin` pair therefore holds the corner magnitude at exactly `Q*sqrt(cos² + sin²) = Q`
at every morph position. A linear crossfade of a quadrature pair would sag to `Q/sqrt(2)`
mid-leg — a 3 dB hole that reads as a defect, not as character.
- **Adjacent only.** HP and LP are exactly antiphase at the corner, so any law giving both
simultaneous weight cancels there and cuts a notch. That notch is the Oberheim SEM's
centre tap. This control's centre is a band-pass, per the explicit HP/BP/LP enumeration —
do not "simplify" the two legs into one three-way weighting, which silently builds the SEM.
### 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 across every rate/cutoff/Q, typically 110 to 145 dB. The fold
makes that 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
@@ -183,6 +214,11 @@ topology.
- **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.
- **No call site yet.** Wiring the filter into the voice path is a separate track; nothing
in `sampler_core` references this module today.
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
@@ -23,10 +23,20 @@ Pair equalPower(double t) {
} // namespace
MorphWeights morphWeights(float norm) {
MorphWeights morphWeights(float norm, MorphLaw law) {
const double n = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : static_cast<double>(norm));
MorphWeights w;
if (law == MorphLaw::HighNotchLow) {
// ONE crossfade across the whole sweep rather than two legs, so HP and LP carry weight
// together everywhere between the endpoints and are equal at the centre.
const Pair p = equalPower(n);
w.hp = static_cast<float>(p.a);
w.bp = 0.0f;
w.lp = static_cast<float>(p.b);
return w;
}
if (n <= 0.5) {
const Pair p = equalPower(2.0 * n); // HP -> BP
w.hp = static_cast<float>(p.a);
@@ -1,34 +1,55 @@
// filter_morph.h — the continuous HP -> BP -> LP morph: normalized position to tap weights,
// and the fold of those weights into the three multipliers the kernel actually applies. An SVF
// produces all three taps from one state, so the morph is a blend, never a coefficient switch.
// filter_morph.h — the continuous morph: normalized position to tap weights under one of two
// laws, and the fold of those weights into the three multipliers the kernel actually applies.
// An SVF produces all three taps from one state, so the morph is a blend, never a coefficient
// switch. Weights are computed at prepare() cadence; the law never reaches the per-sample path.
#pragma once
namespace reasampler::instrument::engine::filter {
// Weight on each SVF tap. Exactly one of hp/lp is nonzero at a time — the morph crossfades
// between ADJACENT taps only, never HP against LP.
// Which shape the sweep traces between its two fixed endpoints. This selects CHARACTER, not
// topology — same SVF, same coefficients, same kernel under either law; only the centre differs.
//
// HighBandLow is enumerator 0 deliberately: a zero-initialized or absent persisted field then
// lands on the default rather than on the SEM leg.
enum class MorphLaw {
// HP -> BP -> LP. Crossfades ADJACENT taps only, so the corner magnitude is flat at Q the
// whole way across. The default.
HighBandLow,
// HP -> notch -> LP, the Oberheim SEM. One crossfade weighting HP and LP together, bp == 0
// throughout; the notch falls out of the antiphase cancellation rather than being tuned in.
HighNotchLow,
};
// Weight on each SVF tap. Under HighBandLow exactly one of hp/lp is nonzero at a time — that law
// crossfades adjacent taps only, never HP against LP. Under HighNotchLow bp is always zero and
// hp/lp carry weight together, which is precisely what cuts the notch.
struct MorphWeights {
float hp = 0.0f;
float bp = 0.0f;
float lp = 1.0f;
};
// HP at 0.0, BP at 0.5, LP at 1.0. Out-of-range norm clamps to the endpoints; NaN clamps to
// neither and lands on pure band-pass instead (every comparison against it is false).
// HP at 0.0, LP at 1.0 under BOTH laws; the centre is a band-pass under HighBandLow and a notch
// under HighNotchLow. Out-of-range norm clamps to the endpoints; NaN clamps to neither (every
// comparison against it is false) and lands on the law's degenerate — pure band-pass under
// HighBandLow, pure high-pass under HighNotchLow, which has no band tap to land on.
//
// Equal-power (cos/sin) rather than linear, and that choice is forced by the topology rather
// than picked by ear. At the corner frequency the three taps are HP = jQ, BP = Q, LP = -jQ, so
// adjacent taps are in exact QUADRATURE there (and the bilinear transform preserves that exactly
// at the prewarped corner). Under a cos/sin pair the corner magnitude is therefore
// Q*sqrt(cos^2 + sin^2) = Q at every morph position — algebraically flat across the whole sweep.
// A linear crossfade of the same quadrature pair would sag to Q/sqrt(2), a 3 dB hole mid-leg.
// Equal-power (cos/sin) in both laws rather than linear, and that choice is forced by the
// topology rather than picked by ear. At the corner frequency the three taps are HP = jQ,
// BP = Q, LP = -jQ, so ADJACENT taps are in exact QUADRATURE there (and the bilinear transform
// preserves that exactly at the prewarped corner). Under HighBandLow's cos/sin pair the corner
// magnitude is therefore Q*sqrt(cos^2 + sin^2) = Q at every morph position — algebraically flat
// across the whole sweep. A linear crossfade of the same quadrature pair would sag to Q/sqrt(2),
// a 3 dB hole mid-leg.
//
// Crossfading adjacent taps only is the other half of it: HP and LP are exactly ANTIPHASE at the
// corner, so any law giving both simultaneous weight cancels there and cuts a notch. That notch
// is the Oberheim SEM's center tap; this control's center is a band-pass, per the explicit
// HP/BP/LP enumeration.
MorphWeights morphWeights(float norm);
// HP and LP are exactly ANTIPHASE at the corner (+90 and -90 degrees), so a law giving both
// simultaneous weight cancels there. HighBandLow avoids that by staying adjacent; HighNotchLow
// uses it — one equal-power crossfade of HP against LP over the whole sweep puts equal weights
// at the centre and the null is exact by construction, not tuned. That is why the corner-flat-at-Q
// guarantee is specific to HighBandLow: on the SEM leg the corner magnitude deliberately goes to
// zero at the centre. Equal power still holds off the notch frequency, so neither law's legs sag.
MorphWeights morphWeights(float norm, MorphLaw law);
// The kernel applies out = m0*v0 + m1*v1 + m2*v2, where v0 is the input and v1/v2 are the SVF's
// band and low outputs. Folding hp = v0 - k*v1 - v2 into the weights here keeps the per-sample
@@ -6,7 +6,7 @@ void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm),
filterQFromNorm(settings.resonanceNorm), sampleRate);
if (sampleRate > 0.0) {
mix_ = morphMix(morphWeights(settings.morphNorm), coeffs_.k);
mix_ = morphMix(morphWeights(settings.morphNorm, settings.morphLaw), coeffs_.k);
} else {
// Bypass: a1=1, a2=a3=0 makes both state updates the exact identity, and bypassMix()
// reads only the input, never the state -- so clearing here is audibly free (the state
@@ -15,11 +15,14 @@
namespace reasampler::instrument::engine::filter {
// Normalized control positions, as the editor moves them and the persisted state carries them.
// morphLaw is the one discrete control here — a two-value selector, not a normalized position —
// because its two values are characters to choose between, not points on a continuum.
struct FilterSettings {
float cutoffNorm = 1.0f;
float resonanceNorm = 0.0f;
float morphNorm = 1.0f; // 0 = high-pass, 0.5 = band-pass, 1 = low-pass
float morphNorm = 1.0f; // 0 = high-pass, 1 = low-pass; the centre is set by morphLaw
float driveNorm = 0.0f;
MorphLaw morphLaw = MorphLaw::HighBandLow;
};
// Below this the recursion has decayed past -600 dB. Flushing keeps the state out of the