Fix filter DSP click-train flush and finish namespace nesting + test coverage

This commit is contained in:
2026-07-30 07:00:41 -04:00
parent 78e112d1f9
commit 7af3c0c630
9 changed files with 97 additions and 35 deletions
+47 -9
View File
@@ -13,7 +13,7 @@
#include <cstdio>
#include <initializer_list>
using namespace reasampler::instrument::engine;
using namespace reasampler::instrument::engine::filter;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -41,9 +41,6 @@ static void testCutoffMapsThreeDecadesLogarithmically() {
CHECK_NEAR(filterCutoffHzFromNorm(1.0f / 6.0f), 20.0 * std::sqrt(10.0), 1e-3);
CHECK_NEAR(filterCutoffHzFromNorm(0.5f), 20.0 * std::sqrt(1000.0), 1e-2);
// A linear sweep would put the midpoint at 10010 Hz; the log sweep is nowhere near it.
CHECK(filterCutoffHzFromNorm(0.5f) < 1000.0f);
CHECK_NEAR(filterCutoffHzFromNorm(-1.0f), 20.0, 1e-3);
CHECK_NEAR(filterCutoffHzFromNorm(2.0f), 20000.0, 1e-2);
}
@@ -351,6 +348,33 @@ static void testStateFlushesToZeroWithoutStallingInDenormals() {
}
}
// A high-pass has an exact DC null (b1 == -2*b0 bit-exactly), so under sustained DC the
// recursive y decays to zero while x1/x2 sit pinned at the DC level -- the case the zero-input
// test above cannot see, since there x1/x2 are zero anyway. A flush that clears x1/x2 along
// 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.
static void testHighPassSustainedDCDoesNotReRing() {
const double sr = 48000.0;
VoiceFilter f;
f.prepare({FilterMode::HighPass, filterNormFromCutoffHz(1000.0f), 0.0f}, 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;
}
}
// 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);
}
// ---------------------------------------------------------------------------
// Impulse / step sanity and saturation
// ---------------------------------------------------------------------------
@@ -380,11 +404,23 @@ static void testLowpassStepSettlesToUnity() {
for (int i = 0; i < 48000; ++i) y = f.process(0, 1.0f);
CHECK_NEAR(y, 1.0, 1e-3); // DC passes a lowpass at unity
// A DC step through a highpass should settle to (and STAY AT) zero. Sampling only the
// final value is not enough to prove that: a resonator swings through zero twice a cycle,
// so a single late sample can land near zero while the envelope is still ringing well
// above it elsewhere in the same run -- track the worst case over the settled region.
VoiceFilter hp;
hp.prepare({FilterMode::HighPass, filterNormFromCutoffHz(1000.0f), 0.0f}, sr);
hp.reset();
for (int i = 0; i < 48000; ++i) y = hp.process(0, 1.0f);
CHECK_NEAR(y, 0.0, 1e-3); // and is fully rejected by a highpass
const int settle = 200;
float worstAfterSettle = 0.0f;
for (int i = 0; i < 48000; ++i) {
y = hp.process(0, 1.0f);
if (i >= settle) {
const float a = std::fabs(y);
if (a > worstAfterSettle) worstAfterSettle = a;
}
}
CHECK(worstAfterSettle < 1e-3f); // fully rejected by a highpass, not just at one instant
}
static void testResetClearsHistoryButPrepareKeepsIt() {
@@ -415,13 +451,14 @@ static void testChannelStateIsIndependent() {
CHECK(frame[0] != frame[1]);
}
static void testFeedbackSaturationIsContinuousAndBounded() {
static void testFeedbackSaturationIsContinuousWithGentleLinearTail() {
CHECK_NEAR(feedbackSaturate(0.0f), 0.0, 1e-9);
// Odd symmetry.
CHECK_NEAR(feedbackSaturate(1.5f), -feedbackSaturate(-1.5f), 1e-6);
// Continuous across the threshold at +/-2.
CHECK_NEAR(feedbackSaturate(2.0f - 1e-4f), feedbackSaturate(2.0f + 1e-4f), 1e-4);
// Compresses hard: a 100x input does not give a 100x output.
// Past the threshold the curve continues on a 0.1 slope rather than hard-clipping -- it is
// NOT bounded, so this pins the linear continuation's shallow slope, not a ceiling.
CHECK(std::fabs(feedbackSaturate(100.0f)) < 12.0f);
CHECK(feedbackSaturate(100.0f) > feedbackSaturate(50.0f));
}
@@ -438,11 +475,12 @@ int main() {
testMeasuredResponsePeaksAtCutoffInBothModes();
testFullRangeCutoffSweepAtAudioRateStaysBounded();
testStateFlushesToZeroWithoutStallingInDenormals();
testHighPassSustainedDCDoesNotReRing();
testImpulseResponseMatchesDifferenceEquation();
testLowpassStepSettlesToUnity();
testResetClearsHistoryButPrepareKeepsIt();
testChannelStateIsIndependent();
testFeedbackSaturationIsContinuousAndBounded();
testFeedbackSaturationIsContinuousWithGentleLinearTail();
if (g_fail == 0) std::printf("filter_tests: all passed\n");
else std::printf("filter_tests: %d FAILED\n", g_fail);