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) add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp)
target_include_directories(master_gain PUBLIC src) target_include_directories(master_gain PUBLIC src)
# filter — the per-voice 2-pole resonant low/high-pass, ported from Daniel's Cortex-M4 filter # filter — the per-voice TPT/SVF with a continuous HP->BP->LP morph and an in-loop drive stage.
# with its virtual FilterBase/Filter/Biquad hierarchy flattened away (that hierarchy dispatched # The Cortex-M4 source's virtual FilterBase/Filter/Biquad hierarchy dispatched per channel per
# virtually per channel per sample, which the per-voice per-sample path forbids). Control # sample, which the per-voice per-sample path forbids, so none of it came across. Control
# mapping, RBJ coefficient math, feedback saturation, and the filter type each get their own # mapping, SVF coefficients, morph weights, and the filter type each get their own file;
# file; VoiceFilter::process is header-inline so the biquad kernel still inlines at the call # VoiceFilter::process is header-inline so the kernel still inlines at the call site. Standard
# site. Standard library only. NEITHER SDK. # library only. NEITHER SDK.
add_library(filter STATIC add_library(filter STATIC
src/core/instrument/engine/filter/filter_params.cpp src/core/instrument/engine/filter/filter_params.cpp
src/core/instrument/engine/filter/filter_coeffs.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) src/core/instrument/engine/filter/voice_filter.cpp)
target_include_directories(filter PUBLIC src) 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) target_link_libraries(master_gain_tests PRIVATE master_gain)
add_test(NAME master_gain_tests COMMAND master_gain_tests) add_test(NAME master_gain_tests COMMAND master_gain_tests)
# filter: the per-voice resonant filter. Pins the RBJ coefficients against an independent # filter: the per-voice resonant filter. Pins the SVF coefficients against an independent
# textbook cos/sin derivation, asserts the cutoff/Q control mappings at their anchors, and # derivation, asserts the cutoff/Q control mappings at their anchors, holds the morph endpoints
# measures the resonant peak both analytically and by driving real sines. NEITHER SDK. # 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) add_executable(filter_tests tests/test_filter.cpp)
target_link_libraries(filter_tests PRIVATE filter) target_link_libraries(filter_tests PRIVATE filter)
add_test(NAME filter_tests COMMAND filter_tests) add_test(NAME filter_tests COMMAND filter_tests)
+128 -99
View File
@@ -2,19 +2,22 @@
## Scope ## Scope
The pure 2-pole resonant low/high-pass a sounding voice runs. No REAPER, no VST3, no The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
allocation, no I/O. Everything here lives in `reasampler::instrument::engine::filter`, HP→BP→LP morph and a drive stage. No REAPER, no VST3, no allocation, no I/O. Everything
nested per the directory-mirrors-namespace convention — this keeps `FilterMode` and here lives in `reasampler::instrument::engine::filter`, nested per the
friends out of `reasampler::instrument::engine` proper, where `zone_params.h` lives, since directory-mirrors-namespace convention — this keeps `FilterSettings` and friends out of
this module has no call site yet to force the collision into the open at compile time. `reasampler::instrument::engine` proper, where `zone_params.h` lives, since this module has
Four files, one responsibility each: 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 → - `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
cutoff Hz and Q, and the exact inverses. drive depth, plus the exact inverses for cutoff and Q.
- `filter_coeffs` — the DSP domain: `BiquadCoeffs` and the RBJ coefficient computation - `filter_coeffs` — the DSP domain: `SvfCoeffs` and the TPT coefficient solve from
from (mode, cutoff Hz, Q, sample rate). (cutoff Hz, Q, sample rate).
- `filter_saturate` — the high-pass feedback saturator (`tanhSaturate` / - `filter_morph` — the morph domain: normalized position → per-tap weights, and the fold of
`feedbackSaturate`). Header-only inline; it sits on the per-sample path. 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. - `voice_filter``FilterSettings` and `VoiceFilter`, the concrete per-voice type.
`process()` is defined in the header. `process()` is defined in the header.
@@ -22,39 +25,102 @@ Four files, one responsibility each:
### No vtable on the per-sample path ### No vtable on the per-sample path
This is a **port, not a relocation**. The Cortex-M4 source was a virtual hierarchy The Cortex-M4 source this began as was a virtual hierarchy (`FilterBase``Filter`
(`FilterBase``Filter``Biquad``{BiquadHP, BiquadLP}`) whose base class routed the `Biquad``{BiquadHP, BiquadLP}`) whose base class routed the channel loop through
channel loop through pure-virtual `process_channel_frame` / `filter` / `update_feedback` pure-virtual `process_channel_frame` / `filter` / `update_feedback` so a `FilterDecorator`
so a `FilterDecorator` chain could wrap it. **None of that came across, and none of it may chain could wrap it. **None of that came across, and none of it may come back.**
come back.** `VoiceFilter` is concrete: mode is a member branch inside an inlined `VoiceFilter` is concrete, `process()` is inlined, and there is no `IFilter`, no decorator
`process()`, predicted perfectly because it cannot change within a note. There is no seam, no virtual `tick()`, and no allocation in `process()` — root `CLAUDE.md`'s structural
`IFilter`, no decorator seam, no virtual `tick()`, and no allocation in `process()` — root heuristic 3 names this class of dispatch blowout directly.
`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 ### The rate enters ONLY through `g = tan(pi*fc/sr)`
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.
### 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 ### Why the high-pass feedback tap was right on Q15 hardware and wrong here
ported. Further modes are deferred — **do not build a mode-extension framework** for them.
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 ### 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 `filterCutoffHzFromNorm` sweeps a fixed 20 Hz 20 kHz (three exact decades, so norm 1/3 is
is 200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the 200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the normalized
normalized knob position, so a rate-derived endpoint would make one preset sound different knob position, so a rate-derived endpoint would make one preset sound different at 44.1k and
at 44.1k and 96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the 96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the bilinear
bilinear transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `biquadCoeffs` transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `svfCoeffs` where the rate
where the rate is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the clamp never eats
clamp never eats live knob travel there; the source's hardcoded 23 kHz endpoint did exactly live knob travel there; the source's hardcoded 23 kHz endpoint did exactly that at 44.1k.
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.
### Q spans 0.1 → 10 with √2 at the center ### 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 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 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 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 `process()` flushes **both** integrators to exact zero once both are below
10 and scaling the feedback by it would push loop gain past unity. The high-pass numerator `kFilterDenormalFloor` (1e-30). Testing both is required, not tidy: `ic1` and `ic2` are in
collapses toward zero as cutoff falls, taking the resonance with it; the saturated quadrature, so a resonator swings each of them through zero twice a cycle. Flushing on a
feedback restores the character down there. Ported behavior; the constant is the tuning single integrator injects a step in phase with the resonance, which the resonance then
knob if the feel needs adjusting. `audio_saturate` and `H()` from the source were unused amplifies — the filter limit-cycles at the floor forever instead of going quiet. This was
by the biquads and were not ported. re-verified for TPT rather than assumed to transfer from the retired Direct Form I state.
### 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.
## Gotchas ## Gotchas
- **The tan pre-warp is not a different filter.** By the half-angle identity - **TPT is what fixed the low-cutoff conditioning defect** this is a topology change, not
`cos(w0) = (1-w²)/(1+w²)` and `sin(w0) = 2w/(1+w²)` with `w = tan(pi*fc/sr)`, these are a relocation. Direct Form I encoded pole proximity in `a1 → -2`, `a2 → +1` and cancelled
the textbook RBJ cos/sin coefficients exactly — just computed in a form that stays them against each other every sample, which at `fc/sr ≈ 1e-4` cost ~17 bits and put the
conditioned at low cutoff where `cos(w0) → 1`. `tests/test_filter.cpp` asserts the measured peak **15% low** at 20 Hz / 192 kHz. TPT encodes the same proximity in `a1`'s
equivalence against an independent derivation. Don't "simplify" it back to `std::cos`. small deviation from 1, which float32 resolves: measured 10.0160 against the analytic
- **`prepare()` deliberately does not clear history** — a live parameter move must glide, 10.0125, +0.034%. Do not reintroduce a direct-form kernel.
not click. Call `reset()` at note-on. - **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
- **`a1`/`a2` are stored for a subtracting difference equation** (`y = ... - a1*y1 - click. Call `reset()` at note-on.
a2*y2`), so the transfer denominator is `1 + a1*z^-1 + a2*z^-2`. A sign convention slip - **The morph endpoints are asserted on the folded mix, exactly.** `morphWeights` snaps the
here inverts the poles. 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 - **No call site yet.** Wiring the filter into the voice path is a separate track; nothing
in `sampler_core` references this module today. 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.** - **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
A test budget expressed in samples is therefore itself a rate assumption — a fixed 20000 budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
samples is ample at 48k and expires mid-decay at 96k and above. 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 } // namespace
BiquadCoeffs biquadCoeffs(FilterMode mode, float cutoffHz, float q, double sampleRate) { SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate) {
if (!(sampleRate > 0.0)) return BiquadCoeffs{};
const double nyquistCeiling = kFilterNyquistFraction * sampleRate;
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, nyquistCeiling);
const double qq = clampd(q, kFilterQMin, kFilterQMax); const double qq = clampd(q, kFilterQMin, kFilterQMax);
const double k = 1.0 / qq;
const double w = std::tan(kPi * fc / sampleRate); double g = 0.0;
const double w2 = w * w; if (sampleRate > 0.0) {
const double cosw = (1.0 - w2) / (1.0 + w2); const double fc = clampd(cutoffHz, kFilterCutoffMinHz, kFilterNyquistFraction * sampleRate);
const double sinw = 2.0 * w / (1.0 + w2); g = std::tan(kPi * fc / sampleRate);
const double alpha = sinw / (2.0 * qq); }
const double norm = 1.0 / (1.0 + alpha);
// Both modes share the denominator; only the numerator's sign on cosw differs, and b1 is // Solved in double and narrowed once. The intermediate g*(g+k) is the term that carries the
// always +/-2*b0 — folding that in keeps the two branches from drifting apart. // pole proximity, so forming it in float would throw away the conditioning TPT just bought.
const double b0 = (mode == FilterMode::HighPass ? (1.0 + cosw) : (1.0 - cosw)) * 0.5 * norm; const double a1 = 1.0 / (1.0 + g * (g + k));
const double b1 = (mode == FilterMode::HighPass ? -2.0 : 2.0) * b0; const double a2 = g * a1;
const double a3 = g * a2;
BiquadCoeffs c; SvfCoeffs c;
c.b0 = static_cast<float>(b0); c.g = static_cast<float>(g);
c.b1 = static_cast<float>(b1); c.k = static_cast<float>(k);
c.b2 = static_cast<float>(b0); c.a1 = static_cast<float>(a1);
c.a1 = static_cast<float>(-2.0 * cosw * norm); c.a2 = static_cast<float>(a2);
c.a2 = static_cast<float>((1.0 - alpha) * norm); c.a3 = static_cast<float>(a3);
return c; return c;
} }
@@ -1,7 +1,7 @@
// filter_coeffs.h — RBJ Audio EQ Cookbook Direct Form I biquad coefficients for the 2-pole // filter_coeffs.h — Zavalishin topology-preserving-transform state-variable coefficients.
// low/high-pass. Computed via the tan half-angle substitution w = tan(pi*fc/sr): by the // The rate enters ONLY through g = tan(pi*fc/sr); there is no reference or calibration rate
// identity cos(w0) = (1-w^2)/(1+w^2), sin(w0) = 2w/(1+w^2) these ARE the textbook cos/sin // anywhere in this module, and reintroducing one would restore the rate-dependent resonance
// coefficients, in a form that stays conditioned at low cutoff where cos(w0) -> 1. // the TPT rewrite exists to remove.
#pragma once #pragma once
@@ -9,23 +9,29 @@
namespace reasampler::instrument::engine::filter { namespace reasampler::instrument::engine::filter {
// Already normalized by a0. The denominator is 1 + a1*z^-1 + a2*z^-2, so the difference // The two-integrator SVF's per-sample constants. a1/a2/a3 are the algebraic solution of the
// equation SUBTRACTS the a terms: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2. // zero-delay feedback loop, so the kernel needs no iteration.
struct BiquadCoeffs { struct SvfCoeffs {
float b0 = 1.0f; float g = 0.0f; // tan(pi*fc/sr) — the ONLY place the sample rate appears
float b1 = 0.0f; float k = 1.0f; // 1/Q, the damping term
float b2 = 0.0f; float a1 = 1.0f;
float a1 = 0.0f;
float a2 = 0.0f; float a2 = 0.0f;
float a3 = 0.0f;
}; };
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges // 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; inline constexpr double kFilterNyquistFraction = 0.48;
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into // cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields pass-through coefficients — the // [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields g == 0 — we refuse to invent a
// no-hardcoded-sample-rates ruling means we refuse to invent a rate rather than assume 44.1k. // rate rather than assume 44.1k.
BiquadCoeffs biquadCoeffs(FilterMode mode, float cutoffHz, float q, double sampleRate); //
// 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 } // 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))); 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) { float filterNormFromQ(float q) {
if (!(q > kFilterQMin)) return 0.0f; if (!(q > kFilterQMin)) return 0.0f;
if (q >= kFilterQMax) return 1.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 // 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 — // positions to cutoff Hz, Q, and drive depth. Deliberately sample-rate-free — the Nyquist
// the Nyquist clamp is a property of the bilinear transform and lives in filter_coeffs, // clamp is a property of the bilinear transform and lives in filter_coeffs, so the persisted
// so the persisted normalized cutoff means the same frequency at every project rate. // normalized cutoff means the same frequency at every project rate.
#pragma once #pragma once
namespace reasampler::instrument::engine::filter { 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 // 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 // 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 // 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 kFilterQMax = 10.0f;
inline constexpr float kFilterQCenter = 1.41421356f; 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. // Out-of-range norm clamps to the endpoints.
float filterCutoffHzFromNorm(float norm); 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. // Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
float filterNormFromQ(float q); 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 } // namespace reasampler::instrument::engine::filter
@@ -1,27 +1,32 @@
// filter_saturate.h — the high-pass feedback-path saturator, ported from the Cortex-M4 // filter_saturate.h — the drive stage's soft limiter. Header-inline: it sits inside the
// filter. Header-inline: it sits on the per-voice per-sample path, and a rational // per-voice per-sample recursion.
// approximation is here precisely to avoid a transcendental tanh() call there.
#pragma once #pragma once
#include <cmath>
namespace reasampler::instrument::engine::filter { namespace reasampler::instrument::engine::filter {
// Rational tanh approximation inside +/-threshold, continued past it with a gentle 0.1 slope // Odd, smooth, strictly monotone, bounded by 1/depth, with unit slope at the origin.
// 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) { // Three properties are load-bearing and none of them are tuning:
if (x > threshold) { // - depth == 0 makes this ALGEBRAICALLY the identity (x / sqrt(1) == x, exact in IEEE), so
const float satAtThreshold = threshold * a / (a + b + threshold * threshold); // drive = 0 is bit-exact linear with no branch and no special case on the hot path.
return satAtThreshold + (x - threshold) * 0.1f; // - |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:
if (x < -threshold) { // stability at any Q and any cutoff is structural, not a tuned margin, and it can never
const float satAtThreshold = -threshold * a / (a + b + threshold * threshold); // self-oscillate.
return satAtThreshold + (x + threshold) * 0.1f; // - 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
return x * a / (a + b + x * x); // 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 } // namespace reasampler::instrument::engine::filter
@@ -3,26 +3,10 @@
namespace reasampler::instrument::engine::filter { namespace reasampler::instrument::engine::filter {
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) { void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
mode_ = settings.mode; coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm),
const float cutoffHz = filterCutoffHzFromNorm(settings.cutoffNorm); filterQFromNorm(settings.resonanceNorm), sampleRate);
coeffs_ = biquadCoeffs(settings.mode, cutoffHz, filterQFromNorm(settings.resonanceNorm), mix_ = (sampleRate > 0.0) ? morphMix(morphWeights(settings.morphNorm), coeffs_.k) : bypassMix();
sampleRate); driveDepth_ = filterDriveDepthFromNorm(settings.driveNorm);
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_);
} }
void VoiceFilter::reset() { void VoiceFilter::reset() {
@@ -31,11 +15,7 @@ void VoiceFilter::reset() {
bool VoiceFilter::isSilent() const { bool VoiceFilter::isSilent() const {
for (const State& s : state_) { for (const State& s : state_) {
if (s.x1 != 0.0f || s.x2 != 0.0f || s.y1 != 0.0f || s.y2 != 0.0f) return false; if (s.ic1 != 0.0f || s.ic2 != 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;
}
} }
return true; 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 // voice_filter.h — per-voice TPT state-variable filter with a continuous HP->BP->LP morph and
// sits on the per-voice per-sample path, so process() is header-inline and mode is a member // an in-loop drive stage. Concrete type, no vtable: this sits on the per-voice per-sample path,
// branch. No allocation, no virtual dispatch, no I/O anywhere in process(). // so process() is header-inline. No allocation, no virtual dispatch, no I/O in process().
#pragma once #pragma once
@@ -8,6 +8,7 @@
#include <type_traits> #include <type_traits>
#include "core/instrument/engine/filter/filter_coeffs.h" #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_params.h"
#include "core/instrument/engine/filter/filter_saturate.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. // Normalized control positions, as the editor moves them and the persisted state carries them.
struct FilterSettings { struct FilterSettings {
FilterMode mode = FilterMode::LowPass;
float cutoffNorm = 1.0f; float cutoffNorm = 1.0f;
float resonanceNorm = 0.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 // Below this the recursion has decayed past -600 dB. Flushing keeps the state out of 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
// subnormal range, where a ringing-out voice would otherwise stall the FPU for thousands of // 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. // samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
inline constexpr float kFilterDenormalFloor = 1e-30f; inline constexpr float kFilterDenormalFloor = 1e-30f;
class VoiceFilter { class VoiceFilter {
public: 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; static constexpr int kMaxChannels = 2;
struct State { struct State {
float x1 = 0.0f; float ic1 = 0.0f; // band-pass integrator
float x2 = 0.0f; float ic2 = 0.0f; // low-pass integrator
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
}; };
// Recomputes coefficients from the control positions. History is deliberately preserved so // Recomputes coefficients from the control positions. State is deliberately preserved so a
// a live parameter move glides instead of clicking; call reset() at note-on. // live parameter move glides instead of clicking; call reset() at note-on.
void prepare(const FilterSettings& settings, double sampleRate); void prepare(const FilterSettings& settings, double sampleRate);
void reset(); void reset();
@@ -80,50 +48,32 @@ public:
assert(channel >= 0 && channel < kMaxChannels); assert(channel >= 0 && channel < kMaxChannels);
State& s = state_[channel]; State& s = state_[channel];
// The high-pass numerator collapses toward zero as cutoff falls, taking the resonance const float v3 = x - s.ic2;
// with it; feeding a saturated share of an earlier output back into the input restores const float v1 = coeffs_.a1 * s.ic1 + coeffs_.a2 * v3;
// the character the coefficients alone stop producing down there. The 0.9f pre-scale is const float v2 = s.ic2 + coeffs_.a2 * s.ic1 + coeffs_.a3 * v3;
// 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 y = coeffs_.b0 * in + coeffs_.b1 * s.x1 + coeffs_.b2 * s.x2 // The drive stage, and the only nonlinearity. It shapes the BAND-PASS integrator state
- coeffs_.a1 * s.y1 - coeffs_.a2 * s.y2; // 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; // Snap the state once the whole resonator has decayed past -600 dB. Testing BOTH
s.x1 = in; // integrators is testing the ENVELOPE rather than one sample, and that is required, not
s.y2 = s.y1; // tidy: ic1 and ic2 are in quadrature, so a resonator swings each of them through zero
s.y1 = y; // 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
// Snap the RECURSIVE half of the state once it has decayed past -600 dB. Only y1/y2 // forever instead of going quiet.
// are flushed (and only they are tested) — x1/x2 is an FIR tail that shifts out within if (s.ic1 > -kFilterDenormalFloor && s.ic1 < kFilterDenormalFloor &&
// two samples on its own, and a high-pass has an exact DC null (b1 == -2*b0 bit-exactly), s.ic2 > -kFilterDenormalFloor && s.ic2 < kFilterDenormalFloor) {
// so under a constant/DC-biased input y decays to zero while x1/x2 sit at the input s.ic1 = 0.0f;
// level; clearing x1/x2 too would discard that history and the next sample would s.ic2 = 0.0f;
// 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;
} }
// 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 return mix_.m0 * x + mix_.m1 * v1 + mix_.m2 * v2;
// 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;
} }
void processFrame(float* samples, int channelCount) { void processFrame(float* samples, int channelCount) {
@@ -131,7 +81,7 @@ public:
for (int c = 0; c < channelCount; ++c) samples[c] = process(c, samples[c]); 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. // ringing and cannot contribute further output.
bool isSilent() const; bool isSilent() const;
@@ -139,23 +89,13 @@ public:
assert(channel >= 0 && channel < kMaxChannels); assert(channel >= 0 && channel < kMaxChannels);
return state_[channel]; return state_[channel];
} }
const BiquadCoeffs& coeffs() const { return coeffs_; } const SvfCoeffs& coeffs() const { return coeffs_; }
const MorphMix& mix() const { return mix_; }
private: private:
// The feedback tap, fbDelay_ + fbDelayFrac_ samples back. Not named near/far: those are SvfCoeffs coeffs_{};
// legacy Windows macros, and this header is bound for translation units that see windows.h. MorphMix mix_{};
float fbTap(const State& s) const { float driveDepth_ = 0.0f;
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;
State state_[kMaxChannels]{}; State state_[kMaxChannels]{};
}; };
+516 -518
View File
File diff suppressed because it is too large Load Diff