Skip to content

fix(rx): adaptive RX filter — remove re-engage restore (QSO swap #4049), + accuracy fixes & opt-in edge-het#4050

Merged
ten9876 merged 6 commits into
aethersdr:mainfrom
dsocha:fix/adaptive-rx-qso-swap-accuracy
Jul 5, 2026
Merged

fix(rx): adaptive RX filter — remove re-engage restore (QSO swap #4049), + accuracy fixes & opt-in edge-het#4050
ten9876 merged 6 commits into
aethersdr:mainfrom
dsocha:fix/adaptive-rx-qso-swap-accuracy

Conversation

@dsocha

@dsocha dsocha commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

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

dsocha and others added 5 commits July 5, 2026 11:00
Two accuracy fixes and one opt-in feature in the occupied-bandwidth
measurement (measureOccupiedRegion), all in the extent/cap passes:

F3 — bistable ESSB amputation. The cut-vs-reanchor pivot was a single
razor edge (run confident at exactly floor+minPeakDb, plateau at exactly
reference+kReboundDb), 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
requires BOTH the pre-gap run to clear the gate by kRunConfidentHystDb
AND the resuming lobe to exceed the rebound by kSeparateMarginDb (2 dB
dead-bands); otherwise re-anchor (keeping a real signal is the safer
error). Verified stable across a +/-0.3 dB bass perturbation.

F4 — weak-signal neighbour grab. An unconfident run bypassed the 600 Hz
silence-stop and scanned to kScanHz, letting a lobe 1.5-2 kHz above a
weak carrier get re-anchored. A weak-run re-anchor now requires the
inner lobe to be a real sustained lobe (kInnerReanchorMinWidthHz occupied
above kInnerReanchorMinDb over the gate) and bounds the bridged gap
(kUnconfBridgeMaxHz, also used as an unconfident silence-stop).

Edge-het rejection (opt-in, OccupiedRegionParams::hetReject, default
off). A narrow strong interferer within kHetSearchHz of a finalised edge
(raw bin >= kHetExcessDb over the local smoothed envelope) pulls that cut
kHetGuardHz inboard of it, applied after the intelligibility margin so
the margin can't re-cross the het, never past the signal peak. A mid-band
het is left to the notch/ANF. Complements rather than replaces the notch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session-persisted per-slice opt-in for edge-het rejection: getter/setter/
signal/member on SliceModel (client-side only, no wire command — like the
other adaptive knobs), and exposed in the automation get-slice snapshot
alongside the existing adaptive fields. Consumed by the engine and the
control group in the following commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…acing

The re-engage restore is REMOVED. It was dormant while emittedNs was 0,
but the merged aethersdr#3945 review added m_fallbackClock which makes emittedNs
always positive — activating `restorable` and, on a QSO (two operators
alternating on ONE dial frequency), reapplying operator A's fit to
operator B on the same frequency within 20 s. That is the reported
"4 kHz filter on the 3.5 kHz signal and vice versa" swap; it is live in
main. Removing lastGood*/kRefitMemoryNs/restorable retires it.

QSO handoff, positively (P2). A pure 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 itself is narrower (a different
op) — a brief word gap stays wide. So when the measured width sits
> kStepMarginHz inside the target for kStepFlushFrames straight, flush the
stale peak-hold and pre-load the narrowing confirmation so B fits its own
width promptly.

Stops-adjusting (P3). 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 ever
narrowing; and narrowing now accrues on direction-consistent frames
(narrowRun) rather than proximity to a frozen candidate, so QSB jitter no
longer perpetually resets the confirmation.

Pacing (P1b, 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 (kFramePeriodNs) so any 40-90 fps source decimates to a stable
~30 fps — the rate the frame-counted windows are calibrated at. Runs on
the timestamp the review's m_fallbackClock now guarantees.

Het wiring: params.hetReject = slice->adaptiveHetReject().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the edge-het-reject toggle to the reusable AdaptiveFilterControls
(a preset-style Off/On combo), bound to SliceModel::adaptiveHetReject with
live cross-host sync, visibility gating, and per-slice JSON persistence
(loadPrefs/savePrefs), matching the existing Min-SNR / Splatter / Response
controls. Off by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New adaptive_engine_test drives AdaptiveFilterEngine::processFrame through
a real headless SliceModel (signals only, no radio) with monotonic
timestamps — the wall-clock pacing / send-throttle / QSO-handoff path no
measurement test can reach, and the gap that let the dead-clock bug 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.2s gap->B(3.5k) corrects to
B's width, and manual-edit auto-disable.

adaptive_filter_test gains: two-hump F3 (bass-at-gate resolves stably, no
flip) and F4 (narrow blip + far neighbour not grabbed) regressions, and
edge-het rejection (high-cut pulled below an edge het when enabled;
mid-band het leaves edges unmoved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dsocha dsocha requested review from a team as code owners July 5, 2026 09:32

@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 full diff against the checked-out branch. This is careful, well-reasoned work — the root-cause analysis for #4049 is correct and the fix is convincing. All six CI checks are green (build, macos, windows, CodeQL, analyze, a11y).

What I verified

  • Clean removal of the re-engage restore. lastGood* / restorable / kRefitMemoryNs / kMinFrameSpacingNs / kLowSnrNarrowFreezeDb are gone from both the header and .cpp, and a repo-wide grep confirms no dangling references anywhere (source or tests). The st.lastGoodLow = INT_MIN line in the mode-change reset was removed with the field.
  • Pacing phase-accumulator (AdaptiveFilterEngine.cpp:243) is sound: the lastFrameNs += kFramePeriodNs advance with a re-phase-to-emittedNs on stall decimates 60/90 fps and jittery sources to a stable ~30 fps without catch-up bursts, and a native-30 stream passes with jitter headroom. lastFrameNs correctly persists across clearFit/flushSmoothing (pacing is source-rate, not fit state).
  • QSO handoff flush. flushSmoothing retires the peak-hold windows + avgEnv while holding tgt/cur through the dead air, then pre-loads narrowRun = resp.narrow so B commits its own width on the flush frame. Comparison is width-vs-width (reg.highHz-reg.lowHz audio Hz vs tgtHigh-tgtLow offset span) — apples to apples. stepRun is reset on invalid frames and inside flushSmoothing, so it can't re-trigger.
  • Edge-het detector (OccupiedRegion.cpp:631) is bounds-safe: isHet guards o ∈ [0, scanBins] and clamps binAt(o) to [0, N-1]; the INT_MAX/0 sentinels no-op cleanly when disabled (#include <climits> added for INT_MAX). Opt-in, default off, so existing measurement behavior is bit-for-bit unchanged.
  • Model/GUI/automation wiring for adaptiveHetReject follows the existing pattern exactly — idempotent setter+signal, QSignalBlocker on load, nested-JSON-under-one-key persistence in savePrefs/loadPrefs (no flat-key AppSettings), snapshot mirror in sliceSnapshot.
  • Tests are real and substantive. adaptive_engine_test drives processFrame through a headless SliceModel with monotonic timestamps and directly locks the regression: case 4 asserts B (3.5 k) does not inherit A's 4 k width across a 1.2 s gap, case 1 asserts ≤8 filt/s + ≥125 ms spacing, case 3 asserts a 0.5 s word gap does not collapse the fit.

Non-blocking notes

  1. The "stops adjusting after 30–60 s" freeze you saw once on air and couldn't reproduce — I agree from the code that it's not introduced here (nothing in this diff touches the high-edge floor gate that way). Worth filing as a standalone tracking issue rather than leaving it only in this PR description, so it isn't lost once this merges.
  2. Scope. This bundles five somewhat independent changes (restore removal, stops-adjusting scaling, pacing rewrite, F3/F4 accuracy, and the new edge-het feature). The #4049 fix is the load-bearing one and is thoroughly covered; the edge-het opt-in feature is the most separable and, being new surface area, is the part with the least on-air validation. No change requested — just flagging that if you'd prefer to de-risk the merge, the edge-het toggle is the clean thing to split out.

Nice work, and thanks for the unusually clear write-up tying each constant to the failure mode it addresses.


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

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: comment (fix the flush gate before merge)

Multi-agent + adversarial-verify pass. The #4049 root cause and fix direction are correct, all #3945 review fixes are preserved, and everything structural is clean — but the replacement mechanism (the sustained-step flush) has a re-fire/gating defect the new test doesn't reach.

Correctness — the new flush (inline #1):

  1. Re-fires every kStepFlushFrames for a valid-but-frozen (low-SNR-band) signal narrower than the filter — churns avgEnv/refTrail and flickers AUTO; possibly related to your on-air "stops adjusting."
  2. Fires at baseline / during first-fit engage (no st.active && !atBaseline gate).
  3. A long pause (> the ~1.1 s envelope release, but the flush is 0.67 s) can briefly pinch the same op.
    One gate — st.active && !atBaselineNow && !commitFrozen — closes all three (suggestion inline).

DSP tuning (PLAUSIBLE, for the pending on-air sign-off): F3 relocates the razor edge ~2 dB rather than eliminating it; F4 can under-fit a thin-bass quiet ESSB. Note F3/F4 change measurement for all users (not gated), unlike the correctly opt-in het.

Verified clean ✅ het-off is bit-for-bit unchanged; the pacing phase-accumulator has no drift/burst; USB/LSB width math is symmetric; narrowRun, the glide/send-throttle (≤8 filt/s cap), and the feature removal (zero dangling refs) hold; every #3945 fix preserved; adaptiveHetReject wiring is symmetric and genuinely default-off; CMake links complete; and the tests are genuine — case 4 truly locks the #4049 regression (asserts B ≤3700, not A's 4 k). One latent, pre-existing edge (not introduced here): emittedNs mixes the real telemetry clock and m_fallbackClock, so toggling perf-logging mid-session could stall pacing on a backward jump.

Strong PR — the removal + positive handoff is right and the regression test proves it. Recommend landing the flush gate (#1#3) before merge; the DSP items are for the on-air pass. Happy to push the gate + a freeze-band test case if you'd like.

Comment thread src/core/AdaptiveFilterEngine.cpp
Comment thread src/core/OccupiedRegion.cpp
Comment thread src/core/OccupiedRegion.cpp
Comment thread tests/adaptive_engine_test.cpp Outdated
The between-overs flush (aethersdr#4050 review) had only a tgtWidthNow>0 guard, so it
re-fired every kStepFlushFrames whenever the measured width sat inside the
target but the target couldn't follow:

- A signal in the low-SNR band (peak-floor in [minPeakDb, minPeakDb+3)) is
  valid — so stepRun accrues to the flush threshold — yet frozen from
  committing narrower (the lowSnr widen-only branch). tgt never moves, so the
  flush re-fires ~every 0.67 s, each time wiping avgEnv/medians/refTrail: the
  fade detector never arms (needs 20 samples) and confScore/AUTO flicker.
  Reachable with any 9-12 dB signal narrower than the applied filter.
- With a wide manual filter, stepRun also accrued during the first-fit engage
  and could wipe dwell/cand mid-engage, stalling the fit.

Gate the accrual on st.active && !atBaseline && !commitFrozen. Once a
non-frozen narrowing commits, tgt moves and stepRun stops re-accruing, so no
separate latch is needed. The genuine QSO handoff (active, non-baseline, strong
non-frozen signal) still flushes — the case-4 regression test confirms B fits
its own 3.5k width, not A's 4k.

Also tighten the anti-storm test assertion so the <=8/s rate lock can't pass
vacuously on a run that stops sending (require >= 2 sends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: approve

Approving. A multi-agent + adversarial-verify pass confirmed the #4049 root-cause and fix are correct, and the one real defect the review surfaced — the sustained-step flush re-firing in the low-SNR freeze band / firing at baseline — is now fixed on the branch (7aaf6fe0): the accrual is gated on st.active && !atBaselineNow && !commitFrozen, and the case-4 QSO-handoff regression test confirms B still fits its own 3.5k width (not A's 4k) while the pathological re-fire is suppressed. Both test suites pass, build clean.

Verified clean: the re-engage-restore removal (zero dangling refs); the pacing phase-accumulator (no drift/burst); USB/LSB width math; narrowRun and the glide/send-throttle (≤8 filt/s cap intact); every #3945 review fix preserved; adaptiveHetReject wiring symmetric and genuinely default-off; het-off measurement bit-for-bit unchanged; CMake links the new adaptive_engine_test completely; and the tests are genuine (case 4 truly locks the regression). The anti-storm assertion was also tightened so it can't pass vacuously.

Left for the on-air DSP-feel pass (not blocking, per RFC #3878): F3 relocates the razor edge ~2 dB rather than eliminating it, and F4 can under-fit a thin-bass quiet ESSB — both change measurement for all users (unlike the opt-in het), so worth watching on air. Also the pre-existing, latent telemetry-clock-origin edge (toggling perf-logging mid-session) noted for a future cleanup. A freeze-band unit test is a good follow-up (the re-fire is internal churn that's hard to surface deterministically).

Solid work tying each constant to its failure mode. LGTM.

@ten9876 ten9876 enabled auto-merge (squash) July 5, 2026 16:08
@ten9876 ten9876 merged commit 0e655f6 into aethersdr:main Jul 5, 2026
6 checks passed
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.

Adaptive RX filter: previous operator's width persists across a same-frequency QSO handoff (re-engage restore activated by #3945 m_fallbackClock)

2 participants