feat(rx): adaptive RX filter for SSB (ESSB auto-fit) — RFC #3878#3945
Conversation
…3878 Per-slice "Adaptive RX filter": measures the occupied bandwidth of the tuned SSB signal from the panadapter FFT and continuously fits the RX passband to it — within operator bounds, smoothly and stably, with a weak-signal fallback to the operator's selected filter. Client-side only: drives the existing `filt` command; the filter stays radio-authoritative and unpersisted (only enable + bounds persist). RFC aethersdr#3878 approved. UI / wiring: - New AdaptiveFilterEngine (src/core), GUI-thread, off spectrumReady; wired in MainWindow_Wiring.cpp; MIDI binding (rx.adaptiveFilter). - SliceModel: client-side adaptive state + applyAdaptiveFilter + userFilterEpoch. - VfoWidget: SSB-only Mode-tab controls + AUTO badge + nested-key config. - SpectrumWidget: floor-level edge markers + green/red status ball. Measurement (measureOccupiedRegion — NEW, not VoiceSignalDetector): - dB-domain envelope suppresses hets/spikes; temporal averaging (EMA) for weak/medium stability. - Carrier-anchored scan; FLOOR-relative occupied threshold (not peak- relative — peak-relative floated the edge with speech loudness). - Internal gaps bridged via pre-gap-level comparison (same-signal recovery vs a separate stronger lobe); wide bridge for deep QSB notches. - Strength-based splatter rejection: near-carrier peak anchor + rebound cut; SSB-voice shape gate (energy must start near the carrier). - Guardrails: low-cut clamped <=400 within [minLow], high-cut >=1800 within [maxHigh]. Stability: - median -> percentile peak-hold (not max: rejects transient over-wide) -> deadband + asymmetric dwell + refractory; glide with a min send interval (anti command-storm, RFC cond. 2). - Schmitt-trigger confidence integrator, slow-release so it rides speech pauses without dropping to the default. - Caches reset cleanly on tune / band / mode (USB<->LSB re-baseline); baseline persists across tunes; manual filter edit disables adaptive (user intent wins, RFC cond. 3). DSP constants are first-pass and expect on-air tuning; UX/visuals/defaults flagged for maintainer sign-off per the RFC. Principle V. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sets Spec-driven improvements to the adaptive RX filter (RFC aethersdr#3878), plus operator presets and an on-air over-cut fix. Measurement core (new SliceModel-free TU src/core/OccupiedRegion.{h,cpp}, split out so it is unit-testable against Qt6::Core alone): - Per-frequency noise-floor curve (sliding low percentile, clamped to only rise above the global scalar) so a tilted floor no longer runs the high-cut out into noise; presence gate stays on the robust global scalar. - In-band reference (median of the confirmed-core bins, not the peak) feeds a reference-relative splatter cap that excludes slowly-decaying tails. - Splatter cap is GATED: it engages only when the floor crossing runs past the plausible voice band, so normal voice that rolls off gradually to the floor is no longer chopped (fixes a ~3 kHz signal being cut to ~1.8 kHz on air). - Sharp-edge precision: snap to a steep transition when a clear cliff exists. - Rebound "separate stronger station" test compares the resume plateau to the in-band reference (robust to envelope blur); peak anchored to the carrier core so a louder neighbour can't hijack it. Engine (AdaptiveFilterEngine): - Faster attack, slow release: first fit out of idle commits on a short engage dwell; widening quicker than narrowing; fade/gap hold unchanged. Operator presets (SliceModel + VfoWidget, persisted; shown only when enabled): - Minimum SNR (Sensitive/Normal/Strong) -> presence gate. - Response speed (Fast/Normal/Slow) -> dwell/settle timing. - Splatter rejection (Tight/Normal/Wide) -> outer-edge cap. UI: idle status ball is now neutral gray (color.text.disabled) instead of red — idle is a normal waiting state, green still marks an active AUTO fit. Tests: new tests/adaptive_filter_test.cpp — 10 scenarios x USB/LSB (clean, het, QSB gap, splatter tail, stronger neighbour, soft roll-off, weak-signal gate, tilted floor, reference-is-median, and the declining-voice over-cut regression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
During TX the panadapter shows the transmit signal (or muted RX), so fitting against it would chase garbage. Gate the per-frame driver on RadioModel::isRadioTransmitting() and return early — this holds the current passband and all per-slice engine state untouched, so the fit resumes cleanly on unkey instead of re-fitting from scratch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the Adaptive-RX-filter control group (enable checkbox + Min low-cut, Max high-cut, Min SNR, Response, Splatter) out of VfoWidget into a reusable AdaptiveFilterControls widget bound to a SliceModel, and embed it in both the VFO flag and the RX Controls applet (RxApplet). Both instances bind to the same slice, so editing one updates the other live via the model's *Changed signals; QSignalBlocker on the programmatic sync keeps only the touched widget saving. - AdaptiveFilterControls owns build + bidirectional binding + show/hide + the static load/save prefs (JSON schema, moved from VfoWidget, single source). withHeader/compact ctor flags fit each host; rows are label + stretched combo (no fixed width) so they flex to the host column. Compact shrinks fonts to 11px and caps combo height at 22px to match the applet's dense rows. - VfoWidget: replace the inline impl with one embedded instance; keep the flag-specific AUTO-badge wiring; load via AdaptiveFilterControls::loadPrefs. - RxApplet: SSB-only instance placed as a full-width row BELOW the two columns so it doesn't unbalance column heights (which had pushed RIT/XIT to the bottom); bound in connectSlice, detached in disconnectSlice, shown for USB/LSB by updateModeSettings. No SliceModel/engine/AppletPanel changes — the engine reads the slice getters each frame, so edits from either surface take effect immediately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refine the RX-applet placement of the adaptive controls so they read like the rest of the applet without disturbing it. - AdaptiveFilterControls gains a Section flag (SecCheckbox / SecBounds / SecPresets / SecAll) so one widget can render any subset of the group. The VFO flag uses SecAll; the applet splits the group into two instances bound to the same slice (live sync via the model). - In the applet the group is now a SELF-CONTAINED 2-column block placed BELOW the main columns (full-width divider + left: enable checkbox + Lo/Hi cut, right: SNR/Speed/Splatter at 40/60). Keeping it out of the left/right columns means toggling it no longer restretches the filter passband (expanding size policy) above it, and rit/xit stay anchored under AGC (their stretch is back in its original spot). - Condense the labels (Min low-cut -> Lo cut, Max high-cut -> Hi cut, Min SNR -> SNR, Response -> Speed, Splatter -> Splat) with the full name as tooltip + accessible name, and let the compact combos shrink (AdjustToMinimumContentsLengthWithIcon) so the rows never force the 2-column split wider. - Compact preset matches the applet's dense rows: 11px label/combo font and 20px combo height (like the mode/SQL controls). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a twoColumn mode to AdaptiveFilterControls (checkbox + filter bounds left, behaviour presets right) and use it in both hosts: - VFO flag now uses the compact two-column arrangement (was a roomier single column), matching the applet's look and size. - RX applet consolidates its previous manual two-instance block down to a single two-column widget — same result, less code (drops m_adaptivePresets and the hand-built block/separator). - Small 6px bottom margin on the group so it isn't flush against the flag edge (matches the S-meter/SmartMTR menu); harmless in the applet (stretch below). Known issue: the two-column layout can overflow the narrow VFO flag; placement there still needs tuning (tighter columns or a single-column fallback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Flag: give the VFO flag's root layout a 4px bottom margin (was 0). Every open tab/menu (Sound, DSP, Mode, X/RIT, DAX, and the adaptive section) sat flush against the flag's bottom edge, so with the 1px-inset rounded background the last row spilled a few px outside the painted shape. One global fix covers all menus. - AdaptiveFilterControls: condense the labels (Lo cut / Hi cut / SNR / Speed / Splat) with the full name as tooltip + accessible name, and let the compact combos shrink (AdjustToMinimumContentsLengthWithIcon) so the two-column rows stay inside the host column. Wrap the two sub-columns in a real widget so the group's height propagates reliably. Drop the widget's own bottom margin now that the flag's root margin provides the spacing (the applet has a stretch below it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The enable checkbox was shorter than a combo row, so the left column started ~2px short and the offset cascaded, misaligning Lo cut/Hi cut against Speed/Splat. Give the checkbox the combo row height (setMinimumHeight, compact mode) so both columns are three equal-height rows and align row-for-row in the flag and applet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-offset temporal average (avgEnv) survived a dropout and kept EMA-tracking the noise floor, so when a signal returned after a long silence the average had to climb back up over ~1 s — the fit re-engaged slowly and narrow. A fresh tune does not have this because clearFit() wipes avgEnv. Clear avgEnv each frame while idle (active false, reverted to baseline) so a returning signal is captured at full width on its first frame, like right after a tune. active stays true through brief speech pauses (HOLD), so pause-riding is untouched; the presence gate + confidence integrator still guard against engaging on noise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A signal whose energy peaks well above the carrier had its dominant high hump cut off — the peak search and in-band reference were anchored near the carrier, so the strong hump read far above that reference and the rebound treated it as a separate/louder station. Anchor both the peak and the reference to the "connected run" — the contiguous energy from the carrier that never returns to the noise floor — instead of a fixed near-carrier window: - peak lands on the dominant hump wherever it sits, so the energy up to it is bridged and the hump is captured; - reference is a high (75th) percentile of the run, so it tracks the signal's real level under a treble hump while staying robust to a lone transient het; - the separate-station rebound cut now fires only across a real floor-reaching disconnection (kFloorDiscHz), never a relative dip, so one tilted signal is not split. New treble-dominant test reproduces the on-air case (high hump captured); the existing neighbour/gap/splatter/transient cases still pass (no regression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for a genuinely impressive, well-documented contribution, @dsocha — the RFC-driven design shows throughout. I focused on conventions, boundaries, and lifecycle bugs rather than the DSP feel (which you've correctly flagged as first-pass, on-air-tuning material for maintainer sign-off). CI is fully green (build/macOS/Windows/CodeQL/a11y).
What looks great
- Clean-room, testable core. Splitting the measurement into a
SliceModel-freeOccupiedRegionTU that unit-tests againstQt6::Corealone (23 assertions, both sidebands, tilt/gap/splatter/neighbour cases) is exactly the right seam. - Principle adherence checks out in code, not just prose. Filter edges stay radio-authoritative:
applyAdaptiveFilter()deliberately does not bumpm_userFilterEpoch, whilesetFilterWidth()does — so the engine can distinguish its own writes from an operator preset/drag unambiguously. Config persists under one nestedAppSettings["AdaptiveFilter"]key (Principle V), edges never persisted (Principle III). - TX suspend in
onSpectrumReadyForAdaptiveFilterreturns early while transmitting and holds per-slice state, so the fit resumes cleanly on unkey. Good. - The Schmitt-trigger confidence integrator + median→peak-hold→dwell→glide pipeline is thoughtfully guarded against per-syllable oscillation and
filtcommand storms.
Issues / suggestions
1. (Should fix) Engine per-slice state is never pruned on slice removal. AdaptiveFilterEngine::resetSlice() is only called from within processFrame() (mode-off / manual-edit disable). Slice teardown wiring calls removeSliceOverlay() (MainWindow_Wiring.cpp:794/1026/1033) but never notifies the engine, so m_state retains the removed slice's entry keyed by sliceId. Because Flex slice ids are small and reused, a new slice that reuses that id inherits the old slice's haveBaseline=true + stale baseLow/baseHigh — the freq-jump path clearFit()s but deliberately keeps the baseline, so the new slice fits against the wrong operator baseline until a manual edit. Suggest connecting the same slice-removed signal that drives removeSliceOverlay() to m_adaptiveFilterEngine->resetSlice(id).
2. (Minor) enabled=true stays persisted after an auto-disable-on-manual-edit. When the engine hands control back via setAdaptiveFilterEnabled(false), the widget's adaptiveFilterEnabledChanged sync path updates the checkbox under QSignalBlocker and does not call savePrefs() — so persisted enabled remains true and the feature re-enables on next session. That contradicts "the operator re-enables it explicitly." If the session-only hand-back is intended, a one-line comment would help; otherwise persist the disable.
3. (Question) Persistence keyed by ephemeral sliceId. root[QString::number(slice->sliceId())] means config is bound to slice index, which is reused across radios/profiles — bounds set on slice 0 of radio A will apply to slice 0 of radio B. If that matches how other per-slice prefs behave here, ignore; just flagging in case a per-radio/per-mode scoping was intended.
Your two disclosed follow-ups (band-stack recording the auto-adjusted width instead of the operator baseline; first-pass DSP constants) are reasonable to defer — #1 above feels like the one worth folding into this PR since it's a correctness bug rather than a tuning matter.
Nice work overall.
🤖 aethersdr-agent · cost: $8.6360 · model: claude-opus-4-8
|
@dsocha Dariusz, can you ask your agent to use the agent automation bridge to test this feature extensively and make recommendations if any agent verbs are required? Thanks! |
AdaptiveFilterEngine.h uses INT_MIN in the SliceState sentinels (curLow/curHigh, tgtLow/tgtHigh) but only pulled it in transitively via Qt headers, so the translation unit fails to compile on toolchains that don't provide <climits> transitively (newer GCC / libstdc++). Add the explicit include. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AdaptiveFilterEngine only self-clears per-slice state for slices its processFrame() still sees; a deleted slice is never seen again, so its QHash entry (baseline, cur/tgt passband, avgEnv, confidence) leaked and a recreated slice reusing the same id inherited the closed slice's fit. Call resetSlice(id) from MainWindow::onSliceRemoved alongside the other per-slice teardown. (RFC aethersdr#3878) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…leInterface The adaptive-filter markers (edge triangles, cut-frequency labels, AUTO status ball) are data-bearing custom-painted content. Add the docs/a11y.md-required TODO(a11y) marker linking the follow-up (aethersdr#3957). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Dariusz, can you ask your Claude: "Use the agent automation bridge to test this feature extensively, update the PR to provide test details and screenshots and make recommendations if any automation verbs are required."
Thanks for the new RX filter feature!
When a manual filter edit makes the engine hand control back via setAdaptiveFilterEnabled(false), the widget synced the checkbox under a QSignalBlocker but never persisted, leaving enabled=true on disk so the feature silently re-enabled next session. Persist on any real enabled change (operator or engine-driven) in the adaptiveFilterEnabledChanged sync path. Addresses review finding aethersdr#2 on PR aethersdr#3945. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sliceSnapshot() omitted every adaptive field, so an agent could drive the controls via invoke but not read them back. Add the seven adaptive fields (settings plus the live adaptiveActive AUTO state) and document them in the get slice reference, making the feature assertable over the automation bridge. Addresses review finding aethersdr#3 / jensenpat's request on PR aethersdr#3945. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lope) On a wide (~3 kHz) SSB signal the measured high edge jittered ~1800<->3100 Hz frame to frame: SSB voice only fills its upper 1.5-3 kHz intermittently, and the symmetric slow envelope average let the upper band sag below the occupied gate in every word gap, collapsing the edge to the low formants and snapping it back when speech resumed. Replace the symmetric per-offset EMA with a video peak-hold: fast attack (0.30, ~0.1 s) so the edge extends the instant the signal reaches out, slow bounded release (0.03, ~1.1 s) so a gap or between-sibilant lull does not collapse it. The high edge now reflects the SUSTAINED occupied width; release stays short enough that a genuine narrowing still resolves in ~1-2 s. New regression test (intermittent upper voice) holds the edge at 2885 Hz across a ~0.7 s upper-band gap; the old symmetric average collapsed it to 1225 Hz. 24/24 assertions pass. DSP feel still expects on-air sign-off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Automation-bridge test + a stability improvementThanks @jensenpat — drove the feature end-to-end through the agent automation bridge against a live FLEX-8400M (20 m/40 m SSB), and while doing so found and fixed a real stability issue. Summary below. Bridge test (what I did)Launched with
ScreenshotsLive captures against the FLEX-8400M (strong USB ESSB, ~3 kHz — AUTO engaged, passband hugging the signal), attached below:
📎 images being added Verb recommendationNo new verb is required. Optional convenience only: a dedicated (Note: the controls carry identical accessible names in both the VFO flag and the RX applet — consistent with every other dual-hosted control here, e.g. Stability fix found during testingOn a wide (~3 kHz) signal the measured high edge jittered ~1800↔3100 Hz frame-to-frame: SSB voice only fills its upper 1.5–3 kHz intermittently, and the symmetric envelope average let the upper band sag below the occupied gate in every word gap — collapsing the edge to the low formants and snapping back when speech resumed. Fixed by replacing the symmetric per-offset average with a video peak-hold envelope (fast attack ~0.1 s, slow bounded release ~1.1 s): the high edge now reflects the signal's sustained reach and sits still, while a genuine narrowing still resolves in ~1–2 s. New regression test holds the edge at 2885 Hz across a ~0.7 s upper-band gap where the old code collapsed it to 1225 Hz. Review findings (from the earlier automated review)
DSP constants remain first-pass and still expect maintainer sign-off on feel, per the RFC.
|
…nable) The enabled state is no longer restored from settings at startup: the operator opts into the adaptive filter explicitly each session, in line with RFC aethersdr#3878's user-intent-wins principle. Bounds and presets (min low-cut, max high-cut, Min SNR, Response, Splatter) still persist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent) The per-frequency floor curve called percentileOfWindow — a QVector heap copy + nth_element over a ±2500 Hz window — once per scan offset, i.e. O(span × window) per frame per slice on the GUI thread. Both factors grow as 1/hzPerBin, so the cost was QUADRATIC in zoom: at a 12 kHz span on a 4096-bin Retina pan it reached ~3.8M float copies + ~2200 heap allocs per frame (the 'waterfall chokes when zoomed' regression, introduced with the floor curve in 69ef02d and doubled by the 60 fps pan ceiling from main). The window slides by exactly one bin per offset, so a 256-bucket dB histogram (one remove + one add per step, running percentile by bucket walk) produces the same low percentile in O(buckets) with zero heap traffic. Bucket rounding is ≤0.24 dB — absorbed by the existing [scalar, scalar+10] clamp and far below the 3/5 dB gates. All 12 measurement tests pass unchanged (both sidebands). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hzPerBin = span/xpixels shrinks without bound as the pan zooms in (xpixels tracks widget width, not span), so every measurement loop grew linearly with zoom on top of the floor curve's window. Everything downstream of the measurement is far coarser (50 Hz snap, 150 Hz margin, 220 Hz deadband), so sub-25 Hz bins buy no edge accuracy. Below 25 Hz/bin the input is now decimated — mean in dB of D consecutive bins (max would inflate averaged noise ~5 dB and risk false-engage at Sensitive; stride subsampling makes narrow hets jitter the edges) — and measured on the effective 25-50 Hz grid: the whole measurement is O(1) in zoom (scan <= ~261 bins). Pans at or coarser than the cap take the D=1 path, bit-identical to before (the canonical test grid included). Tests: buildSpectrum generalized to buildSpectrumAt(N, bw, ...); new fine-grid group (8192 bins / 50 kHz -> D=5) replays the clean-sharp, soft-rolloff and declining-voice shapes and asserts the fit matches the coarse grid within ±150 Hz / 2 dB. All pass both sidebands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…filt guard The pan fps ceiling was raised to 60 on main (perf(gui) aethersdr#3958), but every constant in the adaptive pipeline is counted in frames and was calibrated at ~25-30 fps. At 60 fps every dwell/hold/confidence window halves in wall-clock time and — worse — the frame-counted send throttle (kSendIntervalFrames=4) allows ~15 filt/s, violating RFC aethersdr#3878 cond. 2 (<= ~8/s). Frames arriving faster than 25 ms apart are now not accepted (native ~30 fps passes untouched; 60 fps is paced to 30 — the calibration rate — and the measurement cost halves as a bonus). The gate sits before any state update, so rejected frames are invisible to every frame counter. glideToward additionally enforces >= 125 ms between filt sends in wall-clock, making the RFC bound independent of any fps assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The connected-run scan broke at the first >=250 Hz floor gap ANYWHERE, so peak, reference and the presence gate all anchored to the bass-only lobe of a two-hump (smiley-EQ / tilted) signal. Consequences, each reproduced: the rebound test amputated treble humps >8 dB above the bass reference; a strong treble-dominant signal whose weak bass lobe alone missed the presence preset was rejected outright (baseline lurches at the fade rate); the 600 Hz silence-stop truncated wide low-SNR mid scoops with AUTO showing a confident (wrong) 1800 Hz fit. Three inconsistent gap standards coexisted (250 Hz first pass / one-bin gapHitFloor / 600 Hz silence) and the bin conversions int-truncated (250 -> 195 Hz at ~98 Hz/bin, one bin past 125 Hz/bin). Now ONE extent pass decides how far the signal reaches, then an anchor pass computes peak/presence/reference over the FULL kept extent, then the cap pass runs against that final reference: * Disconnections arm on the RAW work-grid bins, Hz-accumulated (no truncation), tracked for every bin — the raw valley starts under the envelope smear shoulder, and measuring it on the envelope re-shrank a real 400 Hz valley to 195 Hz. * A resume across an armed gap compares its plateau to max(pre-gap level, reference-so-far) + kReboundDb — what the comment always claimed. Stronger lobe past a CONFIDENT run: cut (neighbour). Past an UNCONFIDENT run: RE-ANCHOR into the tuned signal's dominant hump, if it starts by kReanchorMaxStartHz (2 kHz) — energy first appearing beyond that above a silent low band is an adjacent station. * The silence-stop only ends a CONFIDENT run; an unconfident run keeps scanning (bounded by kScanHz) so a wide at-floor scoop no longer hides the dominant hump behind it. All 12 original scenarios keep passing (both sidebands), plus a new two-hump matrix: sub-standard valley bridged, +6 dB resume bridged, +12 dB lobe past a confident run cut, weak-bass smiley re-anchored, wide scoop looked past, distant lobe rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a soft skirt the floor-relative crossing is a property of the SNR, not of the TX signal: on S dB/kHz it moves ~1000/S Hz per dB of level change, so the measured width breathed with QSB and weak stations were stably fitted too narrow. ITU-R SM.443 defines SSB voice's 99% occupied bandwidth as ~26 dB below the peak — a level-invariant criterion. Inside the splatter guard the edge is now additionally capped at the outermost occupied bin within (splatterDownDb + 5) dB of the in-band reference — the SAME rule family as the splatter cap at a deeper depth (Tight 23 / Normal 30 / Wide 40), so the operator's preset scales both regimes and the past-guard splatter depth is always the tighter where both apply. The +5 over ITU's 26 covers the reference sitting under the peak plus envelope smear (a bare 26 re-chopped the declining-voice shape the splatter guard exists to protect). The cap engages only with real headroom (reference >= depth + 5 dB over the scalar floor): weak signals stay on the floor crossing and are handled by the engine's widen-only low-SNR rule (next commit). The floor+5 crossing remains the absolute outer bound by construction. OccupiedRegion now exports floorDbm (the scalar floor actually used, supplied or fallback) so the engine can key low-SNR behaviour on peakDbm - floorDbm. Tests: level sweep (same soft-skirt shape at -60/-70/-80 dBm) shows a 0 Hz high-cut spread — was ~333 Hz per 10 dB; FCC 47 CFR 2.1049 two-tone vectors (400/1800, 500/2100, 500/2400) land just past the upper tone; floorDbm passthrough + fallback covered. All prior scenarios unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three engine rules protecting the NARROWING direction, sharing the one commit chokepoint (measurement, dwell and widening stay live throughout): * Widen-only at low SNR: below kLowSnrNarrowFreezeDb (16 dB peak over the measurement's floor — above the strongest presence preset, so every preset has a widen-only band just over engagement) a narrower reading is an SNR artifact of the floor-relative crossing (the level-invariant cap has no headroom down there): narrowing commits are suppressed, so a weak DX station never gets pinched below the operator's baseline or its running fit. * Fade freeze: while the in-band reference is falling (median of the first vs last kFadeEndFrames over a ~0.7 s trail drops by more than kFadeDropDb) the measured width is shrinking with the fade, not with the TX filter — narrowing holds until the level stabilises. Dwell keeps accumulating, so a genuine width change commits the moment the freeze lifts. * Re-engage restore: a deep fade decays confidence, reverts to baseline, and the fresh engage fit was built from the first weak frames — narrow. The fit is now remembered at the dropout and restored verbatim when the same frequency re-engages within 20 s (an HF QSB cycle); the pipeline refines from there. Memory is invalidated by tune (frequency check), mode change (signed convention flips), and slice reset. No engine unit test: the engine is QObject-coupled to SliceModel (whose setters send wire commands); the measurement-layer inputs these rules key on (floorDbm, peakDbm, referenceDbm) are covered by the measurement tests, and the behaviours are on the on-air checklist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extent pass's raw-bin disconnection arming indexed binsDbm[binAt(o)] directly; binAt(o) is unclamped and the scan runs the full kScanHz, so a slice tuned within 6.5 kHz of the pan edge (with signal energy present) walked past the last bin — QList assert abort in debug, OOB read in release. Every other consumer of binAt() goes through env(), which clamps; the raw access now clamps the same way (edge bin repeats). Regression test: voice hump painted relative to a carrier 2 kHz inside the pan edge, both sidebands, canonical and decimated grids — SIGABRT before the fix, valid clipped fit after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Fable touches — 8 commits on top of the existing PR, covering a zoom-related performance regression plus two measurement improvements from a deep analysis of the adaptive filter. On-air tested. Performance (the reported "waterfall chokes when zoomed")
Measurement improvements
Also
Tests: 🤖 Generated with Claude Code 📹 Demo video (a few minutes, on-air): https://youtu.be/WzW-m0v294A — shows the adaptive RX filter fitting the passband in real time. |
jensenpat
left a comment
There was a problem hiding this comment.
Thanks for the thorough bridge-test pass, the screenshots/video, and for folding in the earlier review findings — the OccupiedRegion core, the bin-clamping/decimation math, TX suspend, slice-id state pruning, threading, and the Principle III/V compliance all reviewed clean. Two blockers surfaced in a fresh review pass, though, so holding the merge until they're addressed:
Blockers
1. The wall-clock pacing / filt rate limit is dead in production — emittedNs is 0 unless perf-debug logging is on.
PanadapterStream.cpp:804 stamps the frame only when PerfTelemetry::instance().enabled() (i.e. lcPerf debug logging), otherwise it emits emittedNs = 0. Every timing guard in AdaptiveFilterEngine is gated on emittedNs > 0:
- the ~30 fps frame-pacing gate is skipped, so at the 60 fps pan ceiling the pipeline runs at full frame rate;
- the ≤8 filt/s wall-clock guard in
glideTowardis skipped, leaving only thekSendIntervalFrames = 4frame counter → up to ~15 filt/s per slice during a glide (the RFC #3878 cond. 2 command storm this PR is designed to prevent); - the fade-freeze / re-engage-restore memory is silently inert.
Suggested fix: timestamp unconditionally in PanadapterStream (a monotonic nowNs() is cheap), or fall back to a QElapsedTimer inside the engine when emittedNs <= 0.
2. Pan migration force-disables the feature mid-session.
On SliceModel::panIdChanged the VfoWidget is destroyed and rebuilt, and VfoWidget::setSlice runs again — which calls AdaptiveFilterControls::loadPrefs(), whose unconditional slice->setAdaptiveFilterEnabled(false) switches the adaptive filter off just because the operator dragged the slice to another panadapter. The "call once when a slice is set up" comment documents an invariant the wiring doesn't provide. Suggested fix: guard loadPrefs to first-bind only (e.g. track loaded slice ids, or skip when re-binding the same slice).
Non-blocking notes (fine as follow-ups)
pushSliceOverlaydoesn't pushadaptiveFilterEnabled/adaptiveActive, so after overlay recreation (pan migration / reconnect-reclaim) the markers and status ball won't reappear until the next change signal — becomes visible once blocker 2 is fixed.- External CAT/TCI filter writes go through
setFilterWidth()and bump the user epoch, so e.g. WSJT-X setting a filter silently disables the adaptive filter with no indication why. Probably "user intent wins" is right, but worth a doc note. - Prefs keyed by volatile
sliceIdcan shuffle bounds/presets between sessions/radios; and after an auto-disable, re-enabling adopts the last adaptive-driven width as the new "operator baseline" rather than the manual filter. Both behavioral nits.
Everything else — including the new sliding-histogram floor, decimation, edge-clamp fix, and the 58-assertion test matrix — looks solid. Happy to merge once the two blockers are fixed.
# Conflicts: # CMakeLists.txt
Two processFrame blockers from the aethersdr#3945 review: 1. The per-frame emittedNs timestamp is stamped only when perf-telemetry logging is on (it doubles as a telemetry gate in PanadapterStream and MainWindow_Session), so in production it arrives as 0 and every wall-clock guard here — the ~30 fps frame pacing, the <=8 filt/s send cap (RFC aethersdr#3878 cond. 2), and the re-engage-restore memory — was silently inert. At the 60 fps pan ceiling the send throttle fell back to the 4-frame counter alone (~15 filt/s per slice). Substitute a monotonic QElapsedTimer when emittedNs <= 0, so the gates hold regardless of telemetry state. Fixed in the engine rather than stamping unconditionally upstream, which would flip the emittedNs>0 telemetry-latency gates in MainWindow_Session on when telemetry is off. (jensenpat blocker 1) 2. The top guard dropped per-slice state on disable without sending anything, so unchecking the feature stranded the passband at the last auto-narrowed width — contradicting SliceModel::setAdaptiveFilterEnabled's own contract that 'the engine restores the operator's selected filter separately.' Before resetSlice(), if the operator disabled it on an SSB slice and the engine had narrowed off the frozen baseline, re-apply baseLow/baseHigh via applyAdaptiveFilter (no user-epoch bump); gated on ssb so a mode change, which already resets the filter, isn't fought. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
loadPrefs unconditionally called setAdaptiveFilterEnabled(false) to enforce the session-scoped 'starts disabled' rule, but it runs on every re-bind — and a pan migration destroys and rebuilds the host VfoWidget (re-running setSlice) — so dragging a slice to another panadapter silently switched the feature off. Force-disable only on the first load of a slice this session, via a set shared across both host controls (VFO flag + RX applet); re-binds preserve the operator's current enable state. (jensenpat blocker 2) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Review: 2nd-pass follow-up — rebased + 3 blockers fixed (COMMENT, not approving)
Ran a fresh high-effort pass (multi-agent finders over the DSP core, the engine state-machine, the GUI wiring, and cross-cutting conventions), reconciled the branch with current main, and pushed fixes for all three blockers. Leaving the merge decision to @jensenpat since his CHANGES_REQUESTED stands and the DSP constants still want on-air sign-off.
Reconciled with main
Merged current origin/main (commit e3dc02b0) — only CMakeLists.txt truly conflicted (add/add of two test targets; kept both). The merged tree builds clean and adaptive_filter_test passes. mergeable is now green.
Blockers fixed (pushed, GPG-signed, build + test verified)
1 — wall-clock gates revived (e9f6aef1). emittedNs is 0 in production (stamped only under perf-telemetry logging, where it doubles as a gate in PanadapterStream/MainWindow_Session), so the ~30 fps pacing, the ≤8 filt/s cap, and re-engage-restore were all inert — at 60 fps the throttle fell back to the 4-frame counter alone (~15 filt/s/slice). Added a monotonic QElapsedTimer fallback in the engine (used when emittedNs <= 0). Chose the engine-side fix over stamping unconditionally upstream on purpose: unconditional stamping would flip the emittedNs>0 telemetry-latency gates in MainWindow_Session on when telemetry is off.
2 — pan migration no longer force-disables (6edde3c0). loadPrefs now force-disables only on a slice's first load this session (via a set shared across the VFO-flag and RX-applet controls); re-binds preserve the operator's enable state.
3 — NEW, disable now restores the operator baseline (e9f6aef1). The top guard previously dropped state on disable without sending anything, stranding the passband at the last auto-narrowed width — contradicting SliceModel::setAdaptiveFilterEnabled's own contract ("the engine restores the operator's selected filter separately"). It now re-applies baseLow/baseHigh via applyAdaptiveFilter (no user-epoch bump) before resetSlice, gated on ssb so a mode change isn't fought.
What the review verified CLEAN
The OccupiedRegion measurement core has no confirmed bugs (bounds-clamped, provably non-inverted, symmetric USB/LSB, sliding histogram, no FP hazards); threading is single-GUI-thread (no races); the single nested AppSettings["AdaptiveFilter"] key is Principle-V compliant; radio status echoes don't false-trip the epoch self-disable; the get slice snapshot matches the model; slice-removal pruning, control-widget lifetime (QPointer, this-scoped, disconnect-on-rebind), and glide convergence are all sound.
Left for the author (should-fixes / notes — NOT pushed)
- Engine has zero tests. The test target links only
OccupiedRegion; the throttle/epoch/state-machine are unverified — and now that blocker 1 makes the rate gate live, a "≤8 filt/s under a 60 fps frame feed" test is both feasible and the highest-value addition. - VFO flag shows "AUTO" while the RX applet shows a live-gliding number — the applet's
filterChangedhandler needs the sameadaptiveActiveshort-circuit the VFO already has (two-readout-drift class of #794/#1225/#2197). - Latent UB:
std::clamp(reg.lowHz, minLow, kMaxLowCutHz)is UB if operator bounds ever cross the fixed guardrails (corrupt prefs / automation setter) — cheap defensive guard. - Per-slice, not aggregate, filt-rate → N enabled SSB slices on one pan = up to 8·N filt/s; confirm that's the intended reading of RFC #3878 cond. 2.
- Convention: braces-on-all-control-flow (AGENTS.md) is violated pervasively across the four new TUs.
- Non-blocking: per-frame heap allocation + by-value
QVectorhelpers in the hot path; threeAppSettings::save()disk writes per enable toggle; MIDI enabling adaptive on a non-SSB slice (unclearable until mode-switch); preset magic-ints; two misleading test names; and theOccupiedRegionlow-severity items (one-sided fallback-floor bias, theoretical NaN/-Inf bucket). - DSP constants remain first-pass per the RFC — on-air feel is @jensenpat's call.
Genuinely strong feature with a clean, well-tested measurement core. With the three blockers now addressed and the branch rebased, the main things between it and merge are the maintainer's on-air sign-off and (ideally) engine-level tests.
Non-blocking items from the aethersdr#3945 review pass: - RX applet filter readout now shows "AUTO" while the adaptive filter holds a live fit (new refreshFilterWidth(), connected to adaptiveActiveChanged), matching the VFO flag — previously the applet animated a gliding number against the flag's "AUTO" (two-readout drift, aethersdr#794/aethersdr#1225/aethersdr#2197). - Guard the operator-bound clamp against inverted bounds (corrupt pref / out-of-range automation setter): std::clamp with lo > hi is UB. - Guard NaN in FloorHistogram::bucketOf before the int cast (static_cast<int> (NaN) is UB and could index counts[] out of range). - MIDI rx.adaptiveFilter no longer enables adaptive on a non-SSB slice, where it would latch a hidden state the operator can't see or clear. - savePrefs is now idempotent (skip the disk write when unchanged): a single enable toggle fanned out to 3 settings writes across the two host controls; now 1. - median/percentile helpers use nth_element (O(n)) instead of a full sort. - Fix two stale test comments (75th-percentile reference, not median; the Normal presence gate is minPeakDb=9 dB, there is no kMinPeakDb=7). Build clean; adaptive_filter_test passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The controls lived in two widgets (VFO flag + RX applet), which meant keeping the enable/bounds/preset state in sync across both instances. Remove the RX applet copy so there is a single UI host backed by the one model source of truth (SliceModel); the applet keeps its read-only filter-width readout, which still shows "AUTO" while a live fit is applied (refreshFilterWidth). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, + accuracy fixes & opt-in edge-het (#4050) Closes #4049 ## What & why The re-engage restore in the adaptive RX filter is a **live regression in `main`**. It was dormant while `emittedNs == 0`, but the #3945 review added `m_fallbackClock` (a correct fix in itself) which makes `emittedNs` always positive — and that activates `restorable`. On a QSO (two operators alternating on the **same dial frequency** with different TX widths), it reapplies operator A's fit to operator B within the 20 s window: the reported "4 kHz filter on the 3.5 kHz signal and vice versa" swap. See #4049 for the full trace. This PR removes the restore and fixes the handoff positively, plus two adjacent accuracy bugs and an opt-in feature. ## Changes **Remove the re-engage restore (the #4049 swap).** Deletes `lastGood*` / `restorable` / `kRefitMemoryNs`. Its "same frequency within 20 s ⇒ same station" premise is exactly what a QSO violates. **QSO handoff, positively.** A gap counter can't detect the handoff — the per-offset peak-hold envelope (`avgEnv`, ~1.1 s release) rides the PTT gap so the measurement stays valid at A's width across it. But that same peak-hold means the measurement reads *sustainedly narrower* than the applied filter only when the signal genuinely is narrower (a different op); a brief word gap stays wide. So a **sustained-step flush**: when the measured width sits > `kStepMarginHz` inside the target for `kStepFlushFrames` straight, retire the stale peak-hold and pre-load the narrowing confirmation so B fits its own width promptly. **Stops-adjusting.** The low-SNR narrow-freeze now scales to the active presence preset (`minPeakDb + kNarrowFreezeMarginDb`) instead of a fixed 16 dB that sat above every gate and blocked any medium signal from narrowing; and narrowing accrues on direction-consistent frames (`narrowRun`) rather than proximity to a frozen candidate, so QSB jitter no longer resets the confirmation. **Pacing (F2).** The min-spacing-vs-last-arrival gate mapped intermediate fps to an unstable 20–40 fps sawtooth; replaced with a fixed-period phase accumulator so any 40–90 fps source decimates to a stable ~30 fps (the calibration rate). Runs on the timestamp `m_fallbackClock` now guarantees. **F3 — bistable ESSB amputation.** The cut-vs-reanchor pivot was a razor edge, so a ~0.2 dB wiggle in a weak bass lobe flipped the high-cut by the whole treble-hump width. A separate-station CUT now needs both dead-band margins (`kRunConfidentHystDb`, `kSeparateMarginDb`); otherwise re-anchor. **F4 — weak-signal neighbour grab.** An unconfident run bypassed the silence-stop and could grab a station 1.5–2 kHz above a weak carrier. A weak-run re-anchor now requires the inner lobe to be a real sustained lobe and bounds the bridged gap. **Edge-het rejection (opt-in, default off).** A narrow strong interferer within `kHetSearchHz` of a finalised edge (raw ≥ `kHetExcessDb` over the local smoothed envelope) pulls that cut inboard of it — a complement to the notch/ANF for a het sitting right at a passband edge. New per-slice `adaptiveHetReject` toggle (model + "Het" Off/On control + automation snapshot). ## Tests New **`adaptive_engine_test`** drives `AdaptiveFilterEngine::processFrame` through a real headless `SliceModel` with monotonic timestamps — the wall-clock pacing / send-throttle / QSO-handoff path no measurement test could reach, and the gap that let the dead-clock issue ship. Cases: no filt-storm at 60 fps (≤8/s, ≥125 ms spacing), engage tracking, word-gap HOLD (no false flush), QSO A(4k)→1.2 s gap→B(3.5k) corrects to B's width, manual-edit auto-disable. `adaptive_filter_test` gains F3 (bass-at-gate resolves stably, no flip), F4 (narrow blip + far neighbour not grabbed), and edge-het (pulled below an edge het when enabled; mid-band het unmoved) regressions. All green; app builds. Preserves every #3945 review fix underneath (`m_fallbackClock`, nth_element medians, floor-histogram NaN guard, baseline-restore-on-disable, clamp-UB guard, session force-disable, idempotent `savePrefs`). ## On-air status Partial. The QSO swap fix and pacing were validated on the bench (engine test) and briefly on air; a separate **"stops adjusting after 30–60 s" freeze** was seen once and could not be reproduced afterwards. It appears to be a pre-existing measurement effect (the high-edge floor gate rising on a signal-dominated, zoomed-in pan), **not** introduced by these commits and **separate from #4049** — flagging as a follow-up, not a blocker. RFC #3878 DSP-feel sign-off still pending. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>



Summary
Closes #3878.
Client-side adaptive RX filter for SSB: it watches the panadapter, measures the tuned signal's occupied bandwidth, and fits the RX passband to it — within operator bounds, stably (median → peak-hold → dwell) and smoothly (glide) — falling back to the operator's manual filter when there is no confident fit. It is RX-only, suspends while transmitting, and a manual filter edit hands control straight back to the operator.
Controls (SSB only) appear in both the VFO flag and the RX applet via one reusable two-column widget: an enable checkbox, Min low-cut / Max high-cut bounds, and Min SNR / Response / Splatter presets. A green/gray status ball on the panadapter shows AUTO vs idle.
The occupied-bandwidth measurement is a self-contained,
SliceModel-free TU (OccupiedRegion) so it unit-tests againstQt6::Corealone. It handles the four required behaviours plus spectral tilt: weak-signal gate, splatter cap, internal-gap bridging, soft/analog edges, a per-frequency floor curve, and a tilt-robust "connected run" so treble-dominant signals aren't mistaken for a neighbour.Constitution principle honored
filt, never persisted client-side); only the client-side-only enable/bounds/presets are stored.AppSettings["AdaptiveFilter"]key, per slice.Test plan
cmake --build build)adaptive_filter_test(23 assertions: clean / het / QSB-gap / splatter / neighbour / soft / weak / tilted-floor / reference-median / declining-voice / treble-dominant × USB & LSB).Checklist
docs/COMMIT-SIGNING.md)AppSettingscalls — single nested"AdaptiveFilter"key (Principle V)MeterSmoother— N/A (no meter UI)Known follow-ups (not blocking)
🤖 Generated with Claude Code