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:
+11
-9
@@ -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)
|
||||||
|
|||||||
@@ -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.7e−06 at 20 Hz).
|
||||||
|
|
||||||
|
The tap was a Q15 fixed-point workaround. At 16-bit fixed point the low-cutoff biquad loses
|
||||||
|
a ~17-bit cancellation and the resonance really does die; the feedback injected it back by
|
||||||
|
another route. float32 survives that cancellation with 7 bits to spare, so on this target
|
||||||
|
the tap did not restore character — it *reduced* it (HP landed 0.4% off the analytic RBJ
|
||||||
|
target with the tap disabled, and 25% off with it enabled), and it introduced both level
|
||||||
|
dependence and rate dependence.
|
||||||
|
|
||||||
|
Daniel's ruling on the level-dependent resonance bloom it produced: *"was a feature on the
|
||||||
|
hardware (one knob colorful HP for master FX), wrong choice for this approach."* Drive is
|
||||||
|
now an explicit user-controlled stage instead of an emergent side effect.
|
||||||
|
|
||||||
|
### The morph is a blend of taps, never a coefficient switch
|
||||||
|
|
||||||
|
An SVF produces high, band, and low from the same state, which is the reason this topology
|
||||||
|
was chosen. `FilterMode` as a discrete enum is retired. HP at 0.0, 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user