Make the high-pass feedback tap a fixed 1/48000 s interval so resonance stops scaling with sample rate; 48k and 44.1k bit-identical

This commit is contained in:
2026-07-30 08:01:28 -04:00
parent 7af3c0c630
commit 7d42d7ed29
4 changed files with 402 additions and 59 deletions
@@ -73,6 +73,37 @@ feedback restores the character down there. Ported behavior; the constant is the
knob if the feel needs adjusting. `audio_saturate` and `H()` from the source were unused
by the biquads and were not ported.
### The feedback tap is a fixed TIME, and 48 kHz is the calibration anchor
`kFilterFeedbackDelaySeconds` (1/48000 s) is the interval the feedback tap reaches back,
resolved to a sample offset at `prepare()` and read with linear interpolation between two
whole taps. It is **not** a fallback sample rate and does not breach the
no-hardcoded-sample-rates ruling: nothing here ever substitutes it for the host's rate,
which still arrives as a parameter and is the only thing the coefficients are computed
from. It is a tuning constant of the filter, in the same sense as an attack time.
The firmware ran one fixed rate, so a tap that reached back one *sample* and one that
reached back a fixed *interval* were indistinguishable there. On a variable-rate host they
are not: the loop closes once per sample, so a one-sample tap made the loop's phase at the
cutoff — and with it the resonant emphasis and the stability margin — a function of the
rate. Measured peak/passband at fc=4 kHz, res=1.0 ran 5.02 at 48k against 8.52 at 192k.
Two consequences worth knowing before touching this:
- **48 kHz is the reference and must stay bit-identical.** It is the rate the constants
were voiced at. The interval resolves to exactly one sample there, so 48k reproduces the
firmware kernel sample-for-sample; `testFortyEightKilohertzBehaviorIsUnchanged` pins that
with literals captured before the tap became a time.
- **44.1 kHz cannot be corrected and is deliberately left alone.** One sample there is
already *longer* than the interval, and the loop must contain at least one sample of
delay or it is algebraic and uncomputable. So 44.1k keeps the firmware's single tap and
sits up to ~6% off 48k at the top of the cutoff range — exactly where it has always been.
Everything at or above 48k lands within the bilinear discretization difference of 48k.
The tap line is written with the **flushed** `y1`, so it drains to exact zero behind a
flushed recursion rather than circulating denormals; `isSilent()` therefore has to scan the
whole line, not just the newest entry.
### Denormal flushing
`process()` flushes the **y** history to exact zero below `kFilterDenormalFloor` (1e-30).
@@ -95,3 +126,6 @@ signal that a voice's filter can no longer contribute output.
here inverts the poles.
- **No call site yet.** Wiring the filter into the voice path is a separate track; nothing
in `sampler_core` references this module today.
- **Decay to the denormal floor is a fixed wall-clock time (~0.21 s), not a sample count.**
A test budget expressed in samples is therefore itself a rate assumption — a fixed 20000
samples is ample at 48k and expires mid-decay at 96k and above.
@@ -11,6 +11,18 @@ void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
? 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() {
@@ -19,8 +31,10 @@ void VoiceFilter::reset() {
bool VoiceFilter::isSilent() const {
for (const State& s : state_) {
if (s.x1 != 0.0f || s.x2 != 0.0f || s.y1 != 0.0f || s.y2 != 0.0f || s.fb != 0.0f) {
return false;
if (s.x1 != 0.0f || s.x2 != 0.0f || s.y1 != 0.0f || s.y2 != 0.0f) return false;
// The whole tap line, not just the newest entry: an older tap still reaches the input.
for (float v : s.fb) {
if (v != 0.0f) return false;
}
}
return true;
@@ -32,6 +32,24 @@ struct FilterSettings {
// 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
// samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
@@ -47,7 +65,8 @@ public:
float x2 = 0.0f;
float y1 = 0.0f;
float y2 = 0.0f;
float fb = 0.0f; // last output; the high-pass input-feedback tap
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
@@ -62,12 +81,16 @@ public:
State& s = state_[channel];
// The high-pass numerator collapses toward zero as cutoff falls, taking the resonance
// with it; feeding a saturated share of the last output back into the input restores
// with it; feeding a saturated share of an earlier output back into the input restores
// the character the coefficients alone stop producing down there. The 0.9f pre-scale is
// carried from the source firmware, uncalibrated here — no derivation is known for it.
const float in = (mode_ == FilterMode::HighPass)
? x - fbAmount_ * feedbackSaturate(s.fb * 0.9f)
: x;
// 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
- coeffs_.a1 * s.y1 - coeffs_.a2 * s.y2;
@@ -93,10 +116,13 @@ public:
s.y1 = 0.0f;
s.y2 = 0.0f;
}
// Stored unconditionally even in LP mode, where nothing reads it: the mode branch above
// already exists, but gating this one 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.y1;
// Pushes the FLUSHED y1, so the tap line drains to exact zero behind a flushed recursion
// instead of feeding denormals back in. Stored unconditionally even in LP mode, where
// nothing reads it: the mode branch above already exists, but gating this store on it
// buys nothing a dead-store-eliminating compiler doesn't already do for free, at the
// cost of a second branch on the mode.
s.fb[s.fbWrite & (kFilterFeedbackTaps - 1)] = s.y1;
++s.fbWrite;
return y;
}
@@ -116,9 +142,20 @@ public:
const BiquadCoeffs& coeffs() const { return coeffs_; }
private:
// The feedback tap, fbDelay_ + fbDelayFrac_ samples back. Not named near/far: those are
// legacy Windows macros, and this header is bound for translation units that see windows.h.
float fbTap(const State& s) const {
constexpr unsigned mask = kFilterFeedbackTaps - 1;
const float recent = s.fb[(s.fbWrite - fbDelay_) & mask];
const float older = s.fb[(s.fbWrite - fbDelay_ - 1u) & mask];
return recent + fbDelayFrac_ * (older - recent);
}
BiquadCoeffs coeffs_{};
FilterMode mode_ = FilterMode::LowPass;
float fbAmount_ = 0.0f;
float fbDelayFrac_ = 0.0f;
unsigned fbDelay_ = 1;
State state_[kMaxChannels]{};
};