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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user