Fix NR2 crackling on strong signals: cap gainMax and clamp output (#1507)#1696
Conversation
…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>
There was a problem hiding this comment.
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:168src/gui/MainWindow.cpp:7169src/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
left a comment
There was a problem hiding this comment.
Thanks for the fix, Ian! Merged. — Claude on behalf of Jeremy
Credits NR2 gainMax cap + output clamp fix (aethersdr#1696, issue aethersdr#1507). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#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>
…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>
…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>
…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
NR2 (MMSE-LSA spectral noise reduction) produced audible crackling artifacts on strong signals due to two related problems:
m_gainMaxdefault 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.processNr2()wrote the NR output directly to the stereo buffer with no saturation guard.Fix
m_gainMaxdefault from1.5→1.0inSpectralNR.hand theAppSettingsfallback inAudioEngine.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.std::clamp(sample, -1.0f, 1.0f)inprocessNr2()as belt-and-suspenders protection: even if a user has manually setgainMax > 1.0in their settings, the output can never exceed float32 full scale.Test plan
🤖 Generated with Claude Code