fix(rx): adaptive RX filter — remove re-engage restore (QSO swap #4049), + accuracy fixes & opt-in edge-het#4050
Conversation
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>
There was a problem hiding this comment.
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/kLowSnrNarrowFreezeDbare gone from both the header and.cpp, and a repo-wide grep confirms no dangling references anywhere (source or tests). Thest.lastGoodLow = INT_MINline in the mode-change reset was removed with the field. - Pacing phase-accumulator (
AdaptiveFilterEngine.cpp:243) is sound: thelastFrameNs += kFramePeriodNsadvance with a re-phase-to-emittedNson 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.lastFrameNscorrectly persists acrossclearFit/flushSmoothing(pacing is source-rate, not fit state). - QSO handoff flush.
flushSmoothingretires the peak-hold windows +avgEnvwhile holdingtgt/curthrough the dead air, then pre-loadsnarrowRun = resp.narrowso B commits its own width on the flush frame. Comparison is width-vs-width (reg.highHz-reg.lowHzaudio Hz vstgtHigh-tgtLowoffset span) — apples to apples.stepRunis reset on invalid frames and insideflushSmoothing, so it can't re-trigger. - Edge-het detector (
OccupiedRegion.cpp:631) is bounds-safe:isHetguardso ∈ [0, scanBins]and clampsbinAt(o)to[0, N-1]; theINT_MAX/0sentinels no-op cleanly when disabled (#include <climits>added forINT_MAX). Opt-in, default off, so existing measurement behavior is bit-for-bit unchanged. - Model/GUI/automation wiring for
adaptiveHetRejectfollows the existing pattern exactly — idempotent setter+signal,QSignalBlockeron load, nested-JSON-under-one-key persistence insavePrefs/loadPrefs(no flat-keyAppSettings), snapshot mirror insliceSnapshot. - Tests are real and substantive.
adaptive_engine_testdrivesprocessFramethrough a headlessSliceModelwith 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
- 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.
- 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
left a comment
There was a problem hiding this comment.
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):
- Re-fires every
kStepFlushFramesfor a valid-but-frozen (low-SNR-band) signal narrower than the filter — churnsavgEnv/refTrailand flickers AUTO; possibly related to your on-air "stops adjusting." - Fires at baseline / during first-fit engage (no
st.active && !atBaselinegate). - 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.
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
left a comment
There was a problem hiding this comment.
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.
Closes #4049
What & why
The re-engage restore in the adaptive RX filter is a live regression in
main. It was dormant whileemittedNs == 0, but the #3945 review addedm_fallbackClock(a correct fix in itself) which makesemittedNsalways positive — and that activatesrestorable. 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 >kStepMarginHzinside the target forkStepFlushFramesstraight, 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_fallbackClocknow 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
kHetSearchHzof a finalised edge (raw ≥kHetExcessDbover 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-sliceadaptiveHetRejecttoggle (model + "Het" Off/On control + automation snapshot).Tests
New
adaptive_engine_testdrivesAdaptiveFilterEngine::processFramethrough a real headlessSliceModelwith 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_testgains 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, idempotentsavePrefs).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