Skip to content

Fix NR2 crackling on strong signals: cap gainMax and clamp output (#1507)#1696

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
M7HNF-Ian:fix/1507-nr2-gain-clamp
Apr 20, 2026
Merged

Fix NR2 crackling on strong signals: cap gainMax and clamp output (#1507)#1696
ten9876 merged 1 commit into
aethersdr:mainfrom
M7HNF-Ian:fix/1507-nr2-gain-clamp

Conversation

@M7HNF-Ian

Copy link
Copy Markdown
Contributor

Summary

NR2 (MMSE-LSA spectral noise reduction) produced audible crackling artifacts on strong signals due to two related problems:

  • m_gainMax default was 1.5 — the MMSE-LSA estimator is supposed to be a noise reducer, but a gainMax of 1.5 allowed it to amplify signals by up to 50%. With a strong signal near ±1.0 float32 full scale, the output could reach ±1.5, which QAudioSink clips to ±1.0 as harsh digital distortion. The comment even said "noise REDUCTION, not amplification" while the value contradicted it.
  • No output clampprocessNr2() wrote the NR output directly to the stereo buffer with no saturation guard.

Fix

  1. Change m_gainMax default from 1.51.0 in SpectralNR.h and the AppSettings fallback in AudioEngine.cpp. A value of 1.0 means NR can attenuate noise bins but can never boost above the input level — the correct behaviour for a noise reducer.
  2. Add std::clamp(sample, -1.0f, 1.0f) in processNr2() as belt-and-suspenders protection: even if a user has manually set gainMax > 1.0 in their settings, the output can never exceed float32 full scale.

Test plan

  • Enable NR2 on an SSB signal at moderate strength — audio should be clean, no crackling
  • Enable NR2 with a strong signal (S9+20 dB) — should sound natural, not distorted
  • Verify the NR2 Advanced settings dialog still shows and saves gainMax correctly
  • Confirm NR2 still reduces noise on weak signals (the fix only prevents amplification, not attenuation)

🤖 Generated with Claude Code

…put (aethersdr#1507)

Two changes:

1. Default m_gainMax was 1.5 (50% amplification), contradicting the
   "noise REDUCTION, not amplification" comment. With a strong signal near
   ±1.0 full scale, the MMSE-LSA estimator could return a gain up to 1.5×,
   pushing samples outside the ±1.0 float32 range expected by QAudioSink —
   causing harsh digital crackling. Change the default and AppSettings
   fallback to 1.0 so NR2 never amplifies above the input level.

2. Add a std::clamp(sample, -1.0f, 1.0f) guard in processNr2() before
   the mono→stereo expansion. This is a belt-and-suspenders protection:
   even if a user has manually set gainMax > 1.0 in settings, the output
   can never exceed full scale and cause DAC clipping artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@M7HNF-Ian M7HNF-Ian requested a review from ten9876 as a code owner April 19, 2026 17:47

@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.

Good fix, @Chaosuk97 — the analysis of why gainMax > 1.0 causes crackling is spot-on, and the belt-and-suspenders std::clamp is the right call.

One thing to address before merging:

Inconsistent default fallbacks. The PR updates the fallback in AudioEngine.cpp:978 and the member initializer in SpectralNR.h:202, but three other call sites still use "1.50" as the fallback default for NR2GainMax:

  • src/gui/SliceTroubleshootingDialog.cpp:168
  • src/gui/MainWindow.cpp:7169
  • src/gui/AetherDspDialog.cpp:557

For a fresh install (no saved settings), the engine will apply 1.0 but the Advanced NR2 dialog will display 1.50. The user sees a value the engine isn't actually using, and if they click OK without changing it, they'll save 1.50 — re-introducing the bug this PR fixes. All fallback strings should be "1.00" to stay consistent.

Everything else looks clean — correct use of std::clamp with float literals, changes are tightly scoped, and AppSettings is used properly. Thanks for the contribution!

@ten9876 ten9876 merged commit fd44c3b into aethersdr:main Apr 20, 2026
5 checks passed

@ten9876 ten9876 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.

Thanks for the fix, Ian! Merged. — Claude on behalf of Jeremy

ten9876 added a commit to M7HNF-Ian/AetherSDR that referenced this pull request Apr 20, 2026
Credits NR2 gainMax cap + output clamp fix (aethersdr#1696, issue aethersdr#1507).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Apr 20, 2026
…#1560) (#1685)

* About: add Ian M7HNF (Chaosuk97) to Contributors

Credits NR2 gainMax cap + output clamp fix (#1696, issue #1507).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix audio pan broken with NR (#1460) and AF mute not persisted (#1560)

    discarding the radio-side stereo balance. The pan slider sends
    audio_pan to the radio which embeds the balance in the VITA-49 PCM,
    but NR collapses it.

    Fix:
    - AudioEngine gains std::atomic<int> m_rxPan (0-100, centre=50)
      and void setRxPan(int).
    - Static helper applyRxPanInPlace() re-applies a linear-pan law to
      stereo float32 in-place; is a true no-op when pan==50.
    - Called at the end of processNr2() and after every other NR
      process() call in feedAudioData (RN2/NR4/DFNR/MNR paths).
      The non-NR path is untouched — the radio still owns pan there.
    - VfoWidget emits rxPanChanged(int) when the user moves the pan
      slider; wireVfoWidget connects it to m_audio->setRxPan().

    radio, but the radio does not persist this between client sessions.
    On reconnect the slice starts unmuted.

    Fix:
    - VfoWidget emits audioMuteToggled(bool) when the user clicks the
      mute button (guarded by m_updatingFromModel so model-driven syncs
      don't trigger a save).
    - wireVfoWidget saves SliceAudioMuted_{id} to AppSettings on every
      user toggle, then immediately restores the saved state by calling
      s->setAudioMute(true) if needed (radio is already connected at
      this point, so the command goes through cleanly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Review fixes: stable slice letter key and nFrames safety guard (#1676 review)

- Use QChar('A' + sliceId) as the SliceAudioMuted settings key so the
  persisted mute state survives sessions where the radio assigns different
  numeric slice IDs (matches DaxChannel_SliceA naming convention) (#1560)
- Add nFrames <= 0 early-exit and explanatory comment to applyRxPanInPlace
  so zero-length / partial buffers on error paths never enter the loop (#1460)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Jeremy Fielder <kk7gwy@aethersdr.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NF0T added a commit to NF0T/AetherSDR that referenced this pull request May 29, 2026
…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>
jensenpat pushed a commit that referenced this pull request May 31, 2026
…led variants to eliminate NR2 Gamma crackling (#1507) (#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 #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:
```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>
@M7HNF-Ian M7HNF-Ian deleted the fix/1507-nr2-gain-clamp branch June 7, 2026 14:58
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.

2 participants