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:
2026-07-30 08:01:28 -04:00
parent 7af3c0c630
commit 7d42d7ed29
4 changed files with 402 additions and 59 deletions
@@ -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]{};
};
+306 -48
View File
@@ -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<int>(sr * 0.15);
const int measure = static_cast<int>(sr * 0.10);
double sumSq = 0.0;
for (int i = 0; i < settle + measure; ++i) {
const float x = static_cast<float>(std::sin(2.0 * kPi * freqHz * i / sr));
const float x = static_cast<float>(amp * std::sin(2.0 * kPi * freqHz * i / sr));
const float y = f.process(0, x);
if (i >= settle) sumSq += static_cast<double>(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<float>(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<float>(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<float>(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<double>(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<float>(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<double>(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<float>(static_cast<int>(rng >> 9) - (1 << 22)) / static_cast<float>(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<int>(sr * 0.25);
for (int i = 0; i < n; ++i) {
const float t = static_cast<float>(i) / static_cast<float>(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<int>(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<float>(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<int>(sr * 0.01);
for (int i = 0; i < excite; ++i) {
f.process(0, 0.5f * static_cast<float>(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<int>(sr * 0.05);
float worstAfterSettle = 0.0f;
for (int i = 0; i < static_cast<int>(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();