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