From 7d42d7ed29d17f6bdee8b777f3e3a638138c6e5a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 30 Jul 2026 08:01:28 -0400 Subject: [PATCH] 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 --- src/core/instrument/engine/filter/CLAUDE.md | 34 ++ .../instrument/engine/filter/voice_filter.cpp | 18 +- .../instrument/engine/filter/voice_filter.h | 55 ++- tests/test_filter.cpp | 354 +++++++++++++++--- 4 files changed, 402 insertions(+), 59 deletions(-) diff --git a/src/core/instrument/engine/filter/CLAUDE.md b/src/core/instrument/engine/filter/CLAUDE.md index 47b8835..97f5b21 100644 --- a/src/core/instrument/engine/filter/CLAUDE.md +++ b/src/core/instrument/engine/filter/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/filter/voice_filter.cpp b/src/core/instrument/engine/filter/voice_filter.cpp index d868e48..7cefa2d 100644 --- a/src/core/instrument/engine/filter/voice_filter.cpp +++ b/src/core/instrument/engine/filter/voice_filter.cpp @@ -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(taps); + fbDelayFrac_ = static_cast(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; diff --git a/src/core/instrument/engine/filter/voice_filter.h b/src/core/instrument/engine/filter/voice_filter.h index a1aa307..ba1d491 100644 --- a/src/core/instrument/engine/filter/voice_filter.h +++ b/src/core/instrument/engine/filter/voice_filter.h @@ -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]{}; }; diff --git a/tests/test_filter.cpp b/tests/test_filter.cpp index d5c9c18..c8914ec 100644 --- a/tests/test_filter.cpp +++ b/tests/test_filter.cpp @@ -238,17 +238,19 @@ static void testHighQPeaksAtCutoffInBothModes() { // Drive real sines through VoiceFilter and measure steady-state RMS. Unlike the analytic // check above this also exercises the high-pass input-feedback path, which is outside the -// coefficient transfer function. +// coefficient transfer function. The settle and measure windows are wall-clock, not sample +// counts, so every rate integrates the same amount of signal. static double measuredRms(FilterMode mode, float cutoffNorm, float resNorm, double freqHz, - double sr) { + double sr, double amp = 1.0) { VoiceFilter f; f.prepare({mode, cutoffNorm, resNorm}, sr); f.reset(); - const int settle = 24000, measure = 24000; + const int settle = static_cast(sr * 0.15); + const int measure = static_cast(sr * 0.10); double sumSq = 0.0; for (int i = 0; i < settle + measure; ++i) { - const float x = static_cast(std::sin(2.0 * kPi * freqHz * i / sr)); + const float x = static_cast(amp * std::sin(2.0 * kPi * freqHz * i / sr)); const float y = f.process(0, x); if (i >= settle) sumSq += static_cast(y) * y; } @@ -280,6 +282,233 @@ static void testMeasuredResponsePeaksAtCutoffInBothModes() { } } +// --------------------------------------------------------------------------- +// Sample-rate invariance +// --------------------------------------------------------------------------- + +// The rates the invariance claim is made over. 88.2k is deliberately included: it is the rate +// whose calibrated feedback delay lands between two whole taps, so it is the one the +// interpolating read has to earn. +static const double kRates[] = {44100.0, 48000.0, 88200.0, 96000.0, 192000.0}; +static constexpr int kRateCount = 5; +static constexpr int kRef48k = 1; // index of the reference rate within kRates + +// Resonant emphasis: level at the cutoff over the passband level. Measured at the requested +// cutoff rather than at the scanned peak so no frequency-grid quantization leaks into the +// comparison. The passband reference is the same frequency at every rate, or the ratio would +// compare a different measurement at each rate -- and it must stay well clear of the LOWEST +// Nyquist tested, since a high-pass reference near 44.1k's band edge measures the bilinear +// warping rather than the resonance. +static double emphasisAtCutoff(FilterMode mode, double fcHz, float resNorm, double sr) { + const float cn = filterNormFromCutoffHz(static_cast(fcHz)); + const double refHz = (mode == FilterMode::LowPass) ? fcHz / 8.0 : fcHz * 8.0; + return measuredRms(mode, cn, resNorm, fcHz, sr, 0.25) / + measuredRms(mode, cn, resNorm, refHz, sr, 0.25); +} + +// The feedback loop's contribution alone: the measured closed-loop level at a frequency over the +// level the bare coefficients predict there. Dividing the coefficient response out removes the +// bilinear discretization difference between rates -- which is real, correct, and not something +// a feedback fix can or should touch -- leaving exactly the loop under audit. In low-pass mode +// there is no loop, so this is identically 1 at every rate. +static double feedbackContribution(FilterMode mode, double fcHz, float resNorm, double sr) { + const float cn = filterNormFromCutoffHz(static_cast(fcHz)); + VoiceFilter f; + f.prepare({mode, cn, resNorm}, sr); + const double openLoopRms = magnitudeAt(f.coeffs(), fcHz, sr) * 0.25 / std::sqrt(2.0); + return measuredRms(mode, cn, resNorm, fcHz, sr, 0.25) / openLoopRms; +} + +// Where the response actually peaks, as a multiple of the requested cutoff. +static double peakOverCutoff(FilterMode mode, double fcHz, float resNorm, double sr) { + const float cn = filterNormFromCutoffHz(static_cast(fcHz)); + double peak = 0.0, peakF = 0.0; + for (int i = 0; i <= 12; ++i) { + const double f = fcHz * std::pow(2.0, -0.5 + i / 12.0); + const double r = measuredRms(mode, cn, resNorm, f, sr, 0.25); + if (r > peak) { peak = r; peakF = f; } + } + return peakF / fcHz; +} + +// The defect these pin: the high-pass feedback loop closes once per sample, so while its tap was +// the immediately previous output the loop's phase at the cutoff -- and with it the resonant +// emphasis -- scaled with the sample rate. Against that one-sample tap, emphasisAtCutoff for +// fc=1 kHz, res=1.0 measured 5.46 at 48k rising monotonically to 6.04 at 192k (10.5%), and +// feedbackContribution for fc=4 kHz, res=1.0 ran 0.443 at 48k against 0.506 at 192k (14.4%). +// Both now sit inside the bounds below. +// +// The two tolerances split on the reference rate, and the split is load-bearing rather than +// convenient. At or above 48k the calibrated interval is at least one sample, so the tap +// reproduces it and only the bilinear discretization difference remains. Below it -- 44.1k -- +// one sample is ALREADY longer than the interval, so the delay cannot be shortened to match +// without a sub-sample delay the loop cannot contain; 44.1k is left exactly where it has always +// been, which is up to 6% off 48k at the top of the cutoff range. +static constexpr double kAtOrAboveReferenceTolerance = 0.02; +static constexpr double kBelowReferenceTolerance = 0.08; + +static void checkInvariant(const char* what, FilterMode mode, double fcHz, float resNorm, + double (*measure)(FilterMode, double, float, double)) { + const double reference = measure(mode, fcHz, resNorm, kRates[kRef48k]); + for (int r = 0; r < kRateCount; ++r) { + const double v = measure(mode, fcHz, resNorm, kRates[r]); + const double deviation = std::fabs(v - reference) / reference; + const double tolerance = kRates[r] >= kRates[kRef48k] ? kAtOrAboveReferenceTolerance + : kBelowReferenceTolerance; + if (!(deviation <= tolerance)) { + std::printf("FAIL line %d: %s %s fc=%.0f res=%.2f at %.0f Hz: %.5f vs 48k %.5f " + "(%.2f%% > %.2f%%)\n", + __LINE__, what, mode == FilterMode::LowPass ? "LP" : "HP", fcHz, resNorm, + kRates[r], v, reference, deviation * 100.0, tolerance * 100.0); + ++g_fail; + } + } +} + +// End-to-end: the emphasis a listener hears, coefficients and feedback together. Held to cutoffs +// whose passband reference (8x the cutoff) stays well below 44.1k's band edge -- higher cutoffs +// are covered by the isolated test below, which does not need a passband reference at all. +static void testHighPassResonanceIsRateInvariant() { + for (float res : {0.2f, 0.5f, 1.0f}) { + checkInvariant("emphasis", FilterMode::HighPass, 250.0, res, emphasisAtCutoff); + checkInvariant("emphasis", FilterMode::HighPass, 1000.0, res, emphasisAtCutoff); + } +} + +// The low-pass has no feedback path, so it was already invariant. Pinning it is the control: it +// proves the measurement detects what it claims to, and it keeps a future feedback path on the +// low-pass from acquiring the same defect unnoticed. +static void testLowPassResonanceIsRateInvariant() { + for (float res : {0.2f, 0.5f, 1.0f}) { + checkInvariant("emphasis", FilterMode::LowPass, 250.0, res, emphasisAtCutoff); + checkInvariant("emphasis", FilterMode::LowPass, 1000.0, res, emphasisAtCutoff); + checkInvariant("emphasis", FilterMode::LowPass, 4000.0, res, emphasisAtCutoff); + } +} + +// The precise form of the same claim, with the discretization difference divided out, so it also +// holds at the top of the cutoff range where a passband reference cannot sit clear of 44.1k's +// band edge. +static void testFeedbackLoopContributionIsRateInvariant() { + for (float res : {0.2f, 0.5f, 1.0f}) { + for (double fc : {250.0, 1000.0, 4000.0}) { + checkInvariant("loop", FilterMode::HighPass, fc, res, feedbackContribution); + checkInvariant("loop", FilterMode::LowPass, fc, res, feedbackContribution); + } + } +} + +static void testResonantPeakTracksCutoffAtEveryRate() { + for (FilterMode mode : {FilterMode::LowPass, FilterMode::HighPass}) { + for (double fc : {250.0, 1000.0, 4000.0}) { + for (int r = 0; r < kRateCount; ++r) { + // At full resonance there is a real peak to find; a quarter octave either side + // of the requested cutoff is the same window the 48k-only test uses. + const double ratio = peakOverCutoff(mode, fc, 1.0f, kRates[r]); + if (!(ratio > 1.0 / 1.19 && ratio < 1.19)) { + std::printf("FAIL line %d: %s peak at %.3f x fc (fc=%.0f, sr=%.0f)\n", + __LINE__, mode == FilterMode::LowPass ? "LP" : "HP", ratio, fc, + kRates[r]); + ++g_fail; + } + } + } + } +} + +// 48k is the rate the feedback constants were voiced at, and the rate Daniel's ear judgments +// were made against, so making the other rates match it must not move it. These literals were +// captured from the build BEFORE the fixed-time feedback tap landed; the tap resolves to +// exactly one sample at 48k, so they must reproduce bit-for-bit rather than merely closely. +static void testFortyEightKilohertzBehaviorIsUnchanged() { + struct Pin { + FilterMode mode; + double y1, y7, y31, y127, energy, sineRms; + }; + const Pin pins[2] = { + {FilterMode::LowPass, 0.016871979, 0.098936319, -0.084338546, -0.044524558, 0.652648822, + 1.767755710}, + {FilterMode::HighPass, -0.184770823, -0.082661532, 0.057580549, -0.008336116, 1.427662234, + 0.895141269}, + }; + + for (const Pin& p : pins) { + VoiceFilter f; + f.prepare({p.mode, filterNormFromCutoffHz(1000.0f), 1.0f}, 48000.0); + f.reset(); + double energy = 0.0; + for (int i = 0; i < 4096; ++i) { + const float y = f.process(0, i == 0 ? 1.0f : 0.0f); + energy += static_cast(y) * y; + if (i == 1) CHECK_NEAR(y, p.y1, 1e-7); + if (i == 7) CHECK_NEAR(y, p.y7, 1e-7); + if (i == 31) CHECK_NEAR(y, p.y31, 1e-7); + if (i == 127) CHECK_NEAR(y, p.y127, 1e-7); + } + CHECK_NEAR(energy, p.energy, 1e-7); + + VoiceFilter g; + g.prepare({p.mode, filterNormFromCutoffHz(1000.0f), 1.0f}, 48000.0); + g.reset(); + double sumSq = 0.0; + for (int i = 0; i < 28800; ++i) { + const float x = static_cast(0.25 * std::sin(2.0 * kPi * 1000.0 * i / 48000.0)); + const float y = g.process(0, x); + if (i >= 14400) sumSq += static_cast(y) * y; + } + CHECK_NEAR(std::sqrt(sumSq / 14400.0), p.sineRms, 1e-7); + } +} + +// The tap is a fixed INTERVAL, so the sample offset it resolves to scales with the rate. Read +// out of the filter's behavior, not its internals: run an impulse through the high-pass and +// alongside it the bare difference equation on the SAME coefficients with no feedback at all. +// The tap reads y[n-D], and every earlier history slot is zero, so the first sample at which the +// two can possibly diverge is exactly D. Against the pre-fix one-sample tap this reports 1 at +// every rate; it must now report 1, 1, 1, 2, 4. +static void testFeedbackTapOffsetScalesWithSampleRate() { + const int expected[kRateCount] = {1, 1, 1, 2, 4}; + for (int r = 0; r < kRateCount; ++r) { + VoiceFilter f; + f.prepare({FilterMode::HighPass, filterNormFromCutoffHz(1000.0f), 1.0f}, kRates[r]); + f.reset(); + const BiquadCoeffs c = f.coeffs(); + + float x1 = 0.0f, x2 = 0.0f, y1 = 0.0f, y2 = 0.0f; + int firstDivergence = -1; + for (int i = 0; i < 64 && firstDivergence < 0; ++i) { + const float x = (i == 0) ? 1.0f : 0.0f; + const float actual = f.process(0, x); + const float noFeedback = c.b0 * x + c.b1 * x1 + c.b2 * x2 - c.a1 * y1 - c.a2 * y2; + x2 = x1; + x1 = x; + y2 = y1; + y1 = noFeedback; + if (actual != noFeedback) firstDivergence = i; + } + if (firstDivergence != expected[r]) { + std::printf("FAIL line %d: sr=%.0f feedback first reaches the output at sample %d, " + "expected %d\n", + __LINE__, kRates[r], firstDivergence, expected[r]); + ++g_fail; + } + } +} + +// The floor is load-bearing, not defensive: below 48k one sample is ALREADY longer than the +// calibrated interval, so the offset cannot shrink to match without a sub-sample delay the loop +// cannot contain -- it would be algebraic and uncomputable. A rate at or below the reference +// therefore keeps the firmware's single tap, and a non-positive rate lands on the same floor +// rather than on an invented rate. +static void testFeedbackTapNeverFallsBelowOneSample() { + for (double sr : {-48000.0, 0.0, 1000.0, 22050.0, 44100.0, 48000.0}) { + VoiceFilter f; + f.prepare({FilterMode::HighPass, filterNormFromCutoffHz(1000.0f), 1.0f}, sr); + f.reset(); + for (int i = 0; i < 512; ++i) CHECK(std::isfinite(f.process(0, i == 0 ? 1.0f : 0.0f))); + } +} + // --------------------------------------------------------------------------- // Stability // --------------------------------------------------------------------------- @@ -292,13 +521,16 @@ static void testFullRangeCutoffSweepAtAudioRateStaysBounded() { return static_cast(static_cast(rng >> 9) - (1 << 22)) / static_cast(1 << 22); }; - for (double sr : {44100.0, 48000.0, 96000.0}) { + for (int r = 0; r < kRateCount; ++r) { + const double sr = kRates[r]; for (FilterMode mode : {FilterMode::LowPass, FilterMode::HighPass}) { for (float res : {0.0f, 0.5f, 1.0f}) { for (int direction = 0; direction < 2; ++direction) { VoiceFilter f; f.reset(); - const int n = 48000; + // A fixed WALL-CLOCK sweep: the same cutoff travel per second at every rate, + // so the per-sample coefficient step gets no gentler as the rate rises. + const int n = static_cast(sr * 0.25); for (int i = 0; i < n; ++i) { const float t = static_cast(i) / static_cast(n - 1); // Per-sample coefficient update across the whole cutoff travel. @@ -314,37 +546,50 @@ static void testFullRangeCutoffSweepAtAudioRateStaysBounded() { } } +// The decay to the floor is a fixed WALL-CLOCK time (~0.21 s at these settings), not a fixed +// sample count -- so the budget has to scale with the rate. A fixed 20000-sample budget is itself +// a rate assumption: it is ample at 48k and expires mid-decay at 96k and above. static void testStateFlushesToZeroWithoutStallingInDenormals() { - const double sr = 48000.0; - for (FilterMode mode : {FilterMode::LowPass, FilterMode::HighPass}) { - VoiceFilter f; - f.prepare({mode, filterNormFromCutoffHz(1000.0f), 1.0f}, sr); - f.reset(); + for (int r = 0; r < kRateCount; ++r) { + const double sr = kRates[r]; + const int budget = static_cast(sr * 0.5); + for (FilterMode mode : {FilterMode::LowPass, FilterMode::HighPass}) { + VoiceFilter f; + f.prepare({mode, filterNormFromCutoffHz(1000.0f), 1.0f}, sr); + f.reset(); - // Excite, then hard-cut to silence the way a released voice does. - for (int i = 0; i < 480; ++i) { - f.process(0, 0.5f * static_cast(std::sin(2.0 * kPi * 1000.0 * i / sr))); - } - - int subnormalSamples = 0; - int silentAt = -1; - for (int i = 0; i < 20000; ++i) { - f.process(0, 0.0f); - const VoiceFilter::State& s = f.state(0); - const float vals[5] = {s.x1, s.x2, s.y1, s.y2, s.fb}; - for (float v : vals) { - if (v != 0.0f && std::fabs(v) < FLT_MIN) { ++subnormalSamples; break; } + // Excite, then hard-cut to silence the way a released voice does. + const int excite = static_cast(sr * 0.01); + for (int i = 0; i < excite; ++i) { + f.process(0, 0.5f * static_cast(std::sin(2.0 * kPi * 1000.0 * i / sr))); } - if (silentAt < 0 && f.isSilent()) silentAt = i; + + int subnormalSamples = 0; + int silentAt = -1; + for (int i = 0; i < budget; ++i) { + f.process(0, 0.0f); + const VoiceFilter::State& s = f.state(0); + bool subnormal = false; + for (float v : {s.x1, s.x2, s.y1, s.y2}) { + if (v != 0.0f && std::fabs(v) < FLT_MIN) subnormal = true; + } + for (float v : s.fb) { + if (v != 0.0f && std::fabs(v) < FLT_MIN) subnormal = true; + } + if (subnormal) ++subnormalSamples; + if (silentAt < 0 && f.isSilent()) silentAt = i; + } + // Without the flush the state grinds down through the subnormal range for thousands + // of samples; a stray sample or two at a zero crossing is not a stall. The feedback + // tap line holds copies of the flushed y, so it drains behind it rather than feeding + // subnormals back into the loop. + CHECK(subnormalSamples <= 2); + CHECK(silentAt >= 0); + CHECK(silentAt < budget); + // And it stays silent — a flush that perturbs the feedback loop would re-excite it. + for (int i = 0; i < 1000; ++i) CHECK(f.process(0, 0.0f) == 0.0f); + CHECK(f.isSilent()); } - // Without the flush the state grinds down through the subnormal range for thousands - // of samples; a stray sample or two at a zero crossing is not a stall. - CHECK(subnormalSamples <= 2); - CHECK(silentAt >= 0); - CHECK(silentAt < 20000); - // And it stays silent — a flush that perturbs the feedback loop would re-excite it. - for (int i = 0; i < 1000; ++i) CHECK(f.process(0, 0.0f) == 0.0f); - CHECK(f.isSilent()); } } @@ -354,25 +599,31 @@ static void testStateFlushesToZeroWithoutStallingInDenormals() { // with y1/y2 discards that pinned history; the next sample then recomputes a full-amplitude // step from b0*in alone, which re-rings and repeats forever (a click train). This must fail // against a flush that also clears x1/x2. +// Run at full resonance as well as none: at res=0 the feedback share is zero and the tap line is +// inert, so that case alone would never notice the tap line failing to drain behind a flush. static void testHighPassSustainedDCDoesNotReRing() { - const double sr = 48000.0; - VoiceFilter f; - f.prepare({FilterMode::HighPass, filterNormFromCutoffHz(1000.0f), 0.0f}, sr); - f.reset(); + for (int r = 0; r < kRateCount; ++r) { + for (float res : {0.0f, 1.0f}) { + const double sr = kRates[r]; + VoiceFilter f; + f.prepare({FilterMode::HighPass, filterNormFromCutoffHz(1000.0f), res}, sr); + f.reset(); - const int settle = 1000; - float worstAfterSettle = 0.0f; - for (int i = 0; i < 20000; ++i) { - const float y = f.process(0, 1.0f); - if (i >= settle) { - const float a = std::fabs(y); - if (a > worstAfterSettle) worstAfterSettle = a; + const int settle = static_cast(sr * 0.05); + float worstAfterSettle = 0.0f; + for (int i = 0; i < static_cast(sr * 0.5); ++i) { + const float y = f.process(0, 1.0f); + if (i >= settle) { + const float a = std::fabs(y); + if (a > worstAfterSettle) worstAfterSettle = a; + } + } + // A correct flush leaves the settled output pinned near zero. The click train this + // regresses against recurs every ~4760 samples at 48k at a magnitude around 0.6 -- + // nowhere near this tolerance. + CHECK(worstAfterSettle < 1e-3f); } } - // A correct flush leaves the settled output pinned near zero. The click train this - // regresses against recurs every ~4760 samples at a magnitude around 0.6 -- nowhere near - // this tolerance. - CHECK(worstAfterSettle < 1e-3f); } // --------------------------------------------------------------------------- @@ -473,6 +724,13 @@ int main() { testPassbandGainIsUnity(); testHighQPeaksAtCutoffInBothModes(); testMeasuredResponsePeaksAtCutoffInBothModes(); + testHighPassResonanceIsRateInvariant(); + testLowPassResonanceIsRateInvariant(); + testFeedbackLoopContributionIsRateInvariant(); + testResonantPeakTracksCutoffAtEveryRate(); + testFortyEightKilohertzBehaviorIsUnchanged(); + testFeedbackTapOffsetScalesWithSampleRate(); + testFeedbackTapNeverFallsBelowOneSample(); testFullRangeCutoffSweepAtAudioRateStaysBounded(); testStateFlushesToZeroWithoutStallingInDenormals(); testHighPassSustainedDCDoesNotReRing();