Skip to content

Fix choppy/harsh RADE decoded audio: dedicated RX buffer + sample-wise mix (regression from #1875)#1953

Merged
ten9876 merged 1 commit intoten9876:mainfrom
NF0T:fix/rade-rx-decoded-audio-buffer
Apr 25, 2026
Merged

Fix choppy/harsh RADE decoded audio: dedicated RX buffer + sample-wise mix (regression from #1875)#1953
ten9876 merged 1 commit intoten9876:mainfrom
NF0T:fix/rade-rx-decoded-audio-buffer

Conversation

@NF0T
Copy link
Copy Markdown
Contributor

@NF0T NF0T commented Apr 25, 2026

Regression

Commit 401d5d7 (PR #1875, "Fix silent audio in SSB/Digital modes when m_radeMode is active") introduced a regression: decoded RADE RX audio sounds choppy, harsh, and perceptibly slowed down.

Root Cause

PR #1875 removed the !m_radeMode guard from feedAudioData's final branch on the premise that:

"RADE's raw OFDM noise is muted at the slice level (audio_mute=1) so it never reaches feedAudioData"

This premise is false. Pcap analysis (rade-harsh-audio.pcapng, FLEX-8400 v4.1.5.39794) shows that when audio_mute=1 is applied to the RADE slice, the radio does not suppress remote_audio_rx VITA-49 packets. It zero-fills the float32 payload (stream 0x04000008, PCC 0x03E3) and keeps sending at the normal ~5 ms cadence. The pcap shows the transition from non-zero audio to all-0x00000000 within one packet cadence of audio_mute=1 taking effect, then staying zero for the rest of the capture.

Why that causes choppy audio

m_rxBuffer is a single queue drained by the RX timer. Before 401d5d7:

  • feedDecodedSpeech()m_rxBuffer at rate R
  • feedAudioData() (muted-RADE slice) → blocked by !m_radeMode
  • Fill rate = 1× drain rate

After 401d5d7:

  • feedDecodedSpeech()m_rxBuffer at rate R
  • feedAudioData() (zero-filled frames) → m_rxBuffer at rate R
  • Combined fill = 2× drain rate → 200 ms cap fires every ~200 ms → ~200 ms of decoded speech dropped periodically → choppy/harsh/slowed audio

The multi-pan case (RADE on Pan A, SSB on Pan B) triggers the same symptom: SSB audio from Pan B fills m_rxBuffer alongside decoded RADE speech, again doubling the fill rate. An intermediate RMS-gate fix was considered (block zero frames, pass non-zero) but reproduced the harsh audio on the SSB pan — same root cause, different source of the extra fill.

Fix

Dedicated m_radeRxBuffer + sample-wise mix in the drain timer.

feedDecodedSpeech() now writes to m_radeRxBuffer instead of m_rxBuffer. feedAudioData() continues writing to m_rxBuffer as always (SSB/CW audio from any pan, plus zero-filled muted-RADE-slice frames). The RX drain timer mixes both buffers sample-wise (float32 addition) before writing to the device:

m_rxBuffer (SSB audio or zeros)  +  m_radeRxBuffer (RADE speech)
─────────────────────────────────────────────────────────────────
output = SSB + RADE   ← both pans heard simultaneously ✅
output = 0   + RADE   ← RADE only, single-pan (zeros add nothing) ✅
output = SSB + 0      ← no RADE active, fast-path zero copy ✅

Each buffer fills at rate R and drains at rate R. No cap is triggered. The original intent of #1875 (non-RADE-slice audio audible while RADE is active) is fully preserved.

A dedicated m_radeRxResampler (24 k→48 k) is used in feedDecodedSpeech() to keep the r8brain filter state independent from m_rxResampler — sharing a stateful FIR filter between two independent audio streams produces output corruption via filter delay-line contamination.

m_radeRxBuffer is capped by the same maxBufBytes policy as m_rxBuffer, the underrun counter now requires both buffers empty before incrementing, and m_radeRxBuffer is cleared on setRadeMode(false) to prevent stale frames bleeding into the next session.

Files Changed

  • src/core/AudioEngine.hm_radeRxBuffer, m_radeRxResampler
  • src/core/AudioEngine.cppfeedDecodedSpeech(), drain timer, setRadeMode()

Test Results

Scenario Result
Pure SSB/CW, no RADE — non-regression check ✅ Verified: audio clean, unaffected
RADE on Pan A + SSB on Pan B — Pan B audio ✅ Verified: SSB audio clean, no interference
Single-pan RADE decoded audio quality ⏳ Pending: no RADE traffic available during testing
Multi-pan RADE decoded audio + SSB simultaneous ⏳ Pending: no RADE traffic available during testing

The two pending cases require an on-air RADE signal to validate. The fix is mechanically sound (zero-filled frames add 0 to the mix → output is pure RADE speech; non-RADE mix is not affected by the RADE buffer path). Wider testing via this PR is the intent.

References

Background — regression introduced by commit 401d5d7 (PR ten9876#1875):

  PR ten9876#1875 removed the !m_radeMode guard from feedAudioData's final else-if
  branch. The intent was correct: keep non-RADE-slice audio (SSB/CW on a second
  pan) audible while RADE is active. But the implementation rested on a false
  assumption about radio behaviour:

    "RADE's raw OFDM noise is muted at the slice level (audio_mute=1) so it
    never reaches feedAudioData."

  Pcap analysis (rade-harsh-audio.pcapng) disproves this. When audio_mute=1 is
  applied to the RADE slice, the FLEX-8600 does NOT suppress remote_audio_rx
  packets. It zero-fills the float32 payload (stream 0x04000008, PCC 0x03E3)
  and keeps sending at the normal ~5 ms cadence. The pcap shows the stream
  transition from non-zero audio to all-0x00000000 within one packet cadence of
  audio_mute=1 taking effect, then remaining zero for the rest of the capture.

Root cause of the regression:

  m_rxBuffer is a single queue drained by the RX timer. feedDecodedSpeech()
  appends decoded RADE speech to m_rxBuffer. After 401d5d7, feedAudioData() also
  appended to m_rxBuffer — both the zero-filled RADE-slice frames and non-zero
  audio from a second pan (SSB/CW). Either source doubles the fill rate:

    - Zero-filled frames arrive at ~200 pkt/s; decoded RADE speech also arrives
      at ~200 pkt/s. Combined fill = 2× drain rate.
    - The 200 ms cap fires every ~200 ms, dropping ~200 ms of decoded speech.
    - Perceived as choppy, harsh, slowed-down audio.

  The multi-pan case (RADE on Pan A, SSB on Pan B) triggers the same symptom:
  SSB audio fills m_rxBuffer alongside decoded RADE speech, again doubling the
  fill rate. An RMS gate (block zero frames only) was considered but rejected
  because it reproduced the identical harsh audio on the SSB pan — same root
  cause, different source of the extra fill.

Fix: dedicated RADE RX buffer + sample-wise mix at drain time

  feedDecodedSpeech() now writes to m_radeRxBuffer instead of m_rxBuffer.
  feedAudioData() continues writing to m_rxBuffer as before (SSB/CW audio from
  any pan, plus zero-filled muted-RADE-slice frames). The RX drain timer mixes
  both buffers sample-wise (float32 addition) before writing to the audio device:

    m_rxBuffer (SSB audio or zeros)  +  m_radeRxBuffer (RADE speech)
    ─────────────────────────────────────────────────────────────────
    output = SSB + RADE   (both pans heard simultaneously)
    output = 0   + RADE   (RADE only, single-pan — zeros add nothing)
    output = SSB + 0      (no RADE active — fast path, zero copy)

  Each buffer fills at rate R and drains at rate R. No cap is triggered.

  A dedicated m_radeRxResampler (24k→48k) is used in feedDecodedSpeech() to
  keep the r8brain filter state independent from m_rxResampler. Sharing a
  stateful FIR resampler between two independent streams produces output
  corruption because the filter delay line contains samples from the wrong stream.

  m_radeRxBuffer is capped by the same maxBufBytes policy as m_rxBuffer, the
  underrun counter now requires both buffers empty, and m_radeRxBuffer is cleared
  on setRadeMode(false) to prevent stale frames bleeding into the next session.

Files changed: src/core/AudioEngine.h, src/core/AudioEngine.cpp

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@NF0T NF0T requested a review from ten9876 as a code owner April 25, 2026 13:11
@NF0T
Copy link
Copy Markdown
Contributor Author

NF0T commented Apr 25, 2026

Additional test results from the submitter (FLEX-8400 firmware v4.1.5.39794):

Scenario Result
Single-pan RADE decoded audio quality ✅ Verified: audio clean, no choppy/harsh/slowed artifacts
Multi-pan RADE decoded audio (Pan A) + SSB (Pan B) simultaneously ✅ Verified: RADE decoded speech clean on Pan A, SSB audio clean on Pan B, no interference between paths

All four test cases are now verified. The fix is confirmed good across both single-pan and multi-pan configurations.

Copy link
Copy Markdown
Contributor

@aethersdr-agent aethersdr-agent Bot left a comment

Choose a reason for hiding this comment

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

Nice work, @NF0T. The root cause analysis is thorough — the pcap evidence showing zero-filled payloads despite audio_mute=1 is exactly the kind of investigation that saves everyone time. The dual-buffer + sample-wise mix approach is clean and correct for this problem.

A few observations:

1. updateRxBufferStats() dropped from feedDecodedSpeech()

The old code called updateRxBufferStats() after appending to m_rxBuffer. The new code appends to m_radeRxBuffer instead but drops the stats call entirely. updateRxBufferStats() only tracks m_rxBuffer.size(), so calling it would be wrong anyway — but now the RADE buffer's contribution to total buffered audio is invisible to the stats exposed via rxBufferBytes() / rxBufferPeakBytes(). This is used by the buffer diagnostics UI.

Not a blocker (it was already imprecise when both buffers had data), but worth noting — if you want accurate diagnostics, updateRxBufferStats() could account for m_radeRxBuffer.size() too. Low priority.

2. Clamp applied asymmetrically in the mix loop

In the mix path, the m_rxBuffer samples are added without clamping:

out[i] += rx[i];

but the RADE samples are clamped:

out[i] = std::clamp(out[i] + rade[i], -1.0f, 1.0f);

Since the RADE add runs second, the final output is clamped — so this is functionally correct. But if someone reorders the loops or adds a third source, the intent becomes unclear. Consider either clamping both (for clarity) or clamping neither (and doing a single final clamp pass), e.g.:

// after both loops:
for (qsizetype i = 0; i < len / floatBytes; ++i)
    out[i] = std::clamp(out[i], -1.0f, 1.0f);

Minor style point — no functional issue as written.

3. len calculation uses std::max of both buffer sizes

len = std::min(len, std::max(m_rxBuffer.size(), m_radeRxBuffer.size()));

This means if one buffer has 1000 bytes and the other has 100 bytes, len is set to 1000 (capped by freeBytes). The shorter buffer contributes silence (via zero-init) for the 100–1000 range. This is correct — just confirming the zero-init on chunk covers the gap, which it does.

4. Scope is clean — only AudioEngine.cpp and AudioEngine.h are touched, exactly matching the stated scope.

5. No convention issues — uses std::unique_ptr for the resampler (RAII), lazy-inits on first use which matches the existing m_rxResampler pattern, no QSettings usage, C++20 std::clamp.

Overall this is a well-reasoned fix. The two pending RADE-on-air test scenarios noted in the PR description are the right gating items before merge. Thanks for the contribution — the pcap-level debugging in the description is a great reference for future RADE audio work.

@ten9876 ten9876 merged commit 1777f4d into ten9876:main Apr 25, 2026
5 checks passed
NF0T added a commit to NF0T/AetherSDR that referenced this pull request Apr 26, 2026
…n another pan

With ten9876#1953, decoded RADE speech writes to m_radeRxBuffer and bypasses the
DSP chain entirely. DSP (NR2/NR4/RN2/BNR/DFNR) runs only on m_rxBuffer,
which carries SSB/CW audio from all non-RADE slices. The original guards
that prevented DSP while m_radeMode was set were motivated by the old single-
buffer design, where decoded speech flowed through feedAudioData() → DSP.

With the dual-buffer architecture those guards are wrong:

  - In multi-pan (RADE on Pan A, SSB on Pan B) the guards silently discarded
    any attempt to enable NR4/etc. on Pan B. The button illuminated but no
    processing occurred — confirmed by user testing (NF0T, FLEX-8400 v4.1.5).
  - setRadeMode(true) tore down any already-active DSP on entry, killing
    noise reduction on a concurrently listening SSB pan.

Decoded speech in m_radeRxBuffer is never touched by DSP regardless; removing
the guards cannot affect RADE speech quality.

Also address two minor issues noted in the ten9876#1953 review (aethersdr-agent):

  - updateRxBufferStats() now sums m_rxBuffer + m_radeRxBuffer so the buffer
    diagnostics UI (rxBufferBytes / rxBufferPeakBytes) reflects total buffered
    audio rather than omitting the RADE speech contribution.

  - Drain-timer mix clamp: both source loops now use plain += and a single
    std::clamp pass runs over the full output array after both sources are
    mixed. Previously the clamp was applied only to the RADE addition, which
    was functionally correct (RADE ran last) but fragile and unclear in intent.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
NF0T added a commit to NF0T/AetherSDR that referenced this pull request Apr 26, 2026
…n another pan

With ten9876#1953, decoded RADE speech writes to m_radeRxBuffer and bypasses the
DSP chain entirely. DSP (NR2/NR4/RN2/BNR/DFNR) runs only on m_rxBuffer,
which carries SSB/CW audio from all non-RADE slices. The original guards
that prevented DSP while m_radeMode was set were motivated by the old single-
buffer design, where decoded speech flowed through feedAudioData() → DSP.

With the dual-buffer architecture those guards are wrong:

  - In multi-pan (RADE on Pan A, SSB on Pan B) the guards silently discarded
    any attempt to enable NR4/etc. on Pan B. The button illuminated but no
    processing occurred — confirmed by user testing (NF0T, FLEX-8400 v4.1.5).
  - setRadeMode(true) tore down any already-active DSP on entry, killing
    noise reduction on a concurrently listening SSB pan.

Decoded speech in m_radeRxBuffer is never touched by DSP regardless; removing
the guards cannot affect RADE speech quality.

Also address two minor issues noted in the ten9876#1953 review (aethersdr-agent):

  - updateRxBufferStats() now sums m_rxBuffer + m_radeRxBuffer so the buffer
    diagnostics UI (rxBufferBytes / rxBufferPeakBytes) reflects total buffered
    audio rather than omitting the RADE speech contribution.

  - Drain-timer mix clamp: both source loops now use plain += and a single
    std::clamp pass runs over the full output array after both sources are
    mixed. Previously the clamp was applied only to the RADE addition, which
    was functionally correct (RADE ran last) but fragile and unclear in intent.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
NF0T added a commit to NF0T/AetherSDR that referenced this pull request Apr 26, 2026
…n another pan

With ten9876#1953, decoded RADE speech writes to m_radeRxBuffer and bypasses the
DSP chain entirely. DSP (NR2/NR4/RN2/BNR/DFNR) runs only on m_rxBuffer,
which carries SSB/CW audio from all non-RADE slices. The original guards
that prevented DSP while m_radeMode was set were motivated by the old single-
buffer design, where decoded speech flowed through feedAudioData() → DSP.

With the dual-buffer architecture those guards are wrong:

  - In multi-pan (RADE on Pan A, SSB on Pan B) the guards silently discarded
    any attempt to enable NR4/etc. on Pan B. The button illuminated but no
    processing occurred — confirmed by user testing (NF0T, FLEX-8400 v4.1.5).
  - setRadeMode(true) tore down any already-active DSP on entry, killing
    noise reduction on a concurrently listening SSB pan.

Decoded speech in m_radeRxBuffer is never touched by DSP regardless; removing
the guards cannot affect RADE speech quality.

Also address two minor issues noted in the ten9876#1953 review (aethersdr-agent):

  - updateRxBufferStats() now sums m_rxBuffer + m_radeRxBuffer so the buffer
    diagnostics UI (rxBufferBytes / rxBufferPeakBytes) reflects total buffered
    audio rather than omitting the RADE speech contribution.

  - Drain-timer mix clamp: both source loops now use plain += and a single
    std::clamp pass runs over the full output array after both sources are
    mixed. Previously the clamp was applied only to the RADE addition, which
    was functionally correct (RADE ran last) but fragile and unclear in intent.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
NF0T added a commit to NF0T/AetherSDR that referenced this pull request Apr 26, 2026
…n another pan

With ten9876#1953, decoded RADE speech writes to m_radeRxBuffer and bypasses the
DSP chain entirely. DSP (NR2/NR4/RN2/BNR/DFNR) runs only on m_rxBuffer,
which carries SSB/CW audio from all non-RADE slices. The original guards
that prevented DSP while m_radeMode was set were motivated by the old single-
buffer design, where decoded speech flowed through feedAudioData() → DSP.

With the dual-buffer architecture those guards are wrong:

  - In multi-pan (RADE on Pan A, SSB on Pan B) the guards silently discarded
    any attempt to enable NR4/etc. on Pan B. The button illuminated but no
    processing occurred — confirmed by user testing (NF0T, FLEX-8400 v4.1.5).
  - setRadeMode(true) tore down any already-active DSP on entry, killing
    noise reduction on a concurrently listening SSB pan.

Decoded speech in m_radeRxBuffer is never touched by DSP regardless; removing
the guards cannot affect RADE speech quality.

Also address two minor issues noted in the ten9876#1953 review (aethersdr-agent):

  - updateRxBufferStats() now sums m_rxBuffer + m_radeRxBuffer so the buffer
    diagnostics UI (rxBufferBytes / rxBufferPeakBytes) reflects total buffered
    audio rather than omitting the RADE speech contribution.

  - Drain-timer mix clamp: both source loops now use plain += and a single
    std::clamp pass runs over the full output array after both sources are
    mixed. Previously the clamp was applied only to the RADE addition, which
    was functionally correct (RADE ran last) but fragile and unclear in intent.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ten9876 pushed a commit that referenced this pull request Apr 26, 2026
…n another pan (#2025)

With #1953, decoded RADE speech writes to m_radeRxBuffer and bypasses the
DSP chain entirely. DSP (NR2/NR4/RN2/BNR/DFNR) runs only on m_rxBuffer,
which carries SSB/CW audio from all non-RADE slices. The original guards
that prevented DSP while m_radeMode was set were motivated by the old single-
buffer design, where decoded speech flowed through feedAudioData() → DSP.

With the dual-buffer architecture those guards are wrong:

  - In multi-pan (RADE on Pan A, SSB on Pan B) the guards silently discarded
    any attempt to enable NR4/etc. on Pan B. The button illuminated but no
    processing occurred — confirmed by user testing (NF0T, FLEX-8400 v4.1.5).
  - setRadeMode(true) tore down any already-active DSP on entry, killing
    noise reduction on a concurrently listening SSB pan.

Decoded speech in m_radeRxBuffer is never touched by DSP regardless; removing
the guards cannot affect RADE speech quality.

Also address two minor issues noted in the #1953 review (aethersdr-agent):

  - updateRxBufferStats() now sums m_rxBuffer + m_radeRxBuffer so the buffer
    diagnostics UI (rxBufferBytes / rxBufferPeakBytes) reflects total buffered
    audio rather than omitting the RADE speech contribution.

  - Drain-timer mix clamp: both source loops now use plain += and a single
    std::clamp pass runs over the full output array after both sources are
    mixed. Previously the clamp was applied only to the RADE addition, which
    was functionally correct (RADE ran last) but fragile and unclear in intent.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
@NF0T NF0T deleted the fix/rade-rx-decoded-audio-buffer branch April 26, 2026 23:36
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