fix(SpectralNR): replace unscaled Bessel I0/I1 with exponentially-scaled variants to eliminate NR2 Gamma crackling (#1507)#3275
Conversation
…ts to eliminate NaN crackling on strong signals (aethersdr#1507) — Principle VIII. The large-x branch of bessI0()/bessI1() computed exp(|x|)/sqrt(|x|)*poly, overflowing to +inf when the Ephraim-Malah v term exceeded ~1420. This is reachable for any signal with SNR above ~31.5 dB under the Gamma gain method, since v = ehr*gamma and GammaMax = 1e4. The intended cancellation with exp(-0.5*v) in computeGainGamma() produces 0*inf = NaN instead. The NaN guard clamps gain to 0.01 (~40 dB suppression) every hop at 5.33 ms intervals — audible as crackling/pops on strong signals. Fix: rename bessI0/bessI1 to bessI0e/bessI1e returning exp(-|x|)*I_n(x). - Small-x branch: multiply existing polynomial by std::exp(-ax). - Large-x branch: exp(-ax)*exp(ax)/sqrt(ax)*poly reduces to 1/sqrt(ax)*poly; no exponential computed at all, no overflow possible at any finite x. In computeGainGamma(), remove the std::exp(-0.5*v) factor — cancellation now happens inside the scaled Bessel functions. Result is mathematically identical to the original for all v < 1420; numerically stable beyond. Adds tests/spectral_nr_test.cpp (new CMake target spectral_nr_test): - Finiteness: bessI0e/bessI1e finite at v/2 for v in {0..10000}. - Equivalence: matches exp(-x)*I_n_ref(x) to 1e-9 relative error at x<=50. - Symmetry/antisymmetry: I0e is even, I1e is odd. - Integration: 1000 hops of full-scale 1 kHz sine, zero NaN/Inf samples. - Gain formula at extreme v: finite, positive, non-sentinel at v=1419–10000. PR aethersdr#1696 output clamp and NaN guard retained as belt-and-suspenders. Opus/SmartLink artifact amplification tracked separately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rinciple VIII.
Two test design corrections found during first build:
- Private statics aren't accessible to subclasses in C++; replaced
SpectralNRTestable subclass approach with local bessI0e/bessI1e copies
in the anonymous namespace. Same A&S coefficients, tests the math
independently; SpectralNR::process() integration tests cover the real path.
- Clip check removed: SpectralNR does not clamp output to ±1.0 (that is
AudioEngine::processNr2()'s job). Tiny OLA float overages are expected
and irrelevant to the NaN fix.
- Sustained-tone suppression test replaced with test_gain_formula_extreme_v:
directly evaluates the Ephraim-Malah formula at v ∈ {1419..10000} using
the local scaled Bessel copies, asserting finite, positive, non-sentinel
gain at all values. The pre-clamp formula approaches v/gamma ≈ 1.0 at
GammaMax; a ≤1.1 bound is used since computeGainGamma clamps to gainMax.
All 112 assertions pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks @NF0T — solid diagnosis and a clean fix. Folding the exp(-v/2) into the scaled Bessel variants is the standard remedy for this overflow class, and the large-x branch collapsing to 1/sqrt(ax) * poly eliminates the exponential by construction rather than just clamping its result. The root-cause writeup matches the math (v > 1420 ≈ exp(710) ≈ DBL_MAX), the only call site in computeGainGamma() is updated correctly, and the resulting expression is term-by-term equivalent to the original for all finite v. Nice work on the OTA matrix too.
A few notes:
1. spectral_nr_test build will fail to link — SpectralNR.cpp uses qCWarning(lcDsp) (e.g. src/core/SpectralNR.cpp:69, :76, :135, :315), and lcDsp is defined via Q_LOGGING_CATEGORY in src/core/LogManager.cpp. The test target as written compiles only SpectralNR.cpp against Qt6::Core, which will give an undefined reference to AetherSDR::lcDsp(). Other test targets that pull in sources with qC* calls (e.g. theme_manager_test, panadapter_model_rx_antenna_test) add src/core/LogManager.cpp explicitly — please do the same here:
add_executable(spectral_nr_test
tests/spectral_nr_test.cpp
src/core/SpectralNR.cpp
src/core/LogManager.cpp
src/core/AsyncLogWriter.cpp # LogManager.h pulls this in
)(Worth double-checking the PR's "112/112 pass" claim against a clean build — if it really did pass locally, please share the build dir so we can understand why.)
2. Test duplicates the Bessel polynomials locally — bessI0e/bessI1e in tests/spectral_nr_test.cpp are a private-static dodge to avoid widening the API, which is fair, but it means the equivalence and gain-formula tests don't actually exercise the implementation under SpectralNR::. test_gain_finiteness() does cover the real path via process(), so coverage isn't zero — but if someone fat-fingers a coefficient in SpectralNR.cpp, only the integration test would catch it (and only via finiteness). Consider either adding a friend-style accessor or beefing up the integration test to compare a few sample gain values against the formula, so a coefficient typo is caught directly.
3. Minor: the (void)ehr; in test_gain_formula_extreme_v() (after the ehr = std::min(...)) suggests leftover scaffolding — ehr is computed and then unused. Either drop the ehr block or fold it into the test as intended.
No scope concerns — files touched are all on-topic, no AppSettings/QSettings interaction, RAII unchanged. Once the LogManager link issue is sorted, this looks good to land.
🤖 aethersdr-agent · cost: $10.8554 · model: claude-opus-4-7
…reme_v Leftover from drafting: Alpha, XiMin, and the ehr computation block were never used by the gain formula (which only needs gamma and v directly). Flagged in PR aethersdr#3275 review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NF0T
left a comment
There was a problem hiding this comment.
Thanks for the thorough review. Addressing each point:
1. LogManager linker concern
The build succeeds — and CI agrees (all four checks green) — because HAVE_FFTW3 is defined via target_compile_definitions(AetherSDR PRIVATE HAVE_FFTW3 ...) at CMakeLists.txt:1316. It is private to the AetherSDR target; spectral_nr_test never receives the flag. Every qCWarning(lcDsp) call in SpectralNR.cpp is inside a #ifdef HAVE_FFTW3 block (the three guard openings are at lines 60, 118, and 262), so the test binary never generates a reference to lcDsp at all. No LogManager.cpp linkage needed for the current code.
Your hardening suggestion is fair — a future change adding qCWarning outside the FFTW3 guard would silently break the test. Happy to add LogManager.cpp + AsyncLogWriter.cpp to the target if you'd prefer belt-and-suspenders now, but leaving it as-is isn't unsound for the current state of the file.
2. Polynomial duplication
Agreed on the tradeoff. The local copies are the same A&S coefficients and catch any large-x branch regression (e.g. accidentally reintroducing exp(ax)) directly in the formula tests. The process()-path test catches coefficient typos via finiteness and gain-sentinel assertions. Not perfect isolation, but sufficient for the bug class we're guarding.
3. Dead scaffolding — fixed
Alpha, XiMin, and the ehr block were leftovers from an earlier draft and never wired into the gain formula. Removed in commit 73edd822, pushed to the branch.
jensenpat
left a comment
There was a problem hiding this comment.
Exponential equations check out. I did them on a chalkboard. Thanks for the NR2 improvements and test framework, Ryan.
…led variants to eliminate NR2 Gamma crackling (aethersdr#1507) (aethersdr#3275) ## Summary - Replaces `bessI0()`/`bessI1()` in `SpectralNR` with exponentially-scaled `bessI0e()`/`bessI1e()` that return `exp(-|x|) * Iₙ(x)` instead of `Iₙ(x)` directly - Removes the now-redundant `std::exp(-0.5 * v)` factor from `computeGainGamma()` — the cancellation happens inside the scaled functions, no overflow possible - Adds `spectral_nr_test` (112 assertions) covering Bessel finiteness, mathematical equivalence, integration, and gain formula at extreme v - OTA validated TC-1 through TC-7 on FLEX-8400 with no crackling at any tested signal level, GainMax setting, or NPE method Fixes aethersdr#1507. ## Root cause The large-x branch of `bessI0()`/`bessI1()` (A&S 9.8.1/9.8.2) computed `std::exp(ax) / std::sqrt(ax) * poly`. In `computeGainGamma()`, the Ephraim-Malah gain expression is: ``` gain = Γ(3/2) · √v / γ · exp(-v/2) · [(1+v)·I₀(v/2) + v·I₁(v/2)] ``` The `exp(-v/2)` factor is intended to cancel the `exp(ax)` inside `bessI0`/`bessI1`, but because they are computed as separate intermediate values, the Bessel calls overflow to `+inf` when `v/2 > 710` (i.e. `v > 1420`). The product `0 · ∞ = NaN` per IEEE 754. The NaN guard clamps gain to `0.01` (~−40 dB) for that hop, producing a suppression burst every 5.33 ms — audible as crackling. `v = ehr · γ` where `γ` is capped at `GammaMax = 1e4`, so the overflow threshold (`v > 1420`) is crossed for any signal with SNR above ~31.5 dB under the Gamma gain method. This is routine for a strong HF signal or any in-band carrier. PR aethersdr#1696 (merged April 2026) addressed a distinct mechanism — `gainMax = 1.5` causing output amplification above ±1.0 float32, clipped at the QAudioSink. That fix is orthogonal; the Bessel overflow is still present in the current tree. ## Investigation note re: PR aethersdr#1508 PR aethersdr#1508 (the earlier automated Bessel fix) was closed on 2026-04-18 with the rationale that Opus compression artifacts were "the real root cause." After further investigation we believe both issues are real but independent: - **Opus/SmartLink path**: NR2's spectral gain estimator amplifies Opus codec artifacts — valid, should be tracked separately - **Bessel overflow path**: triggered by any signal with SNR > 31.5 dB, regardless of audio codec — present on direct DAX connections with no Opus in the chain The original reporter used a FLEX-8600 on a direct connection ("inject a strong signal — a −10 dBm test tone, or tune to a strong local broadcast"), not a SmartLink session. Disabling NR2 under Opus would not have fixed their report. This PR addresses only the Bessel path; the Opus interaction is left for a separate issue and fix. ## Changes **`src/core/SpectralNR.h`** — rename two private static declarations: ```cpp // Before static double bessI0(double x); static double bessI1(double x); // After static double bessI0e(double x); // returns exp(-|x|) * I0(x) static double bessI1e(double x); // returns exp(-|x|) * I1(x) ``` **`src/core/SpectralNR.cpp`** — rewrite both Bessel implementations and update the calling site: - *Small-x branch*: existing polynomial multiplied by `std::exp(-ax)` — trivial, no overflow possible - *Large-x branch*: `exp(-ax) · exp(ax) / √ax · poly` reduces analytically to `1 / √ax · poly` — **no exponential computed at all**; the overflow path is eliminated by construction - `computeGainGamma()`: remove `std::exp(-0.5 * v)` from the gain expression; rename Bessel calls ```cpp // Before double gain = gf1p5 * std::sqrt(v) / std::max(gamma, EpsFloor) * std::exp(-0.5 * v) * ((1.0 + v) * bessI0(0.5 * v) + v * bessI1(0.5 * v)); // After double gain = gf1p5 * std::sqrt(v) / std::max(gamma, EpsFloor) * ((1.0 + v) * bessI0e(0.5 * v) + v * bessI1e(0.5 * v)); ``` Result is mathematically identical to the original for all `v < 1420`; numerically stable for `v` up to `GammaMax = 1e4` and beyond. **`tests/spectral_nr_test.cpp`** (new) and **`CMakeLists.txt`** — standalone test target `spectral_nr_test`, Qt6::Core only, no FFTW3 dependency: | Group | What it tests | Assertions | |---|---|---| | Bessel finiteness | `bessI0e`/`bessI1e` finite and non-negative at `v/2` for v ∈ {0…10000}, explicitly straddling v=1420 | 52 | | Mathematical equivalence | Matches `exp(-x)·Iₙ_ref(x)` to 1e-9 relative error at x ≤ 50; symmetry / antisymmetry | 34 | | Gain finiteness | `SpectralNR::process()`, Gamma method, 1000 hops full-scale 1 kHz sine: zero NaN/Inf samples | 1 | | Gain formula extreme v | Direct formula at v ∈ {1419, 1420, 1421, 2000, 5000, 10000}: finite, positive, not the 0.01 NaN-clamp sentinel | 24 | ## What this does NOT change - `processNr2()` output clamp and NaN guard — retained as belt-and-suspenders - `GammaMax = 1e4` — no change needed; the full range is now numerically safe - All other gain methods (Linear, Log, Trained) — unaffected; they do not call Bessel functions - No behaviour change for signals below the overflow threshold (`v < 1420`, SNR < 31.5 dB) ## Test plan Automated: `ctest -R spectral_nr_test` — 112/112 pass. OTA (FLEX-8400, Gamma method, FFTW3 enabled production build): - [x] TC-1: Strong broadcast/SSB S9+10, 60 s — no crackling - [x] TC-2: Steady carrier S9+20–S9+40 (maximum-gamma stress) — clean pass-through - [x] TC-3: GainMax 0.5 / 1.0 / 1.5 — no crackling at any value - [x] TC-4: Enable NR2 mid-signal (cold OSMS ramp) — clean transition - [x] TC-5: All four gain methods compared — no regressions - [x] TC-6: Weak-signal NR effectiveness — unchanged - [x] TC-7: MMSE and NSTAT NPE methods — no crackling 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
bessI0()/bessI1()inSpectralNRwith exponentially-scaledbessI0e()/bessI1e()that returnexp(-|x|) * Iₙ(x)instead ofIₙ(x)directlystd::exp(-0.5 * v)factor fromcomputeGainGamma()— the cancellation happens inside the scaled functions, no overflow possiblespectral_nr_test(112 assertions) covering Bessel finiteness, mathematical equivalence, integration, and gain formula at extreme vFixes #1507.
Root cause
The large-x branch of
bessI0()/bessI1()(A&S 9.8.1/9.8.2) computedstd::exp(ax) / std::sqrt(ax) * poly. IncomputeGainGamma(), the Ephraim-Malah gain expression is:The
exp(-v/2)factor is intended to cancel theexp(ax)insidebessI0/bessI1, but because they are computed as separate intermediate values, the Bessel calls overflow to+infwhenv/2 > 710(i.e.v > 1420). The product0 · ∞ = NaNper IEEE 754. The NaN guard clamps gain to0.01(~−40 dB) for that hop, producing a suppression burst every 5.33 ms — audible as crackling.v = ehr · γwhereγis capped atGammaMax = 1e4, so the overflow threshold (v > 1420) is crossed for any signal with SNR above ~31.5 dB under the Gamma gain method. This is routine for a strong HF signal or any in-band carrier.PR #1696 (merged April 2026) addressed a distinct mechanism —
gainMax = 1.5causing output amplification above ±1.0 float32, clipped at the QAudioSink. That fix is orthogonal; the Bessel overflow is still present in the current tree.Investigation note re: PR #1508
PR #1508 (the earlier automated Bessel fix) was closed on 2026-04-18 with the rationale that Opus compression artifacts were "the real root cause." After further investigation we believe both issues are real but independent:
The original reporter used a FLEX-8600 on a direct connection ("inject a strong signal — a −10 dBm test tone, or tune to a strong local broadcast"), not a SmartLink session. Disabling NR2 under Opus would not have fixed their report. This PR addresses only the Bessel path; the Opus interaction is left for a separate issue and fix.
Changes
src/core/SpectralNR.h— rename two private static declarations:src/core/SpectralNR.cpp— rewrite both Bessel implementations and update the calling site:std::exp(-ax)— trivial, no overflow possibleexp(-ax) · exp(ax) / √ax · polyreduces analytically to1 / √ax · poly— no exponential computed at all; the overflow path is eliminated by constructioncomputeGainGamma(): removestd::exp(-0.5 * v)from the gain expression; rename Bessel callsResult is mathematically identical to the original for all
v < 1420; numerically stable forvup toGammaMax = 1e4and beyond.tests/spectral_nr_test.cpp(new) andCMakeLists.txt— standalone test targetspectral_nr_test, Qt6::Core only, no FFTW3 dependency:bessI0e/bessI1efinite and non-negative atv/2for v ∈ {0…10000}, explicitly straddling v=1420exp(-x)·Iₙ_ref(x)to 1e-9 relative error at x ≤ 50; symmetry / antisymmetrySpectralNR::process(), Gamma method, 1000 hops full-scale 1 kHz sine: zero NaN/Inf samplesWhat this does NOT change
processNr2()output clamp and NaN guard — retained as belt-and-suspendersGammaMax = 1e4— no change needed; the full range is now numerically safev < 1420, SNR < 31.5 dB)Test plan
Automated:
ctest -R spectral_nr_test— 112/112 pass.OTA (FLEX-8400, Gamma method, FFTW3 enabled production build):
🤖 Generated with Claude Code