From 736db623b3e8dc26c08bb3591a78cf9c3329d869 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 25 Aug 2026 18:33:47 +0200 Subject: [PATCH] Fix total-audio-silence-on-any-EQ-change: negate ADAU1701 feedback coeffs designPeakingEq() mapped the RBJ cookbook's a1/a2 straight into the ADAU1701 Param EQ cell's A0/A1 registers. The cookbook's difference equation subtracts the feedback terms; the ADAU1701 cell adds them. Any nonzero band gain therefore applied positive instead of negative feedback at the target frequency, so the biquad's state diverged and railed to a constant (inaudible DC) value -- the "any EQ/enhancement change goes completely silent, even the beep test tone" bug. Confirmed live: SigmaStudio's own direct safeload writes to the same registers (correctly signed by its own tool) only distorted, never silenced, which pointed at this driver's own coefficient math rather than the DSP chain or the safeload mechanism itself. Adds a host test asserting Jury stability for the ADD-convention denominator across the actual bass/stereo-enhance gain values and the GainDb range extremes, so a regression trips ctest instead of requiring a live listening test to notice. Co-Authored-By: Claude Sonnet 5 --- Software/components/core/src/BiquadDesign.cpp | 12 ++++- .../core/test/biquad_design_test.cpp | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/Software/components/core/src/BiquadDesign.cpp b/Software/components/core/src/BiquadDesign.cpp index 3fd63fc..db76b15 100644 --- a/Software/components/core/src/BiquadDesign.cpp +++ b/Software/components/core/src/BiquadDesign.cpp @@ -82,12 +82,20 @@ BiquadCoefficients designPeakingEq(FrequencyHz center, GainDb gain, const float a1 = -2.0F * cosOmega; const float a2 = 1.0F - alpha / a; + // RBJ's cookbook form subtracts the feedback terms + // (y = ... - (a1/a0) y[n-1] - (a2/a0) y[n-2]); the ADAU1701 Param EQ + // cell's A0/A1 registers add them instead (y = ... + A0 y[n-1] + + // A1 y[n-2]), so the normalized RBJ a1/a2 must be negated when they + // land in A0/A1. Without this, any nonzero gain applies positive + // instead of negative feedback at the band's pole, so the biquad's + // state diverges and rails to a constant (inaudible DC) value -- + // root cause of the 2026-08-25 total-silence-on-any-EQ-change bug. return BiquadCoefficients{ .b0 = b0 / a0, .b1 = b1 / a0, .b2 = b2 / a0, - .a0 = a1 / a0, - .a1 = a2 / a0, + .a0 = -(a1 / a0), + .a1 = -(a2 / a0), }; } diff --git a/Software/components/core/test/biquad_design_test.cpp b/Software/components/core/test/biquad_design_test.cpp index dd771cb..de2a4e1 100644 --- a/Software/components/core/test/biquad_design_test.cpp +++ b/Software/components/core/test/biquad_design_test.cpp @@ -54,6 +54,55 @@ namespace { return EXIT_SUCCESS; } +/* + * The ADAU1701 Param EQ cell's A0/A1 registers ADD the feedback terms + * (y = ... + A0 y[n-1] + A1 y[n-2]), unlike the RBJ cookbook's native + * subtractive form. Denominator 1 - A0 z^-1 - A1 z^-2 = 0 has poles at the + * roots of z^2 - A0 z - A1 = 0; by the Jury test for a monic real quadratic + * z^2 + c1 z + c0 (c1 = -A0, c0 = -A1), both poles lie strictly inside the + * unit circle iff |A1| < 1, A0 + A1 < 1, and A0 - A1 > -1. A missing + * negation when mapping the RBJ a1/a2 into A0/A1 (2026-08-25 total-silence + * regression) fails this for real gain/Q combinations, so this guards + * against that class of bug rather than just checking magnitudes. + */ +[[nodiscard]] bool isAdau1701Stable(const core::BiquadCoefficients &c) noexcept +{ + return std::fabs(c.a1) < 1.0F && (c.a0 + c.a1) < 1.0F + && (c.a0 - c.a1) > -1.0F; +} + +[[nodiscard]] int runPeakingStabilityTest() +{ + const struct + { + std::uint32_t hz; + float dbGain; + float q; + } cases[] = { + {100U, 9.0F, 0.9F}, // bass-enhance band 1 at max level + {400U, 3.0F, 1.0F}, // bass-enhance band 2 at max level + {1000U, -1.5F, 1.0F}, // stereo-enhance band 3 at max level + {3000U, 2.0F, 1.0F}, // stereo-enhance band 4 at max level + {8000U, 4.0F, 1.0F}, // stereo-enhance band 5 at max level + {1000U, 12.0F, 10.0F}, // GainDb::kMaxDb at high Q + {1000U, -96.0F, 0.2F}, // near GainDb::kMinDb at low Q + }; + + for (const auto &tc : cases) { + const auto center = core::FrequencyHz::tryFromHz(tc.hz); + const auto gain = core::GainDb::tryFromDb(tc.dbGain); + const core::BiquadCoefficients peaking = + core::designPeakingEq(*center, *gain, tc.q); + if (!isAdau1701Stable(peaking)) { + std::cerr << "unstable ADAU1701 biquad for " << tc.hz << " Hz, " + << tc.dbGain << " dB, Q=" << tc.q << ": a0=" << peaking.a0 + << " a1=" << peaking.a1 << '\n'; + return EXIT_FAILURE; + } + } + return EXIT_SUCCESS; +} + } // namespace int main() @@ -64,5 +113,8 @@ int main() if (runFlatBiquadTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } + if (runPeakingStabilityTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } return EXIT_SUCCESS; }