Skip to content

fix(spectrum): reset FFT trace EMA on TX→RX so the floor doesn't linger (#3804)#3831

Merged
NF0T merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/3804-fft-floor-tx-reset
Jun 26, 2026
Merged

fix(spectrum): reset FFT trace EMA on TX→RX so the floor doesn't linger (#3804)#3831
NF0T merged 1 commit into
aethersdr:mainfrom
jensenpat:fix/3804-fft-floor-tx-reset

Conversation

@jensenpat

@jensenpat jensenpat commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Issue

#3804: after TX the FFT noise floor takes a long time to drop back to its pre-TX level.

Root cause

SpectrumWidget::setTransmitting(false) resets every other TX→RX display state — the waterfall blanker (m_txEndMs, #2117), the native-tile wait, the dBm-range freeze — but never the FFT trace EMA, m_smoothed.

During the radio's UNKEY_REQUESTED window the panadapter keeps streaming TX-contaminated FFT frames (the same behaviour called out for the waterfall in #1927). Those frames feed updateSpectrum() and poison m_smoothed; at SMOOTH_ALPHA = 0.35 the EMA then needs ~5–7 frames (~200–300 ms at 25 fps) to wash back out, so the displayed floor stays visibly elevated right after key-up.

Fix

One line in setTransmitting(false): set m_resetFftSmoothingOnNextFrame = true so the first clean RX frame is taken raw instead of being weighted against TX-era history. This reuses the exact mechanism already used on the lock / retune / dBm-range-change paths.

Scope

This addresses the client-side contributor only. The triage on the issue identifies the radio firmware's own FFT averaging buffer (display pan average / weighted_average) as the dominant slow-decay source — that is firmware-side and not client-fixable (workaround: lower Average + Wt Avg off).

Testing

Verified live on a FLEX radio (fw 4.1.5) using the agent automation bridge. To do this at all, the bridge first had to gain numeric FFT-floor observability and deterministic antenna/CWX/pan control — that work is the companion PR #3832. The fix was A/B'd by building it behind a temporary env-gate (since stripped) so baseline-vs-fixed could be measured on the same binary.

Harness. A floors bridge verb samples the panadapter's measured display floor (displayFloorDbm, off m_smoothed) at ~20–25 Hz — fast enough to resolve the ~300 ms client-EMA transient (dumpTree at ~3.5 Hz is not). Every keying was gated on a live txAntenna == ANT2 readback (dummy load) and confirmed transmitting == false afterward.

Findings

Scenario Result
Two-tone TX, dummy load Display floor flat — peak during TX +0.00 dB, peak after unkey +0.11 dB
CWX morse, 40m (slice A, ANT2 rx/tx, 18 wpm) baseline −125.7 dBm, peak +1.79 dB, never crossed +2 dB; safe
CWX morse, 20m (slice B on a 2nd panadapter, ANT2 rx/tx) baseline −109.2 dBm, +0.07 dB; TX correctly isolated to slice B (live TX freq read 14.06 MHz for 74/75 samples); the non-TX 40m pan stayed flat; safe
Baseline build vs fixed build (env-gate A/B) Both stayed flat on the dummy load — see honesty note

This exercised the change across two bands, two panadapters, two-tone + CW, and multi-slice TX routing, with a hard ANT2 safety gate and post-run transmitting=false confirmation on every pass. No regression, no stuck/elevated floor, no TX-safety violation.

Honesty note — what this does not prove. On a clean dummy-load RX, no TX-contaminated frames reach the client, so the displayed floor stays flat on both the unfixed and fixed builds — there was nothing to wash out to measure a delta against. The original report is from a live-RF environment that this bench (dummy load) does not reproduce. So the bench result is "builds, stays level, no regression, TX-safe", not a measured shrink of the multi-second tail. The change remains a low-risk correctness completion of the existing TX→RX reset block, matching the lock/retune/dBm-range paths.

Build

Builds clean standalone (Ninja, RelWithDebInfo) off origin/main; the only file touched is SpectrumWidget.cpp (+9 incl. comment).

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

…er (aethersdr#3804)

After TX→RX the displayed FFT noise floor stayed elevated and decayed
slowly. setTransmitting(false) already resets every other TX→RX display
state (waterfall blanker, native-tile wait, dBm-range freeze) but never
the FFT trace's client-side EMA (m_smoothed). During the UNKEY_REQUESTED
window the radio keeps streaming TX-contaminated FFT frames (aethersdr#1927);
those poison m_smoothed, and at SMOOTH_ALPHA=0.35 the EMA takes ~5-7
frames (~200-300 ms at 25 fps) to wash out — leaving the trace floor
visibly elevated after key-up.

Reset the trace EMA on the first clean RX frame via the existing
m_resetFftSmoothingOnNextFrame mechanism (the same pattern used by the
lock / retune / dBm-range paths) so it is taken raw instead of weighted
against TX-era history.

Scope: this is the client-side contributor only. The radio firmware's
own FFT averaging buffer (display pan average / weighted_average) is the
dominant slow-decay source and is not client-fixable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jensenpat jensenpat linked an issue Jun 26, 2026 that may be closed by this pull request
2 tasks
@jensenpat jensenpat marked this pull request as ready for review June 26, 2026 06:31
@jensenpat jensenpat requested a review from a team as a code owner June 26, 2026 06:31

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

Reviewed the one-line change against the codebase — it's correct and well-scoped.

Verified:

  • The new m_resetFftSmoothingOnNextFrame = true sits in the (!tx && m_transmitting) branch of setTransmitting(), alongside the other TX→RX resets (m_txEndMs, native-tile wait, endTxDbmRangeFreeze()), so it only fires on a genuine key-up edge — not on every setTransmitting(false) call.
  • It reuses the existing, proven flag exactly as the lock (SpectrumWidget.cpp:4878), retune (6348), dBm-range (4125), and FFT-scale (1441) paths do. The flag is consumed once in updateSpectrum() (4566): m_smoothed = *spectrumBins, i.e. the next frame is taken raw instead of EMA-weighted against TX-era history. So this genuinely completes a reset block that was missing this one member.
  • No convention concerns: no settings, no allocations, no lifetime/RAII issues, no new system boundaries to guard. The flag is default-initialized (SpectrumWidget.h:802).

One thing worth noting (no change requested): because the radio keeps streaming TX-contaminated frames through the UNKEY_REQUESTED window, the first post-key-up frame the flag lands on may itself still be slightly contaminated — m_smoothed then re-anchors from that frame rather than a fully clean one. That's strictly better than the multi-frame EMA tail this replaces, and the waterfall blanker (m_txEndMs) covers that window separately, so it's fine as-is. I mainly call it out because it's the same subtlety your honesty note already gestures at.

The honesty note about the bench is appreciated and accurate — a clean dummy-load RX can't produce the contaminated frames needed to measure the tail shrink, so "builds, stays level, no regression, TX-safe" is the right claim to make. The change stands on its own as a low-risk correctness completion regardless.

Thanks for the thorough write-up and the careful scoping (client-side only, firmware averaging buffer explicitly out of scope). LGTM.


🤖 aethersdr-agent · cost: $3.7830 · model: claude-opus-4-8

@NF0T NF0T self-assigned this Jun 26, 2026
@NF0T

NF0T commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

@NF0T review — PR #3831

Single-file, one line of code, nine lines total including comment. One of the cleaner fixes in the queue.

Root cause and fix are correct. setTransmitting(false) already resets every other TX→RX display state — m_txEndMs (waterfall blanker), native-tile wait, m_wfBlankerRingCount, m_wfLastGoodRow, endTxDbmRangeFreeze() — but was missing the FFT trace EMA. During the UNKEY_REQUESTED window the radio keeps streaming TX-contaminated FFT frames (#1927); those feed updateSpectrum() and poison m_smoothed. At SMOOTH_ALPHA = 0.35 the EMA needs ~5–7 frames (~200–300 ms at 25 fps) to wash out, producing the elevated post-key-up floor the user sees. The fix completes the reset block by setting m_resetFftSmoothingOnNextFrame = true in the exact same branch — (!tx && m_transmitting), the genuine key-up edge — that the other resets already occupy. It reuses the flag already consumed in updateSpectrum() exactly as the lock, retune, dBm-range-change, and FFT-scale paths do. This isn't a new pattern; it's the missing entry in an existing list.

The inline comment is the right shape. It explains the WHY, quantifies the impact (~200–300 ms, 5–7 frames, SMOOTH_ALPHA), and explicitly bounds what this does not fix (firmware averaging buffer). That last sentence is especially good — it tells a future reader the exact right place to look for the remaining effect.

The honesty note in the PR body is Principle VIII applied correctly. Explicitly calling out that the dummy-load bench can't reproduce the TX-contaminated frames needed to measure the tail shrink, and making only the claim the evidence supports ("builds, stays level, no regression, TX-safe"), is exactly right. The fix stands on its own as a correctness completion regardless of what the bench can prove.

CI: All six checks green. The live-RF limitation (CI can't produce TX-contaminated frames from a dummy load) is inherent and honestly disclosed — no concern there.

Constitution: Principle VIII (Evidence Over Assertion) and Principle XI (Fixes Are Demonstrated) both honored. Principle II respected by explicitly scoping to the client-side contributor only and leaving the firmware averaging buffer out of scope.

For future reference (not blocking): No Fixes #3804 / Closes #3804 keyword in the PR body — the issue is linked by hyperlink and the branch name, but GitHub only auto-closes on a keyword. The squash commit will carry Fixes #3804 in its body to close the issue on merge.

Tier 3 only (src/gui/SpectrumWidget.cpp* catch-all). Merging now.

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

One-line fix in the correct branch, reusing the existing m_resetFftSmoothingOnNextFrame mechanism already used by lock/retune/dBm-range paths — completes a reset block that was missing this one member. Inline comment is WHY-correct and bounds the firmware-side contributor explicitly. Honesty note about dummy-load bench limitations is Principle VIII applied correctly. All six CI checks pass. Tier 3 only.

Squash subject: fix(spectrum): reset FFT trace EMA on TX→RX so the floor doesn't linger — Principle VIII. (#3831)

@NF0T NF0T merged commit 05e4ea7 into aethersdr:main Jun 26, 2026
6 checks passed
ten9876 added a commit that referenced this pull request Jun 26, 2026
… CWX, floors, pan mgmt (#3646) (#3832)

Expands the agent automation bridge with the verbs and read-fields a
TX/spectrum behavioural test actually needs. Every one was exercised
live against a FLEX radio while reproducing and verifying the
[#3804](#3804) FFT-floor
work (companion fix PR #3831). No behaviour changes outside
`AETHER_AUTOMATION`.

## New verbs

| Verb | Purpose | TX-gated |
|---|---|---|
| `slice txant <ANT>` / `slice rxant <ANT>` | Set TX/RX antenna
deterministically — the GUI controls are blocking `QMenu::exec()` popups
`invoke()` can't drive. Routes through `SliceModel::setTx/RxAntenna`. |
no |
| `floors` | Lightweight per-pan FFT floor read
(`displayFloorDbm`/`noiseFloorDbm`/`panIndex`), ~15 ms vs `dumpTree`'s
~250 ms — fast enough to sample a sub-second post-TX transient. | no |
| `cwx send <text>` / `cwx speed <wpm>` / `cwx stop` | Drive the CWX
keyer (CW's rapid TX→RX edges are the easy floor-recovery repro). |
`send` only |
| `txwaterfall on\|off` | Toggle the radio's `show_tx_in_waterfall` so a
test can confirm keyed TX paints the waterfall. | no |
| `pan create` / `pan remove <id>` / `pan center <mhz>` | Add/remove a
panadapter and recenter it (band-change lever — plain `tune` uses
`autopan=0` and clamps to the pan's range). Multi-band coverage. | no |

## New read-fields / properties

- **`txAntenna`** on the slice snapshot (`get slice`) — previously
unreadable, so the live TX antenna couldn't be asserted before keying.
This is what makes a dummy-load TX-safety gate enforceable.
- **`showTxInWaterfall`** on the transmit snapshot (`get transmit`).
- **`noiseFloorDbm` / `displayFloorDbm` / `panIndex`** Q_PROPERTYs on
`SpectrumWidget`, surfaced generically via `QObject::property()` in
`describeWidget`/`floors` — no core→gui coupling. `displayFloorDbm`
reflects the smoothed trace (`m_smoothed`); `noiseFloorDbm` tracks the
raw frame.

## Safety

`cwx send` rides the existing `AETHER_AUTOMATION_ALLOW_TX` rail and arms
the force-unkey watchdog, same as `txtest`/`key`. Antenna/floor/pan
verbs don't key.

## Known follow-ups (not in this PR)

- `RadioModel::removePanadapter` sends `display pan close`, which
doesn't close a *panafall*-created pan — the second pan had to be torn
down via slice removal.
- `grab` returns the *first* `SpectrumWidget` in a multi-pan layout — a
`grab pan <index>` selector (keyed on the new `panIndex`) would let a
driver capture a specific pan.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.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.

FFT floor takes long time to drop to the same level after TX

2 participants