Rebuild the instrument filter as a TPT/SVF with a continuous HP-BP-LP morph and a configurable drive stage

This commit is contained in:
2026-07-30 09:14:13 -04:00
parent 7d42d7ed29
commit 902030bfba
12 changed files with 888 additions and 812 deletions
+11 -9
View File
@@ -995,15 +995,16 @@ target_link_libraries(curve_popup PUBLIC editor_geometry)
add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp)
target_include_directories(master_gain PUBLIC src)
# filter — the per-voice 2-pole resonant low/high-pass, ported from Daniel's Cortex-M4 filter
# with its virtual FilterBase/Filter/Biquad hierarchy flattened away (that hierarchy dispatched
# virtually per channel per sample, which the per-voice per-sample path forbids). Control
# mapping, RBJ coefficient math, feedback saturation, and the filter type each get their own
# file; VoiceFilter::process is header-inline so the biquad kernel still inlines at the call
# site. Standard library only. NEITHER SDK.
# filter — the per-voice TPT/SVF with a continuous HP->BP->LP morph and an in-loop drive stage.
# The Cortex-M4 source's virtual FilterBase/Filter/Biquad hierarchy dispatched per channel per
# sample, which the per-voice per-sample path forbids, so none of it came across. Control
# mapping, SVF coefficients, morph weights, and the filter type each get their own file;
# VoiceFilter::process is header-inline so the kernel still inlines at the call site. Standard
# library only. NEITHER SDK.
add_library(filter STATIC
src/core/instrument/engine/filter/filter_params.cpp
src/core/instrument/engine/filter/filter_coeffs.cpp
src/core/instrument/engine/filter/filter_morph.cpp
src/core/instrument/engine/filter/voice_filter.cpp)
target_include_directories(filter PUBLIC src)
@@ -1107,9 +1108,10 @@ add_executable(master_gain_tests tests/test_master_gain.cpp)
target_link_libraries(master_gain_tests PRIVATE master_gain)
add_test(NAME master_gain_tests COMMAND master_gain_tests)
# filter: the per-voice resonant filter. Pins the RBJ coefficients against an independent
# textbook cos/sin derivation, asserts the cutoff/Q control mappings at their anchors, and
# measures the resonant peak both analytically and by driving real sines. NEITHER SDK.
# filter: the per-voice resonant filter. Pins the SVF coefficients against an independent
# derivation, asserts the cutoff/Q control mappings at their anchors, holds the morph endpoints
# to the analytic 2-pole targets, and measures rate/level invariance and drive stability by
# driving real sines. NEITHER SDK.
add_executable(filter_tests tests/test_filter.cpp)
target_link_libraries(filter_tests PRIVATE filter)
add_test(NAME filter_tests COMMAND filter_tests)
+128 -99
View File
@@ -2,19 +2,22 @@
## Scope
The pure 2-pole resonant low/high-pass a sounding voice runs. 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 `FilterMode` and
friends out of `reasampler::instrument::engine` proper, where `zone_params.h` lives, since
this module has no call site yet to force the collision into the open at compile time.
Four files, one responsibility each:
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
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
no call site yet to force a collision into the open at compile time. Five files, one
responsibility each:
- `filter_params` — the control domain: `FilterMode`, normalized [0,1] knob position →
cutoff Hz and Q, and the exact inverses.
- `filter_coeffs` — the DSP domain: `BiquadCoeffs` and the RBJ coefficient computation
from (mode, cutoff Hz, Q, sample rate).
- `filter_saturate` — the high-pass feedback saturator (`tanhSaturate` /
`feedbackSaturate`). Header-only inline; it sits on the per-sample path.
- `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: normalized position → per-tap weights, 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.
@@ -22,39 +25,102 @@ Four files, one responsibility each:
### No vtable on the per-sample path
This is a **port, not a relocation**. The Cortex-M4 source 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: mode is a member branch inside an inlined
`process()`, predicted perfectly because it cannot change within a note. 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 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.
A non-type template parameter for the mode was considered and rejected: mode is a
runtime-settable user parameter, so templating would only relocate the same branch to the
call site and force the voice to hold two instances or switch over them.
### The rate enters ONLY through `g = tan(pi*fc/sr)`
### Two modes, and only two
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.
2-pole high-pass and 2-pole low-pass. The source's `Biquad1PoleLP` is struck and was not
ported. Further modes are deferred — **do not build a mode-extension framework** for them.
### 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.7e06 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, BP at 0.5, LP at 1.0,
continuous throughout, and the three endpoints are exact.
The crossfade is **equal-power between adjacent taps**, and both halves of that are 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, 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.
### 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 with no branch and no special case on the hot path. The
test asserts bit-identity against the same kernel with the limiter deleted.
- `|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 `biquadCoeffs`
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. Below 44.1k (e.g. 32k, 22.05k) the clamp still handles the math correctly —
it just legitimately eats the top of the knob travel at those rates.
`biquadCoeffs` with a non-positive sample rate returns pass-through coefficients. It does
**not** fall back to 44100 — that would breach the standing no-hardcoded-sample-rates
ruling.
`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
@@ -62,70 +128,33 @@ Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at
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.
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it. The SVF consumes it as
`k = 1/Q`.
### The high-pass input feedback is load-bearing
### Denormal flushing tests the envelope, not one sample
`kHighPassFeedbackShare` (0.24) times the raw **normalized** resonance, not Q — Q reaches
10 and scaling the feedback by it would push loop gain past unity. The high-pass numerator
collapses toward zero as cutoff falls, taking the resonance with it; the saturated
feedback restores the character down there. Ported behavior; the constant is the tuning
knob if the feel needs adjusting. `audio_saturate` and `H()` from the source were unused
by the biquads and were not ported.
### The feedback tap is a fixed TIME, and 48 kHz is the calibration anchor
`kFilterFeedbackDelaySeconds` (1/48000 s) is the interval the feedback tap reaches back,
resolved to a sample offset at `prepare()` and read with linear interpolation between two
whole taps. It is **not** a fallback sample rate and does not breach the
no-hardcoded-sample-rates ruling: nothing here ever substitutes it for the host's rate,
which still arrives as a parameter and is the only thing the coefficients are computed
from. It is a tuning constant of the filter, in the same sense as an attack time.
The firmware ran one fixed rate, so a tap that reached back one *sample* and one that
reached back a fixed *interval* were indistinguishable there. On a variable-rate host they
are not: the loop closes once per sample, so a one-sample tap made the loop's phase at the
cutoff — and with it the resonant emphasis and the stability margin — a function of the
rate. Measured peak/passband at fc=4 kHz, res=1.0 ran 5.02 at 48k against 8.52 at 192k.
Two consequences worth knowing before touching this:
- **48 kHz is the reference and must stay bit-identical.** It is the rate the constants
were voiced at. The interval resolves to exactly one sample there, so 48k reproduces the
firmware kernel sample-for-sample; `testFortyEightKilohertzBehaviorIsUnchanged` pins that
with literals captured before the tap became a time.
- **44.1 kHz cannot be corrected and is deliberately left alone.** One sample there is
already *longer* than the interval, and the loop must contain at least one sample of
delay or it is algebraic and uncomputable. So 44.1k keeps the firmware's single tap and
sits up to ~6% off 48k at the top of the cutoff range — exactly where it has always been.
Everything at or above 48k lands within the bilinear discretization difference of 48k.
The tap line is written with the **flushed** `y1`, so it drains to exact zero behind a
flushed recursion rather than circulating denormals; `isSilent()` therefore has to scan the
whole line, not just the newest entry.
### Denormal flushing
`process()` flushes the **y** history to exact zero below `kFilterDenormalFloor` (1e-30).
Only the recursive half needs it: a denormal in `y` self-sustains and stalls the FPU for
thousands of samples on a ringing-out voice, while the `x` history is an FIR tail that
shifts out within two samples. `isSilent()` reports the flushed state and is the honest
signal that a voice's filter can no longer contribute output.
`process()` flushes **both** integrators to exact zero once both are below
`kFilterDenormalFloor` (1e-30). Testing both is required, not tidy: `ic1` and `ic2` are in
quadrature, so a resonator swings each of them through zero twice a cycle. Flushing on a
single integrator injects a step in phase with the resonance, which the resonance then
amplifies — the filter limit-cycles at the floor forever instead of going quiet. This was
re-verified for TPT rather than assumed to transfer from the retired Direct Form I state.
## Gotchas
- **The tan pre-warp is not a different filter.** By the half-angle identity
`cos(w0) = (1-w²)/(1+w²)` and `sin(w0) = 2w/(1+w²)` with `w = tan(pi*fc/sr)`, these are
the textbook RBJ cos/sin coefficients exactly — just computed in a form that stays
conditioned at low cutoff where `cos(w0) → 1`. `tests/test_filter.cpp` asserts the
equivalence against an independent derivation. Don't "simplify" it back to `std::cos`.
- **`prepare()` deliberately does not clear history** — a live parameter move must glide,
not click. Call `reset()` at note-on.
- **`a1`/`a2` are stored for a subtracting difference equation** (`y = ... - a1*y1 -
a2*y2`), so the transfer denominator is `1 + a1*z^-1 + a2*z^-2`. A sign convention slip
here inverts the poles.
- **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, which at `fc/sr ≈ 1e-4` cost ~17 bits and put the
measured peak **15% low** at 20 Hz / 192 kHz. TPT encodes the same proximity in `a1`'s
small deviation from 1, which float32 resolves: measured 10.0160 against the analytic
10.0125, +0.034%. Do not reintroduce a direct-form kernel.
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
click. Call `reset()` at note-on.
- **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.
- **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 (~0.21 s), 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.
- **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.
@@ -12,31 +12,28 @@ double clampd(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? h
} // namespace
BiquadCoeffs biquadCoeffs(FilterMode mode, float cutoffHz, float q, double sampleRate) {
if (!(sampleRate > 0.0)) return BiquadCoeffs{};
const double nyquistCeiling = kFilterNyquistFraction * sampleRate;
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, nyquistCeiling);
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate) {
const double qq = clampd(q, kFilterQMin, kFilterQMax);
const double k = 1.0 / qq;
const double w = std::tan(kPi * fc / sampleRate);
const double w2 = w * w;
const double cosw = (1.0 - w2) / (1.0 + w2);
const double sinw = 2.0 * w / (1.0 + w2);
const double alpha = sinw / (2.0 * qq);
const double norm = 1.0 / (1.0 + alpha);
double g = 0.0;
if (sampleRate > 0.0) {
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, kFilterNyquistFraction * sampleRate);
g = std::tan(kPi * fc / sampleRate);
}
// Both modes share the denominator; only the numerator's sign on cosw differs, and b1 is
// always +/-2*b0 — folding that in keeps the two branches from drifting apart.
const double b0 = (mode == FilterMode::HighPass ? (1.0 + cosw) : (1.0 - cosw)) * 0.5 * norm;
const double b1 = (mode == FilterMode::HighPass ? -2.0 : 2.0) * b0;
// Solved in double and narrowed once. The intermediate g*(g+k) is the term that carries the
// pole proximity, so forming it in float would throw away the conditioning TPT just bought.
const double a1 = 1.0 / (1.0 + g * (g + k));
const double a2 = g * a1;
const double a3 = g * a2;
BiquadCoeffs c;
c.b0 = static_cast<float>(b0);
c.b1 = static_cast<float>(b1);
c.b2 = static_cast<float>(b0);
c.a1 = static_cast<float>(-2.0 * cosw * norm);
c.a2 = static_cast<float>((1.0 - alpha) * norm);
SvfCoeffs c;
c.g = static_cast<float>(g);
c.k = static_cast<float>(k);
c.a1 = static_cast<float>(a1);
c.a2 = static_cast<float>(a2);
c.a3 = static_cast<float>(a3);
return c;
}
@@ -1,7 +1,7 @@
// filter_coeffs.h — RBJ Audio EQ Cookbook Direct Form I biquad coefficients for the 2-pole
// low/high-pass. Computed via the tan half-angle substitution w = tan(pi*fc/sr): by the
// identity cos(w0) = (1-w^2)/(1+w^2), sin(w0) = 2w/(1+w^2) these ARE the textbook cos/sin
// coefficients, in a form that stays conditioned at low cutoff where cos(w0) -> 1.
// filter_coeffs.h — Zavalishin topology-preserving-transform state-variable coefficients.
// The rate enters ONLY through g = tan(pi*fc/sr); there is no reference or calibration rate
// anywhere in this module, and reintroducing one would restore the rate-dependent resonance
// the TPT rewrite exists to remove.
#pragma once
@@ -9,23 +9,29 @@
namespace reasampler::instrument::engine::filter {
// Already normalized by a0. The denominator is 1 + a1*z^-1 + a2*z^-2, so the difference
// equation SUBTRACTS the a terms: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2.
struct BiquadCoeffs {
float b0 = 1.0f;
float b1 = 0.0f;
float b2 = 0.0f;
float a1 = 0.0f;
// The two-integrator SVF's per-sample constants. a1/a2/a3 are the algebraic solution of the
// zero-delay feedback loop, so the kernel needs no iteration.
struct SvfCoeffs {
float g = 0.0f; // tan(pi*fc/sr) — the ONLY place the sample rate appears
float k = 1.0f; // 1/Q, the damping term
float a1 = 1.0f;
float a2 = 0.0f;
float a3 = 0.0f;
};
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges
// as fc approaches sr/2. Ported unchanged from the firmware, where it was already the ceiling.
// as fc approaches sr/2.
inline constexpr double kFilterNyquistFraction = 0.48;
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields pass-through coefficients — the
// no-hardcoded-sample-rates ruling means we refuse to invent a rate rather than assume 44.1k.
BiquadCoeffs biquadCoeffs(FilterMode mode, float cutoffHz, float q, double sampleRate);
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields g == 0 — we refuse to invent a
// rate rather than assume 44.1k.
//
// Float storage is safe HERE in a way it was not for the retired Direct Form I path. DF1 encoded
// pole proximity in a1 -> -2, a2 -> +1 and cancelled them against each other every sample, which
// at fc/sr ~ 1e-4 cost ~17 bits and moved the resonant peak -15%. TPT encodes the same proximity
// in a1's small DEVIATION from 1, which float resolves. Measured 20 Hz/192 kHz peak is 10.0160
// against the analytic 10.0125, +0.034%.
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate);
} // namespace reasampler::instrument::engine::filter
@@ -0,0 +1,54 @@
#include "core/instrument/engine/filter/filter_morph.h"
#include <cmath>
namespace reasampler::instrument::engine::filter {
namespace {
constexpr double kPi = 3.14159265358979323846;
struct Pair {
double a, b;
};
// Equal-power crossfade, EXACT at both ends by construction rather than by rounding: cos and sin
// of the leg's quarter turn are only 1e-17 from 0/1 at the endpoints, and the endpoints have to
// be pure taps, not a pure tap plus a -324 dB neighbour.
Pair equalPower(double t) {
if (!(t > 0.0)) return {1.0, 0.0};
if (t >= 1.0) return {0.0, 1.0};
const double theta = 0.5 * kPi * t;
return {std::cos(theta), std::sin(theta)};
}
} // namespace
MorphWeights morphWeights(float norm) {
const double n = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : static_cast<double>(norm));
MorphWeights w;
if (n <= 0.5) {
const Pair p = equalPower(2.0 * n); // HP -> BP
w.hp = static_cast<float>(p.a);
w.bp = static_cast<float>(p.b);
w.lp = 0.0f;
} else {
const Pair p = equalPower(2.0 * n - 1.0); // BP -> LP
w.hp = 0.0f;
w.bp = static_cast<float>(p.a);
w.lp = static_cast<float>(p.b);
}
return w;
}
MorphMix morphMix(const MorphWeights& w, float k) {
MorphMix m;
m.m0 = w.hp;
m.m1 = w.bp - w.hp * k;
m.m2 = w.lp - w.hp;
return m;
}
MorphMix bypassMix() { return MorphMix{1.0f, 0.0f, 0.0f}; }
} // namespace reasampler::instrument::engine::filter
@@ -0,0 +1,48 @@
// 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.
#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.
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.
//
// 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.
//
// 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);
// 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
// path at three multiplies and spares it ever forming the high tap.
struct MorphMix {
float m0 = 0.0f;
float m1 = 0.0f;
float m2 = 1.0f;
};
MorphMix morphMix(const MorphWeights& w, float k);
// Passes the input through untouched, whatever the morph position asks for. Reserved for a
// sample rate we cannot form a filter from: silencing an instrument is a worse failure than
// ignoring the morph, and at g == 0 a low-pass tap is analytically silent.
MorphMix bypassMix();
} // namespace reasampler::instrument::engine::filter
@@ -43,6 +43,11 @@ float filterQFromNorm(float norm) {
return static_cast<float>(std::exp(k.a + n * (k.b + k.c * n)));
}
float filterDriveDepthFromNorm(float norm) {
const double n = clamp01(norm);
return static_cast<float>(kFilterDriveDepthMax * n * n);
}
float filterNormFromQ(float q) {
if (!(q > kFilterQMin)) return 0.0f;
if (q >= kFilterQMax) return 1.0f;
@@ -1,14 +1,12 @@
// filter_params.h — control-domain mapping for the voice filter: normalized [0,1] knob
// positions to cutoff Hz and Q, plus the two-mode enum. Deliberately sample-rate-free —
// the Nyquist clamp is a property of the bilinear transform and lives in filter_coeffs,
// so the persisted normalized cutoff means the same frequency at every project rate.
// positions to cutoff Hz, Q, and drive depth. Deliberately sample-rate-free — the Nyquist
// clamp is a property of the bilinear transform and lives in filter_coeffs, so the persisted
// normalized cutoff means the same frequency at every project rate.
#pragma once
namespace reasampler::instrument::engine::filter {
enum class FilterMode { LowPass, HighPass };
// The audio band the cutoff control sweeps: three exact decades, so norm 1/3 is 200 Hz and
// norm 2/3 is 2 kHz. NOT derived from the sample rate — a rate-dependent endpoint would make
// one saved preset sound different at 44.1k and 96k, and at 44.1k the top of the travel would
@@ -23,6 +21,14 @@ inline constexpr float kFilterQMin = 0.1f;
inline constexpr float kFilterQMax = 10.0f;
inline constexpr float kFilterQCenter = 1.41421356f;
// Depth at the top of the drive control. The limiter's knee is at 1/depth, and the resonance
// swings the state to roughly 2*Q*level, so this is the range over which drive bites. Chosen
// against measurement rather than by 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 (measured 21 dB under passband at
// depth 64), turning the peak the user dialled in into a notch.
inline constexpr float kFilterDriveDepthMax = 4.0f;
// Out-of-range norm clamps to the endpoints.
float filterCutoffHzFromNorm(float norm);
@@ -37,4 +43,10 @@ float filterQFromNorm(float norm);
// Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
float filterNormFromQ(float q);
// Drive depth for the in-loop limiter. Square law, not linear: the knee is 1/depth, so a linear
// depth would spend most of the audible travel in the first tenth of the knob. Exactly 0 at
// norm 0 — the limiter is then algebraically the identity, which is what makes drive=0 bit-exact
// linear rather than merely close.
float filterDriveDepthFromNorm(float norm);
} // namespace reasampler::instrument::engine::filter
@@ -1,27 +1,32 @@
// filter_saturate.h — the high-pass feedback-path saturator, ported from the Cortex-M4
// filter. Header-inline: it sits on the per-voice per-sample path, and a rational
// approximation is here precisely to avoid a transcendental tanh() call there.
// filter_saturate.h — the drive stage's soft limiter. Header-inline: it sits inside the
// per-voice per-sample recursion.
#pragma once
#include <cmath>
namespace reasampler::instrument::engine::filter {
// Rational tanh approximation inside +/-threshold, continued past it with a gentle 0.1 slope
// anchored at the threshold value so the curve stays continuous rather than hard-clipping.
inline float tanhSaturate(float x, float threshold, float a, float b) {
if (x > threshold) {
const float satAtThreshold = threshold * a / (a + b + threshold * threshold);
return satAtThreshold + (x - threshold) * 0.1f;
}
if (x < -threshold) {
const float satAtThreshold = -threshold * a / (a + b + threshold * threshold);
return satAtThreshold + (x + threshold) * 0.1f;
}
return x * a / (a + b + x * x);
// Odd, smooth, strictly monotone, bounded by 1/depth, with unit slope at the origin.
//
// Three properties are load-bearing and none of them are tuning:
// - depth == 0 makes this ALGEBRAICALLY the identity (x / sqrt(1) == x, exact in IEEE), so
// drive = 0 is bit-exact linear with no branch and no special case on the hot path.
// - |softLimit(x, d)| <= |x| for every d, so dropping it into the resonance state update can
// only ever shrink the state. The filter therefore cannot gain energy from the drive stage:
// stability at any Q and any cutoff is structural, not a tuned margin, and it can never
// self-oscillate.
// - Unit slope at the origin, so the shaper adds no gain of its own at any depth. What reaches
// it is the resonance state, already multiplied by roughly 2*Q, which is why drive and
// resonance interact: the same drive setting bites harder the more resonance is dialled in.
//
// The retired feedbackSaturate() is deliberately not carried forward: it had 0.75 slope at the
// origin, a fixed +/-2.0 threshold calibrated for firmware excursion levels, and turned over
// (non-monotone) past x = 6. That absolute threshold is the origin of the level-dependent
// resonance this rewrite removes — do not reintroduce it.
inline float softLimit(float x, float depth) {
const float s = depth * x;
return x / std::sqrt(1.0f + s * s);
}
// TB-303-style hard feedback saturation. Tuned for the large excursions a resonant feedback
// path produces, not for audio-level signals — do not reuse it as a general waveshaper.
inline float feedbackSaturate(float x) { return tanhSaturate(x, 2.0f, 27.0f, 9.0f); }
} // namespace reasampler::instrument::engine::filter
@@ -3,26 +3,10 @@
namespace reasampler::instrument::engine::filter {
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
mode_ = settings.mode;
const float cutoffHz = filterCutoffHzFromNorm(settings.cutoffNorm);
coeffs_ = biquadCoeffs(settings.mode, cutoffHz, filterQFromNorm(settings.resonanceNorm),
sampleRate);
const float res = settings.resonanceNorm < 0.0f
? 0.0f
: (settings.resonanceNorm > 1.0f ? 1.0f : settings.resonanceNorm);
fbAmount_ = res * kHighPassFeedbackShare;
// The calibrated feedback interval, expressed in samples at THIS rate. Floored at one sample
// because the loop must hold at least that much delay or it is algebraic and not computable
// — which is also why 44.1k, whose sample period already exceeds the interval, keeps the
// firmware's single tap. A non-positive rate lands on that same floor rather than on an
// invented rate. Clamped as a double before the narrowing cast so a wild rate cannot
// overflow the integer part.
double taps = kFilterFeedbackDelaySeconds * sampleRate;
if (!(taps > 1.0)) taps = 1.0;
if (taps > kFilterFeedbackTaps - 1) taps = kFilterFeedbackTaps - 1;
fbDelay_ = static_cast<unsigned>(taps);
fbDelayFrac_ = static_cast<float>(taps - fbDelay_);
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm),
filterQFromNorm(settings.resonanceNorm), sampleRate);
mix_ = (sampleRate > 0.0) ? morphMix(morphWeights(settings.morphNorm), coeffs_.k) : bypassMix();
driveDepth_ = filterDriveDepthFromNorm(settings.driveNorm);
}
void VoiceFilter::reset() {
@@ -31,11 +15,7 @@ void VoiceFilter::reset() {
bool VoiceFilter::isSilent() const {
for (const State& s : state_) {
if (s.x1 != 0.0f || s.x2 != 0.0f || s.y1 != 0.0f || s.y2 != 0.0f) return false;
// The whole tap line, not just the newest entry: an older tap still reaches the input.
for (float v : s.fb) {
if (v != 0.0f) return false;
}
if (s.ic1 != 0.0f || s.ic2 != 0.0f) return false;
}
return true;
}
+41 -101
View File
@@ -1,6 +1,6 @@
// voice_filter.h — per-voice 2-pole resonant low/high-pass. Concrete type, no vtable: this
// sits on the per-voice per-sample path, so process() is header-inline and mode is a member
// branch. No allocation, no virtual dispatch, no I/O anywhere in process().
// voice_filter.h — per-voice TPT state-variable filter with a continuous HP->BP->LP morph and
// an in-loop drive stage. Concrete type, no vtable: this sits on the per-voice per-sample path,
// so process() is header-inline. No allocation, no virtual dispatch, no I/O in process().
#pragma once
@@ -8,6 +8,7 @@
#include <type_traits>
#include "core/instrument/engine/filter/filter_coeffs.h"
#include "core/instrument/engine/filter/filter_morph.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/filter_saturate.h"
@@ -15,62 +16,29 @@ namespace reasampler::instrument::engine::filter {
// Normalized control positions, as the editor moves them and the persisted state carries them.
struct FilterSettings {
FilterMode mode = FilterMode::LowPass;
float cutoffNorm = 1.0f;
float resonanceNorm = 0.0f;
float morphNorm = 1.0f; // 0 = high-pass, 0.5 = band-pass, 1 = low-pass
float driveNorm = 0.0f;
};
// Share of the last output fed back into the high-pass input at full resonance. Driven by the
// raw control position rather than by Q: Q reaches 10, and scaling the feedback by it would
// push the loop gain past unity at the top of the range.
//
// The HP/LP resonance asymmetry this produces is a known ear call reserved for Daniel, not a
// bug: measured peak/passband at res=1.0, fc=1kHz/sr=48k is LP 9.98 (flat at every input level)
// vs HP 7.44 (input 0.001-0.1), 7.59 (0.3), 8.52 (1.0) — HP resonance is level-dependent because
// feedbackSaturate's threshold (+/-2.0) is an absolute level, not a fraction of the signal.
// Retuning this constant alone cannot make the two modes track, since it does not touch that
// level-dependence.
inline constexpr float kHighPassFeedbackShare = 0.24f;
// The feedback tap is a fixed TIME, not a fixed sample count. The loop closes once per sample
// through it, so tapping the immediately previous sample makes the loop's phase at the cutoff --
// and with it the resonant emphasis -- a function of the sample rate: measured peak/passband at
// fc=4 kHz, res=1.0 was 5.02 at 48k against 8.52 at 192k while this was one sample. The source
// firmware ran a single fixed rate and could not see it. 1/48000 s is the interval the constants
// above were voiced at, so 48k resolves to exactly the one-sample tap the firmware used and is
// bit-identical to it; 44.1k, where one sample already exceeds the interval, is held at that
// same single tap by the floor in prepare() and is likewise unchanged.
inline constexpr double kFilterFeedbackDelaySeconds = 1.0 / 48000.0;
// Depth of the tap line, a power of two so the index wraps with a mask. Sixteen holds delays 1
// through 16, and the interpolating read needs one tap beyond the whole part, so rates up to
// 15/kFilterFeedbackDelaySeconds = 720 kHz resolve exactly — past REAPER's 384 kHz ceiling.
// Beyond that the delay clamps and the rate dependence creeps back, which is the pre-fix
// behavior rather than a new failure.
inline constexpr int kFilterFeedbackTaps = 16;
static_assert((kFilterFeedbackTaps & (kFilterFeedbackTaps - 1)) == 0, "mask indexing needs 2^n");
// Below this the recursion has decayed past -600 dB. Flushing keeps the history out of the
// Below this the recursion has decayed past -600 dB. Flushing keeps the state out of the
// subnormal range, where a ringing-out voice would otherwise stall the FPU for thousands of
// samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
inline constexpr float kFilterDenormalFloor = 1e-30f;
class VoiceFilter {
public:
// The instrument's output bus is permanently stereo; one history line per channel.
// The instrument's output bus is permanently stereo; one integrator pair per channel.
static constexpr int kMaxChannels = 2;
struct State {
float x1 = 0.0f;
float x2 = 0.0f;
float y1 = 0.0f;
float y2 = 0.0f;
float fb[kFilterFeedbackTaps]{}; // output history the high-pass feedback tap reads back
unsigned fbWrite = 0; // slot the NEXT output goes into
float ic1 = 0.0f; // band-pass integrator
float ic2 = 0.0f; // low-pass integrator
};
// Recomputes coefficients from the control positions. History is deliberately preserved so
// a live parameter move glides instead of clicking; call reset() at note-on.
// Recomputes coefficients from the control positions. State is deliberately preserved so a
// live parameter move glides instead of clicking; call reset() at note-on.
void prepare(const FilterSettings& settings, double sampleRate);
void reset();
@@ -80,50 +48,32 @@ public:
assert(channel >= 0 && channel < kMaxChannels);
State& s = state_[channel];
// The high-pass numerator collapses toward zero as cutoff falls, taking the resonance
// with it; feeding a saturated share of an earlier output back into the input restores
// the character the coefficients alone stop producing down there. The 0.9f pre-scale is
// carried from the source firmware, uncalibrated here — no derivation is known for it.
// fbDelay_/fbDelayFrac_ are resolved at prepare(), so the tap stays a rate-free index
// here and the whole arm is evaluated only in high-pass mode. The interpolation between
// adjacent taps is exactly a no-op wherever the rate is a whole multiple of the
// calibration rate (fbDelayFrac_ is then exactly 0), so it costs no accuracy at 48/96/192k
// and only engages at the rates a whole tap would have rounded.
const float in =
(mode_ == FilterMode::HighPass) ? x - fbAmount_ * feedbackSaturate(fbTap(s) * 0.9f) : x;
const float v3 = x - s.ic2;
const float v1 = coeffs_.a1 * s.ic1 + coeffs_.a2 * v3;
const float v2 = s.ic2 + coeffs_.a2 * s.ic1 + coeffs_.a3 * v3;
const float y = coeffs_.b0 * in + coeffs_.b1 * s.x1 + coeffs_.b2 * s.x2
- coeffs_.a1 * s.y1 - coeffs_.a2 * s.y2;
// The drive stage, and the only nonlinearity. It shapes the BAND-PASS integrator state
// rather than the input because that state IS the resonance: in the passband and at DC
// it sits at zero, so drive colours the resonance and leaves the passband transparent.
// Placing it on the state rather than inside the zero-delay loop keeps a1/a2/a3 an exact
// algebraic solve — a nonlinearity inside the loop would need per-sample Newton
// iteration. softLimit is a contraction, so this cannot destabilize the filter.
s.ic1 = softLimit(2.0f * v1 - s.ic1, driveDepth_);
s.ic2 = 2.0f * v2 - s.ic2;
s.x2 = s.x1;
s.x1 = in;
s.y2 = s.y1;
s.y1 = y;
// Snap the RECURSIVE half of the state once it has decayed past -600 dB. Only y1/y2
// are flushed (and only they are tested) — x1/x2 is an FIR tail that shifts out within
// two samples on its own, and a high-pass has an exact DC null (b1 == -2*b0 bit-exactly),
// so under a constant/DC-biased input y decays to zero while x1/x2 sit at the input
// level; clearing x1/x2 too would discard that history and the next sample would
// recompute a full-amplitude step from b0*in alone, re-ringing forever (a click train).
// Zeroing individual samples instead of the pair does not work either: a resonator
// swings through zero twice a cycle, so a per-sample flush injects a step in phase with
// the resonance, which the resonance then amplifies — the filter limit-cycles at the
// floor forever rather than going quiet. Testing y1 AND y2 tests the envelope, not one
// sample.
if (s.y1 > -kFilterDenormalFloor && s.y1 < kFilterDenormalFloor &&
s.y2 > -kFilterDenormalFloor && s.y2 < kFilterDenormalFloor) {
s.y1 = 0.0f;
s.y2 = 0.0f;
// Snap the state once the whole resonator has decayed past -600 dB. Testing BOTH
// integrators is testing the ENVELOPE rather than one sample, and that is required, not
// tidy: ic1 and ic2 are in quadrature, so a resonator swings each of them through zero
// twice a cycle. Flushing on a single integrator would inject a step in phase with the
// resonance, which the resonance then amplifies — the filter limit-cycles at the floor
// forever instead of going quiet.
if (s.ic1 > -kFilterDenormalFloor && s.ic1 < kFilterDenormalFloor &&
s.ic2 > -kFilterDenormalFloor && s.ic2 < kFilterDenormalFloor) {
s.ic1 = 0.0f;
s.ic2 = 0.0f;
}
// Pushes the FLUSHED y1, so the tap line drains to exact zero behind a flushed recursion
// instead of feeding denormals back in. Stored unconditionally even in LP mode, where
// nothing reads it: the mode branch above already exists, but gating this store on it
// buys nothing a dead-store-eliminating compiler doesn't already do for free, at the
// cost of a second branch on the mode.
s.fb[s.fbWrite & (kFilterFeedbackTaps - 1)] = s.y1;
++s.fbWrite;
return y;
return mix_.m0 * x + mix_.m1 * v1 + mix_.m2 * v2;
}
void processFrame(float* samples, int channelCount) {
@@ -131,7 +81,7 @@ public:
for (int c = 0; c < channelCount; ++c) samples[c] = process(c, samples[c]);
}
// True once every history line has flushed to exact zero — the voice's filter has stopped
// True once every integrator has flushed to exact zero — the voice's filter has stopped
// ringing and cannot contribute further output.
bool isSilent() const;
@@ -139,23 +89,13 @@ public:
assert(channel >= 0 && channel < kMaxChannels);
return state_[channel];
}
const BiquadCoeffs& coeffs() const { return coeffs_; }
const SvfCoeffs& coeffs() const { return coeffs_; }
const MorphMix& mix() const { return mix_; }
private:
// The feedback tap, fbDelay_ + fbDelayFrac_ samples back. Not named near/far: those are
// legacy Windows macros, and this header is bound for translation units that see windows.h.
float fbTap(const State& s) const {
constexpr unsigned mask = kFilterFeedbackTaps - 1;
const float recent = s.fb[(s.fbWrite - fbDelay_) & mask];
const float older = s.fb[(s.fbWrite - fbDelay_ - 1u) & mask];
return recent + fbDelayFrac_ * (older - recent);
}
BiquadCoeffs coeffs_{};
FilterMode mode_ = FilterMode::LowPass;
float fbAmount_ = 0.0f;
float fbDelayFrac_ = 0.0f;
unsigned fbDelay_ = 1;
SvfCoeffs coeffs_{};
MorphMix mix_{};
float driveDepth_ = 0.0f;
State state_[kMaxChannels]{};
};
+516 -518
View File
File diff suppressed because it is too large Load Diff