Fix filter DSP click-train flush and finish namespace nesting + test coverage
This commit is contained in:
+47
-9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user