Filter review fixes: gate softLimit on drive, correct flush/DF1 measurement claims, pin control-law test literals, fix stale bypass state
This commit is contained in:
+113
-19
@@ -14,6 +14,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
#include <limits>
|
||||
|
||||
using namespace reasampler::instrument::engine::filter;
|
||||
|
||||
@@ -116,6 +117,23 @@ static void testQSpansPointOneToTenWithRootTwoAtCenter() {
|
||||
CHECK_NEAR(filterQFromNorm(-1.0f), 0.1, 1e-6);
|
||||
CHECK_NEAR(filterQFromNorm(2.0f), 10.0, 1e-4);
|
||||
|
||||
// Pins the single quadratic-in-log-Q curve at two interior points, derived independently by
|
||||
// solving log Q = a + b*n + c*n^2 through the three anchors above rather than read out of
|
||||
// the implementation. A two-spliced-log-segments curve (log-linear on each half, the design
|
||||
// this module doc explicitly rejects for its center-detent slope kink) would give 0.376 and
|
||||
// 3.761 here instead — both comfortably outside this tolerance.
|
||||
{
|
||||
const double lo = std::log(static_cast<double>(kFilterQMin));
|
||||
const double mid = std::log(static_cast<double>(kFilterQCenter));
|
||||
const double hi = std::log(static_cast<double>(kFilterQMax));
|
||||
const double c = 2.0 * lo + 2.0 * hi - 4.0 * mid;
|
||||
const double b = hi - lo - c;
|
||||
const double a = lo;
|
||||
auto qLaw = [&](double n) { return std::exp(a + b * n + c * n * n); };
|
||||
CHECK_NEAR(filterQFromNorm(0.25f), qLaw(0.25), 1e-5);
|
||||
CHECK_NEAR(filterQFromNorm(0.75f), qLaw(0.75), 1e-5);
|
||||
}
|
||||
|
||||
// Strictly monotonic across the whole travel — no fold-back from the quadratic term.
|
||||
float prev = -1.0f;
|
||||
for (int i = 0; i <= 1000; ++i) {
|
||||
@@ -142,6 +160,10 @@ static void testDriveDepthIsZeroAtRestAndRisesMonotonically() {
|
||||
CHECK_NEAR(filterDriveDepthFromNorm(1.0f), kFilterDriveDepthMax, 1e-6);
|
||||
CHECK_NEAR(filterDriveDepthFromNorm(2.0f), kFilterDriveDepthMax, 1e-6);
|
||||
|
||||
// Pins the SQUARE law at an interior point, not just the anchors: a linear law would give
|
||||
// kFilterDriveDepthMax/2 (2.0) here, not kFilterDriveDepthMax/4 (1.0).
|
||||
CHECK_NEAR(filterDriveDepthFromNorm(0.5f), kFilterDriveDepthMax * 0.25, 1e-6);
|
||||
|
||||
float prev = -1.0f;
|
||||
for (int i = 0; i <= 100; ++i) {
|
||||
const float d = filterDriveDepthFromNorm(static_cast<float>(i) / 100.0f);
|
||||
@@ -195,6 +217,28 @@ static void testTheSampleRateEntersOnlyThroughG() {
|
||||
CHECK(svfCoeffs(1000.0f, 1.0f, -48000.0).g == 0.0f);
|
||||
}
|
||||
|
||||
// A voice re-prepared at a non-positive rate while still ringing must not latch isSilent()
|
||||
// false forever -- a future voice allocator using isSilent() as its free condition would leak
|
||||
// the voice. Bypass ignores state entirely (a1=1, a2=a3=0, bypassMix reads only the input), so
|
||||
// clearing it here is audibly free.
|
||||
static void testNonPositiveRatePrepareClearsStaleStateAndReportsSilent() {
|
||||
VoiceFilter f;
|
||||
f.prepare(at(1000.0, 1.0f, kLowPass), 48000.0);
|
||||
f.reset();
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
f.process(0, static_cast<float>(std::sin(2.0 * kPi * 1000.0 * i / 48000.0)));
|
||||
}
|
||||
CHECK(!f.isSilent()); // genuinely ringing before the rate goes bad
|
||||
|
||||
f.prepare({0.5f, 0.5f, kLowPass, 0.0f}, 0.0);
|
||||
CHECK(f.isSilent());
|
||||
for (int i = 0; i < 480000; ++i) {
|
||||
const float x = static_cast<float>(std::sin(0.1 * i));
|
||||
CHECK(f.process(0, x) == x);
|
||||
}
|
||||
CHECK(f.isSilent());
|
||||
}
|
||||
|
||||
// An invalid rate must pass the signal, not silence the instrument, whatever the morph asks for.
|
||||
static void testNonPositiveRatePassesSignalThroughAtEveryMorph() {
|
||||
for (float morph : {kHighPass, kBandPass, kLowPass}) {
|
||||
@@ -229,6 +273,23 @@ static void testMorphEndpointMixesAreExactlyPureTaps() {
|
||||
// Out-of-range clamps to the endpoints rather than extrapolating.
|
||||
CHECK(morphWeights(-1.0f).hp == 1.0f);
|
||||
CHECK(morphWeights(2.0f).lp == 1.0f);
|
||||
|
||||
// NaN clamps to neither endpoint (every comparison against it is false) and lands on pure
|
||||
// band-pass instead -- no crash, a sane fallback rather than an extrapolation.
|
||||
const MorphWeights nanW = morphWeights(std::numeric_limits<float>::quiet_NaN());
|
||||
CHECK(nanW.hp == 0.0f && nanW.bp == 1.0f && nanW.lp == 0.0f);
|
||||
}
|
||||
|
||||
// Pins the cos/sin curve at an interior point, not just the endpoints and the quadrature
|
||||
// identity (hp^2+bp^2+lp^2=1, which any equal-power reparameterization would also satisfy).
|
||||
// theta=0.5*pi*t^2 (quadratic in the leg fraction, still equal-power, still exact at both
|
||||
// ends) would give hp=0.9239/bp=0.3827 here instead of the cos/sin pair's 0.7071/0.7071.
|
||||
static void testMorphInteriorPointMatchesCosSinNotAnAlternateEqualPowerCurve() {
|
||||
const MorphWeights w = morphWeights(0.25f); // HP->BP leg, t = 2*0.25 = 0.5
|
||||
const double theta = 0.5 * kPi * 0.5;
|
||||
CHECK_NEAR(w.hp, std::cos(theta), 1e-6);
|
||||
CHECK_NEAR(w.bp, std::sin(theta), 1e-6);
|
||||
CHECK(w.lp == 0.0f);
|
||||
}
|
||||
|
||||
// HP and LP never carry weight at the same time. That is what keeps the centre a band-pass
|
||||
@@ -322,33 +383,64 @@ static void testMorphSweepHasNoDiscontinuity() {
|
||||
|
||||
// The hard acceptance criterion, in its strongest form: at drive 0 the kernel is BIT-IDENTICAL
|
||||
// to the same kernel with the limiter deleted. softLimit(x, 0) is x / sqrt(1) == x exactly, so
|
||||
// this holds by algebra rather than by tolerance.
|
||||
// this holds by algebra rather than by tolerance. Both channels and both entry points
|
||||
// (process() and processFrame()) are covered, not just channel 0 through process().
|
||||
struct LinearKernelRef {
|
||||
SvfCoeffs c;
|
||||
MorphMix mix;
|
||||
float ic1 = 0.0f, ic2 = 0.0f;
|
||||
|
||||
float step(float x) {
|
||||
const float v3 = x - ic2;
|
||||
const float v1 = c.a1 * ic1 + c.a2 * v3;
|
||||
const float v2 = ic2 + c.a2 * ic1 + c.a3 * v3;
|
||||
ic1 = 2.0f * v1 - ic1; // no limiter at all
|
||||
ic2 = 2.0f * v2 - ic2;
|
||||
if (ic1 > -kFilterDenormalFloor && ic1 < kFilterDenormalFloor &&
|
||||
ic2 > -kFilterDenormalFloor && ic2 < kFilterDenormalFloor) {
|
||||
ic1 = 0.0f;
|
||||
ic2 = 0.0f;
|
||||
}
|
||||
return mix.m0 * x + mix.m1 * v1 + mix.m2 * v2;
|
||||
}
|
||||
};
|
||||
|
||||
static float nextNoise(unsigned& rng) {
|
||||
rng = rng * 1664525u + 1013904223u;
|
||||
return static_cast<float>(static_cast<int>(rng >> 9) - (1 << 22)) /
|
||||
static_cast<float>(1 << 22);
|
||||
}
|
||||
|
||||
static void testDriveZeroIsBitIdenticalToTheLinearKernel() {
|
||||
for (float morph : {kHighPass, kBandPass, kLowPass}) {
|
||||
VoiceFilter f;
|
||||
f.prepare(at(1000.0, 1.0f, morph, 0.0f), 48000.0);
|
||||
f.reset();
|
||||
const SvfCoeffs c = f.coeffs();
|
||||
const MorphMix mix = f.mix();
|
||||
LinearKernelRef ref0{f.coeffs(), f.mix()};
|
||||
LinearKernelRef ref1{f.coeffs(), f.mix()};
|
||||
|
||||
float ic1 = 0.0f, ic2 = 0.0f;
|
||||
unsigned rng = 0x13579bdfu;
|
||||
unsigned rng0 = 0x13579bdfu;
|
||||
for (int i = 0; i < 4096; ++i) {
|
||||
rng = rng * 1664525u + 1013904223u;
|
||||
const float x = static_cast<float>(static_cast<int>(rng >> 9) - (1 << 22)) /
|
||||
static_cast<float>(1 << 22);
|
||||
const float x = nextNoise(rng0);
|
||||
CHECK(f.process(0, x) == ref0.step(x));
|
||||
}
|
||||
|
||||
const float v3 = x - ic2;
|
||||
const float v1 = c.a1 * ic1 + c.a2 * v3;
|
||||
const float v2 = ic2 + c.a2 * ic1 + c.a3 * v3;
|
||||
ic1 = 2.0f * v1 - ic1; // no limiter at all
|
||||
ic2 = 2.0f * v2 - ic2;
|
||||
if (ic1 > -kFilterDenormalFloor && ic1 < kFilterDenormalFloor &&
|
||||
ic2 > -kFilterDenormalFloor && ic2 < kFilterDenormalFloor) {
|
||||
ic1 = 0.0f;
|
||||
ic2 = 0.0f;
|
||||
}
|
||||
CHECK(f.process(0, x) == mix.m0 * x + mix.m1 * v1 + mix.m2 * v2);
|
||||
// process(1, ...): channel 1's state is independent of channel 0's above.
|
||||
unsigned rng1 = 0x2468acefu;
|
||||
for (int i = 0; i < 4096; ++i) {
|
||||
const float x = nextNoise(rng1);
|
||||
CHECK(f.process(1, x) == ref1.step(x));
|
||||
}
|
||||
|
||||
// processFrame(): both channels advanced together through the frame entry point,
|
||||
// continuing from the state each channel already has.
|
||||
for (int i = 0; i < 4096; ++i) {
|
||||
float frame[2] = {nextNoise(rng0), nextNoise(rng1)};
|
||||
const float want0 = ref0.step(frame[0]);
|
||||
const float want1 = ref1.step(frame[1]);
|
||||
f.processFrame(frame, 2);
|
||||
CHECK(frame[0] == want0);
|
||||
CHECK(frame[1] == want1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -712,9 +804,11 @@ int main() {
|
||||
|
||||
testSvfCoefficientsMatchPinnedValues();
|
||||
testTheSampleRateEntersOnlyThroughG();
|
||||
testNonPositiveRatePrepareClearsStaleStateAndReportsSilent();
|
||||
testNonPositiveRatePassesSignalThroughAtEveryMorph();
|
||||
|
||||
testMorphEndpointMixesAreExactlyPureTaps();
|
||||
testMorphInteriorPointMatchesCosSinNotAnAlternateEqualPowerCurve();
|
||||
testMorphNeverBlendsHighAgainstLowPass();
|
||||
testMorphEndpointsMatchTheAnalyticTwoPoleTargets();
|
||||
testCornerMagnitudeIsFlatAcrossTheWholeMorphSweep();
|
||||
|
||||
Reference in New Issue
Block a user