Skip to content

fix(SpectralNR): replace unscaled Bessel I0/I1 with exponentially-scaled variants to eliminate NR2 Gamma crackling (#1507)#3275

Merged
jensenpat merged 3 commits into
aethersdr:mainfrom
NF0T:fix/nr2-bessel-overflow
May 31, 2026
Merged

fix(SpectralNR): replace unscaled Bessel I0/I1 with exponentially-scaled variants to eliminate NR2 Gamma crackling (#1507)#3275
jensenpat merged 3 commits into
aethersdr:mainfrom
NF0T:fix/nr2-bessel-overflow

Conversation

@NF0T

@NF0T NF0T commented May 29, 2026

Copy link
Copy Markdown
Collaborator

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 #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 #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 #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:

  • 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:

// 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 · polyno 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
// 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):

  • TC-1: Strong broadcast/SSB S9+10, 60 s — no crackling
  • TC-2: Steady carrier S9+20–S9+40 (maximum-gamma stress) — clean pass-through
  • TC-3: GainMax 0.5 / 1.0 / 1.5 — no crackling at any value
  • TC-4: Enable NR2 mid-signal (cold OSMS ramp) — clean transition
  • TC-5: All four gain methods compared — no regressions
  • TC-6: Weak-signal NR effectiveness — unchanged
  • TC-7: MMSE and NSTAT NPE methods — no crackling

🤖 Generated with Claude Code

NF0T and others added 2 commits May 29, 2026 13:34
…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>
@NF0T NF0T requested review from a team as code owners May 29, 2026 18:45

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 > 1420exp(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 linkSpectralNR.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 locallybessI0e/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 NF0T left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exponential equations check out. I did them on a chalkboard. Thanks for the NR2 improvements and test framework, Ryan.

@jensenpat jensenpat enabled auto-merge (squash) May 31, 2026 03:35
@jensenpat jensenpat merged commit 9c7b00c into aethersdr:main May 31, 2026
5 checks passed
@NF0T NF0T deleted the fix/nr2-bessel-overflow branch June 1, 2026 19:32
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NR2 Gamma (MMSE-LSA) produces audible crackling on strong signals — current AetherSDR NR2 does not match the Thetis/WDSP NR2 users expect

2 participants