Repository navigation
fix(hl2): run the CW RX bandpass at 4096 taps until a notch needs 8192 — 43 ms sooner. Principle VIII. - #5981
Conversation
…ly for a notch or a sub-100 Hz filter, so CW audio arrives 42.7 ms sooner. aethersdr#5578 aethersdr#5954 took the RX bandpass to minimum phase outside CW; CW keeps linear phase by ruling, so there the 8192-tap FIR still delays by (taps-1)/2. Hl2RxDsp::rxFilterTapsFor() now picks the length from mode, passband width and notch count: - outside CW: 8192, unchanged (minimum phase: 44.19 ms onset at 8192 vs 43.92 ms at 2048 -- nothing to buy); - CW with any notch, or a passband under 100 Hz: 8192; - otherwise CW: 4096, with 20 Hz of hysteresis on the way down. Why 4096 and not the 2048 aethersdr#5678 row 4.1 proposed: measured through a real WdspChannel, 2048 taps leave a signal 50 Hz outside the CW filter edge at -33 dB (8192: -114 dB, 4096: -109 dB), at every width. The length follows setMode, setFilter, addNotch (raised BEFORE WDSP sees the notch), removeNotch, the enable that closes a seed, and installChannel, via WdspChannel::setFilterTaps (aethersdr#5878). clearNotches deliberately does not lower it, so a reseed does not refill twice. Measured: CW onset 128.00 -> 85.33 ms; unkey return 128 -> 86 ms. Live 4096 -> 8192 switch: no NaN, no spike, level unchanged, 155 ms refill. Notch across the switch -103.4 dB vs -103.5 dB on an already-long chain. New hl2_rxdsp_adaptive_taps_test; hl2_rxdsp_unmute_return_test's CW margin follows the new length. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Issue fit
Yes, with one substitution the maintainer should sign off on. #5578 asks for the RX bandpass to stop paying 8192 taps unconditionally and to buy the long length only while a notch exists. The triage comment on that issue listed three things an implementation has to get right, and I checked each against the diff:
| #5578 triage item | Status |
|---|---|
| 1. "the switch is not free… whether repeated add/remove should hysteresis rather than thrash" | Partly. Hysteresis exists for the passband width (kRxTapsHysteresisHz), not for the notch transition — see nit 2 |
| 2. "taps must go long before the notch is added" | ✅ Hl2RxDsp.cpp:609-610, before m_channel->addNotch(), and before installChannel()'s replay |
3. "kMinNotchWidthHz should be derived from the long-length constant, capability pinned" |
✅ kRxFilterTaps stays the long length, kMinNotchWidthHz still derives from it, Hl2Backend.cpp:2502 still advertises 50 Hz. I grepped for a runtime consumer of WdspChannel::minimumNotchWidthHz() that could see the 100 Hz short-state floor — there is none outside the new test, so the divergence the triage worried about is not reachable in production |
The substitution: #5578's letter proposes 2048 taps / 200 Hz floor. This PR ships 4096, and the test carries a negative control (tests/hl2_rxdsp_adaptive_taps_test.cpp:390-395) asserting 2048 realises no CW width because its skirt 50 Hz outside the edge is ~−33 dB. That is the right way to refuse a proposal, but it does mean the issue's stated fix is not what landed — worth a maintainer nod rather than an implicit close. The commit message references #5578 without a closing keyword, which reads correctly to me.
Also correct in my reading: leaving every non-CW mode long. #5954 already took those to minimum phase, and on8st's own corrected measurements in the #5578 thread (11.95 ms at 8192 minimum phase) support the header's claim that length buys nothing there.
Scope
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/backends/hl2/Hl2RxDsp.h |
rxFilterTapsFor() policy + 3 constants + two read-back accessors + comment rewrites |
Yes | In scope |
src/core/backends/hl2/Hl2RxDsp.cpp |
tapsForBlock(), applyFilterTaps(), five call sites, armMeterSettle() comment |
Yes | In scope |
tests/hl2_rxdsp_adaptive_taps_test.cpp |
New test (583 lines) | Yes | In scope |
tests/hl2_rxdsp_unmute_return_test.cpp |
Threshold >60 ms → >30 ms + comment |
Implied, not spelled out | In scope — but it is a loosened assertion, see nit 5 |
tests/tests.cmake |
Registers the new target | Yes | In scope |
Everything in the diff is explained by the issue. No new protocol verb, wire message, settings key, capability field or CLI flag — notchMinWidthHz is unchanged at 50 Hz, and the two new accessors are read-only test surface on an internal class. No CHANGELOG.md entry (correct). No UI default moved, so §6 does not bite: this is a fix against an open, maintainer-review-labelled issue with measured evidence, not a preference.
I read the - lines for removed guards. The only deletions are the two comment blocks and the one relaxed test threshold; wc.filterTaps = kRxFilterTaps is replaced, not dropped. Nothing that names a symptom was removed.
Socket tests: none. The new test is socket-free (no QTcpServer/QUdpSocket/bind/listen/connectToHost, no Fake* peer) — it drives a real WdspChannel and a real Hl2RxDsp in-process. Correct layer per AGENTS.md's test-layer boundary (DSP → socket-free CTest).
Blockers
None.
Nits (all non-blocking)
addNotch()proceeds when the length raise fails —Hl2RxDsp.cpp:609-611. Inline comment below. The header's claim that a widened notch "never exists at all" holds on the success path only.- No hysteresis on the notch transition, which is exactly what #5578's triage item 1 asked to be weighed. Toggling the last notch on and off repeatedly costs a channel stop + six FIR re-plans under the process-global FFTW lock + one filter refill each way. The
applyFilterTaps()comment argues each change is "at a moment they caused", which is a defensible answer — it just isn't stated against that ask. One line in the body would close it. - The
installChannel()raise is not pinned by any check — inline comment atHl2RxDsp.cpp:340. - 1.0 ms tolerance on the onset delta — inline at
hl2_rxdsp_adaptive_taps_test.cpp:400. - The relaxed
unmute_returnthreshold is still mutation-resistant, and worth saying so in the body. I walked it: if minimum phase were dropped outside CW,usbReturngoes to ~128 ms andcwReturn - usbReturngoes negative, so> 30.0still fails. TheusbReturn < 80.0check is untouched. The loosening is honest, but a reader of the diff can't tell that without doing the walk. - The rate-change path silently discards the width hysteresis.
buildChannel()callsrxFilterTapsFor(..., 0)withcurrentTapsdefaulted to 0, soshortening && currentTaps > 0is false and the 20 Hz margin is skipped. A CW passband of 110 Hz that hysteresis was holding at 8192 opens at 4096 after a sample-rate change, andinstallChannel()'sapplyFilterTaps(nowcurrent == 4096) keeps it there. Both lengths are legal at 110 Hz by the policy, so this is cosmetic — but the length at identical operator settings differs before and after a rate change. - The body should quote the measured centre loss at the threshold width.
kGainTolDbis 0.5 dB and the header records 0.4 dB of centre loss at a 50 Hz passband. The first width the policy does hand to 4096 is 100 Hz, and its margin against that 0.5 dB gate is not stated anywhere — the printed table has it, but it should be in the record alongside the −114/−109/−33 dB skirt figures. - Runtime.
magnitude()is an O(bins × taps) naive DFT — 3001 bins over ~24k-sample impulse responses, 24 times, plus 5 onset runs and 4 liveHl2RxDsplegs. Worth stating the wall-clock figure; no HL2 test appears in anyctest -Rfilter inci.yml, so whoever runsctest -R hl2locally is the only one who will find out.
What I tried to break
tapsForBlock()'s divisibility claim.filterTapsArePartitionable()needstaps % dspBlockSize == 0, not merelytaps >= dspBlockSize, so the loop is only safe if the block is a power of two. Traced it:Hl2RxDsp.cpp:149computeswc.dspBlockSize = 1024 * 48000 / inputRate, which is 1024/512/256/128 at the HL2's 48/96/192/384 ksps. The comment's "at most 1024 and this never fires" holds, andcreate()would have refused 8192 on a non-power-of-two block before this PR too. No new failure.- A path that leaves a notch on the short filter. Every call site passes the live
m_notches.size(), andrxFilterTapsForreturnskRxFilterTapsunconditionally fornotchCount > 0.editNotch()is the one notch verb with no raise, and it is unreachable with an empty set (bounds-checked first), so the length is always already long.buildChannel()hardcodes 0, butinstallChannel()raises before the replay and nothing is processed between them (same thread, synchronous).clearNotches()deliberately holds the long length; I confirmedHl2Backend::seedNotches()(Hl2Backend.cpp:3969-3995) is its only caller and that it always ends insetNotchesEnabled, so the "stranded long filter" the comment reasons about can't happen on that path. - Hysteresis arithmetic, all four table rows. At
currentTaps == kShorttheshorteningflag is false soneededis 100 and width 100 stays short; fromkLongit is 120, so 100 stays long and 120 shortens; at width 99 even from short it returns long. The test's four assertions match. ThehighHz >= lowHzguard makes the negative-sideband CWL passband work. - Bounds ordering in the notch verbs.
addNotchandremoveNotchboth range-check before touching the length, so an out-of-range index can't re-plan the filter for a notch that never lands. - Whether the new test could pass against unfixed code. It can't: section 2 references
rxFilterTapsFor(doesn't exist on main) and section 3 assertsrxFilterTapsInForce() == kShortright afterconfigure(). Theno2048negative control also makesrealises()provably falsifiable, which is what stops the honesty loop from being a tautology — though note that for every width the policy hands tokLong,realises()comparesshapes[w][2]against itself and the only live assertion is the stopband, duplicating thelongOkcontrol. The real content is the short-picked widths. - Whether
armMeterSettle()needed more than a comment.MeterTap::arm()takes only the rates and the block, so the settle window is independent of tap length; halving the CW filter makes the existing window conservative, not wrong. Comment-only was the right change. - The FFTW-lock exposure on the EP2-pacing thread.
setFilterTaps()holdsg_setupMutexfor the six FIR re-plans (1.8–28.8 ms measured, perWdspChannel.cpp), andHl2RxDsplives on the I/O thread that paces EP2. SosetFilter()— a filter drag — can now stall that thread where before it was a cheapSetRXABandpassFreqs. I did not raise this as a finding becausesetMode()→applyMinimumPhaseForMode()→setMinimumPhase()already pays exactly the same cost on exactly that thread today, and the pre-stop trick insetFilterTaps()keeps the 155–227 msnanosleepout of the locked region. Worth a maintainer eye if EP2 jitter is ever traced back to filter drags. - Reentrancy during
installChannel().applyFilterTaps()runs withm_installingtrue, so if a further background build holdsg_setupMutex,setFilterTaps()blocks the DSP thread on it. Same class of exposure as thesetMode/setFilter/setAgc/addNotchcalls already in that function, so not new — but it does add a longer lock acquisition to that list. - Regression risk in the sibling test I expected to break.
hl2_rxdsp_unmute_staleness_test.cppprints and sizes its detector fromkRxFilterTapsand predicts a 170.667 ms frozen history. It configuresMode::Usb, 150–3000 Hz (line 457), so it still runs at 8192 and needs no change. Its header block explicitly says it does not assert against the constant.
Everything above is reasoned from the head checkout at /tmp/aetherclaude/pr-5981, not reproduced at runtime. I have no build and no radio, so I could not run the new test, could not check the measured figures in the header (−114/−109/−33 dB, 128.00/85.33 ms, 44.19/43.92 ms) against a real WdspChannel, and could not verify the 0.4 dB centre loss at 50 Hz. Those are the load-bearing numbers in the design and they are self-asserted by the test that also prints them; the negative control is what makes that arrangement trustworthy rather than circular. CI is green on e610bb5 (build, check-macos, check-windows, Static checks, sanitizer-configures) — but no ctest -R step in ci.yml selects any hl2_* target, so neither this test nor the one it amends gated that green.
Recommendation
Approve with nits. The mechanism is right, the ordering hazard #5578's triage flagged is handled in the one place it matters, and the 2048→4096 deviation is refused with a control rather than an assertion. Nit 1 is the only one with a real (if narrow) failure mode; nits 3, 4 and 5 are about the test being able to prove what it claims. The one thing I'd route to a maintainer is the substitution itself: #5578 asked for 2048, this ships 4096 on measured grounds, and that deserves an explicit yes before the issue is treated as closed. Next step: address nit 1, add the rebuild-with-moved-mode leg from nit 3, and state the onset-delta and runtime figures in the body.
Nice work on the negative control — "2048 realises no CW width, so the criterion can fail" is the check that makes the rest of section 1 mean something, and it is the part most PRs leave out.
🤖 aethersdr-agent · cost: $9.1371 · model: claude-opus-5
…it, and pin the rebuild's length re-apply. Review of aethersdr#5981. - applyFilterTaps() returns whether the wanted length is in force. addNotch() refuses the notch (WDSP and mirror both untouched, logged) when the raise to 8192 fails, instead of landing it on the 4096 filter where WDSP would silently widen it to 100 Hz. The next edit retries. Test hook setRefuseFilterTapsChangesForTest() forces the refusal. - Two rebuild legs where the mode moves mid-build (CW->USB, USB->CW) with no notches, so only installChannel()'s applyFilterTaps() sets the length. - The CW latency delta is asserted on the impulse response's centre of energy (exactly the group delay of a symmetric FIR: measured 2048.00 samples); the half-amplitude onset delta keeps a 3 ms bound. Mutation-checked: deleting the install line fails both rebuild legs; unchecking the raise fails the refused-raise leg. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The listening test from the "Not verified" line is now done, on the air, receive only.
I listened for ringing or a hollow tail on the dits at the narrow widths, for clicks when the width changes, and for fast CW smearing. It was clean at all three widths. What this does not cover:
73, ON8ST |
|
We're updating this branch against main and landing it (maintainer pass); please don't push to it meanwhile. New commits go on top, with no rebase or force-push. |
Conflicts were comment condensation from aethersdr#6072 against this branch's comment edits, the private-declaration block (applyFilterTaps beside aethersdr#5982's pushSquelchToChannel) and adjacent tests.cmake registrations (hl2_rxdsp_adaptive_taps_test beside wdsp_minphase_workspace_test). Kept both sides; the addNotch raise guard is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…condense the comments. Principle VIII. A rebuilt chain opened at the short length for a passband the old chain held long by hysteresis (a 100-120 Hz CW filter narrowed from below), so the length at identical settings differed before and after a rate change. installChannel() now measures the hysteresis from the outgoing chain's length. New leg in hl2_rxdsp_adaptive_taps_test; passing 0 instead turns it red. The comments this branch added are condensed to AGENTS.md § Comments; the measurements they carried are asserted and printed by the test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Brings in aethersdr#6019, which adds a test registration to tests/tests.cmake; no conflict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
ten9876
left a comment
There was a problem hiding this comment.
Approving at 499a7c26 (maintainer landing pass). The 4096-tap substitution was accepted by maintainer ruling on 2026-10-02 (comment above); #5578 closes on 4096.
Branch update (Wave 3, maintainer decision): merged main into the branch twice, with no rebase or force-push. The first merge (a3ea493c) had conflicts from #6072's comment condensation against this branch's comment edits, from the private declarations beside #5982's pushSquelchToChannel(), and from adjacent tests.cmake registrations (beside wdsp_minphase_workspace_test). Both sides were kept, and the addNotch() raise guard is unchanged. The second merge (499a7c26) brought in #6019 (another tests.cmake registration) cleanly.
Review findings (aethersdr-agent, 2026-09-27):
- Unchecked raise in
addNotch(): fixed by on8st inbf9fe0a5. Verified by mutation in this pass: an unchecked raise turns both refused-raise checks red. - No hysteresis on the notch transition: by design. The body now states the rationale: one refill each way, only at a moment the operator caused.
- The
installChannel()re-apply was unpinned: on8st added rebuild legs in both directions inbf9fe0a5. Deleting the line turns them red (re-verified in this pass). - 1 ms onset tolerance: on8st moved the exact assertion to the impulse centre of energy. It measures 2048.00 samples here on Linux, the same as on8st's macOS run.
- The relaxed
unmute_returnthreshold is still mutation-resistant. The body now says why. Re-verified: with minimum phase dropped outside CW, all three ruling/fix checks fail. - The rate-change path discarded the width hysteresis: fixed in
d1c2017e.installChannel()measures the hysteresis from the outgoing chain's length, and a new leg pins it (passing 0 turns it red). - Centre loss at the threshold width: now in the body (−0.00 dB at 100 Hz/4096, against 0.40 dB at 50 Hz).
- Runtime: about 10.5 s wall clock, stated in the body.
The comments this branch added are condensed to AGENTS.md § Comments (d1c2017e). The measurements stay in the test and the body.
g_setupMutex on hl2-io: confirmed that nothing widened it. Main's changes to WdspChannel::setFilterTaps() since the merge base are comment-only. The pre-stop is still outside the lock, and the lock covers RXASetNC alone. #5965's workspace free doesn't touch that path.
Verified empirically (Arch Linux, RelWithDebInfo, at 499a7c26): full build. Non-socket ctest 544/545; the one failure is connection_panel_size_test, which fails locally (#5811). Socket-owning tests were excluded per operator direction. ctest -R 'hl2|wdsp' passes 72/72. Static gates (engine boundary, touchpoint manifest, capability records, command plane, test registration, CI test gate, qsettings, network timeouts) all pass, with findings identical to main's. Reasoned, not driven: the on-air behaviour, which rests on on8st's receive-only listening test at bf9fe0a5 (40 m CW, 500/250/100 Hz, clean). No live radio was used in this pass, and nothing was transmitted.
Green freshness: CI and Static Checks on 499a7c26 were created at 2026-10-03T01:09:17Z. Main's tip is 75d00ae26 (#6019, 00:58:47Z), which is older, so the green covers current main.
Brings in #5981 and #6012 (HL2 CW RX bandpass, RIT/XIT registers), which touch Hl2Backend.cpp/.h. Merged clean; neither changes keying, drive or TUNE. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Adjacent conflicts, both sides kept: the APF/AGC-off receiver fields, pushes and re-applies beside aethersdr#5982's squelch and aethersdr#5980's pan averaging in Hl2Backend and Hl2RxDsp; the APF Config fields and open() re-push beside the squelch in WdspChannel; Hl2RxDsp::setMode now applies the filter length (aethersdr#5981) and then the APF; test registrations beside hl2_rxdsp_squelch_test. Comment hunks take main's condensed text. SliceModel::setAgcOffLevel takes main's version (aethersdr#5907 routes the off level through receiveAgcRequested / requestSliceAgc); the next commit moves the HL2 off-level route onto it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
…6050) ## Summary On a Hermes-Lite 2, two receive controls had no route to the DSP that runs on this host: - **The AGC-T slider with AGC Off** moved `SliceModel::agcOffLevel`, and the HL2 backend ignored the request, so every HL2 ran AGC Off at WDSP's channel default of 10 dB (`WdspChannel::Config::agcFixedGainDb`) whatever the slider said. - **The CW audio peaking filter (APF)** was Flex wire text only (`apf=` / `apf_level=`). That is why HL2 declared `hasAudioPeakingFilter = false` and the APF row stayed hidden, although WDSP builds a peaking-filter stage into every receive channel. ## Seam - **AGC-off level: main's typed route.** `SliceModel::setAgcOffLevel` emits `receiveAgcRequested(SliceAgcRequest::Field::OffLevel)` → `IRadioBackend::requestSliceAgc` (#5907). `Hl2Backend` now overrides `requestSliceAgc`: it handles `Field::OffLevel` itself and passes every other field to `IRadioBackend::requestSliceAgc` (the paired `setSliceAgc`). No new verb. - **APF: one new seam verb, interim.** `IRadioBackend::setSliceApf(sliceId, on, level)` is a defaulted no-op. `SliceModel` emits `apfCommandIssued(on, level)` from the operator setters only, never from status application (Principle II), and `RadioModel::wireBackendReceiverState()` routes it next to the squelch route. **It folds into #5919's `SliceDspRequest::Feature::Apf` when #5919 lands.** - **Flex is unchanged.** It overrides neither verb, its `apf=`/`apf_level=` text from SliceModel is byte-identical, and its `agc_off_level=` text comes from `FlexBackend::requestSliceAgc` as on main. Icom, RTL, Sim and ANAN keep the base no-op and still declare `hasAudioPeakingFilter = false`. - The `agcOffLevel` branch in `SliceModel::applyChanges()` gets the same unchanged-value guard as the threshold beside it. HL2 now echoes the off level on every slice-state publish, and `AgcCalibrationDialog` wires `agcOffLevelChanged` to the calibrator. **Maintainer decisions (KK7GWY, 2026-10-02):** (1) the AGC-off level uses main's `requestSliceAgc` route, not a parallel verb; (2) `setSliceApf` is accepted as an interim verb until #5919's request type replaces it. ## HL2 - **AGC-off level → `SetRXAAGCFixed`**, via a new `WdspChannel::setAgcFixedGain()`. The mapping is `gain_dB = 10 + 0.6·(level − 10)`, so the range is 4..64 dB. **The default level 10 gives exactly 10.0 dB**, so AGC Off sounds the same until the operator moves the slider. The slope matches the AGC-T threshold's 0.6 dB/unit. WDSP applies the fixed gain only in AGC mode 0, so the value is pushed in every mode and takes effect when AGC is switched off. - **APF → WDSP SPCW** (`apfshadow`: `SetRXASPCWSelection/Run/Freq/Bandwidth/Gain`, declared in `aether_wdsp.h`), via `WdspChannel::setApf()`: - The filter is centred on the CW pitch in audio Hz, and it follows a pitch change on every receiver, whether or not it is in CW at the time. - Level 0..100 sets the bandwidth, because the control is labelled "APF bandwidth" / "Higher values narrow the peak". The map is 200 Hz → 50 Hz, geometric. The default of 50 gives **100 Hz at gain 2.0, which is RXA.c's own construction design**. - The stage runs only in CWL/CWU. The request is kept through a trip to SSB and comes back with CW. - `open()` selects the double-pole explicitly. An unchanged `setApf()` returns before the control handshake and the FFTW planner lock. - While it runs, both audio outputs derive from I (WDSP's mode-2 double-pole copies I into Q). - `hasAudioPeakingFilter` is now `true` for HL2. The caps map row says the host runs it. - Both settings are held per receiver outside `Config` and applied again on channel rebuild, reconnect and new panadapter, the same way the noise blanker is. - Both appear in `dsp.chains` as what WDSP last **accepted** (`apfRun`, `apfCenterHz`, `apfBandwidthHz`, `apfGain`, `agcFixedGainDb`). - **Not persisted, on purpose:** like the host noise blanker. After a reconnect the off level is 10 again and the APF is off. ## Tests Both new tests are socket-free and registered in `tests/tests.cmake`. - **`slice_apf_agc_off_seam_test`** (a recording backend installed through `RadioModel`'s production receiver bindings): - Flex's APF wire text is byte-identical. - The operator setters emit the APF intent with enable and level together. - `RadioModel` routes the APF to `setSliceApf` and the off level to `requestSliceAgc(Field::OffLevel)` for the right slice, including through the AGC-T knob and the AGC-T calibrator's sweep and restore. - Status application never re-emits a command, and an echo at an unchanged off level notifies nothing. - The base verbs emit no signal, and the base drops `OffLevel` rather than calling `setSliceAgc`. - **`hl2_apf_agc_off_test`** (a real `Hl2RxDsp` and WDSP channel, measured audio): 1. the level maps; 2. refusals, and an unchanged `setApf()` that does not wait on the planner lock; 3. AGC Off: 50 units is 30 dB of audio, and with AGC on the off level changes nothing; 4. APF in CWU lifts the pitch over a tone 200 Hz away by more than 10 dB, outside CW it is held and inert, and CWL is centred on the pitch; 5. both survive a rebuild; 6. through `Hl2Backend`: `setSliceApf` / `requestSliceAgc(OffLevel)` → WDSP → published slice state and `dsp.chains`. A pitch set outside CW is the centre on entering CW, and a `Mode` request still reaches the chain's AGC and leaves the off level alone. - **Mutation-checked** (each goes red, and green again when restored): - sending the APF only to receivers already in CW; - `Hl2Backend::requestSliceAgc` not handling `OffLevel`; - not delegating other fields to the base. **Results at the head (Linux, RelWithDebInfo):** - Full build. Non-socket ctest 549/550 (including #6049's `reroute_dead_controls_test`); the one failure is `connection_panel_size_test`, which fails locally (#5811). - Static gates: engine boundary, touchpoint manifest, capability records (frozen count unchanged), command plane, test registration and the CI test gate all pass, with findings identical to main's. - on8st's earlier run: 556/556 on macOS. ### Hardware evidence (receive only) Hermes-Lite 2 (gateware 74) with the antenna switch on the dummy load, read through the automation bridge with TX blocked. This run was at an earlier head, when the AGC-off level still used its own verb. The HL2/WDSP half is the same code, and the route change is covered by the tests above. | # | Condition | Expected | Observed | Result | |---|---|---|---|---| | A1 | AGC Off, off level 10 | fixed gain 10 dB | 10 dB | PASS | | A2 | AGC Off, off level 40 | 28 dB | 28 dB | PASS | | A3 | AGC Off, off level 100 | 64 dB | 64 dB | PASS | | P1–P3 | APF not requested (USB / CW / CWL) | not running | `apfRun` false | PASS | | P4 | APF on, CW | running at CW pitch | `apfRun` true, 600 Hz (pitch 600), 100 Hz, gain 2 | PASS | | P5 | APF on, CWL | running at CW pitch | `apfRun` true, 600 Hz, 100 Hz, gain 2 | PASS | | P6 | APF requested, USB | held, not running | `apfRun` false, model still on | PASS | | P7 | back to CW | running again | `apfRun` true, 600 Hz | PASS | ## Not covered - No listening check of the APF on hardware. The audible effect is covered by the offline measurements (sections 3 and 4). - The APF level slider was not swept on hardware. - No transmit path is touched, and nothing was transmitted. ## Update against main Merged `main` into this branch (no rebase). The conflicts were adjacent additions next to #5982's squelch, #5980's pan averaging and #5981's filter length in `Hl2Backend`, `Hl2RxDsp` and `WdspChannel`, plus test registrations; both sides were kept. `SliceModel::setAgcOffLevel` took main's version, and the following commit moved the HL2 route onto it (decision 1 above). Two later merges brought in #6013 and #6049 cleanly; #6049's band-stack AGC recall reaches `requestSliceAgc`, which `Hl2Backend` passes to the base for every field but `OffLevel`. Refs #5262, #5917. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
…ures. Neither file existed at this branch's previous base, so neither conflicted and neither appeared in the rebase: hl2_rxdsp_adaptive_taps_test arrived with aethersdr#5981 and reroute_dead_controls_test with aethersdr#6049, and both call signatures this branch widens. hl2_rxdsp_adaptive_taps_test: four buildChannel(cfg, false, 0) calls become (cfg, NoiseBlanker::Off, 0, NoiseBlankerFill::Zero). These cases assert filter tap lengths across a rebuild and never engage a blanker, so off with a zero fill is what they already had. reroute_dead_controls_test: the fake backend's setSliceNoiseBlanker override takes the kind and fill, and records kind != Off as the bool its rows already assert — the same reading a radio with one blanker gives the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ciple VI. (#6104) ## Summary On HL2 and Icom, a TUNE power change made while TUNE is keyed now reaches the carrier. Before this, the value waited for the next key-down, and the operator saw a "dropped" notice for a control that looks live. This PR also fixes two TX-power defects in the same paths: - **HL2 transmit tail.** At TUNE release the RF drive was restored right after MOX-off, but the HL2 keeps radiating for its FIFO drain plus PTT hang after the MOX-off frame. The restore landed under the still-draining full-scale tune tone. The drive restore and the receive filter switch now wait out that tail. (Found by ON8ST.) - **Icom restore.** An RF power change made during TUNE was written over the tune drive and then lost at the unkey. ## What changed **Seam and capability** - `IRadioBackend::setTunePower(int)` is a new verb with a default no-op, next to `setTxPower`. It never keys. A backend that generates its own carrier re-applies drive to a carrier in progress and otherwise does nothing; the next `setTune()` carries the value. Owner-thread contract as for `setTxPower`. - `RadioCapabilities::TransmitDriveControl::tunePowerAppliesLive` defaults to `false`. It is a field inside the existing record, not a top-level bool, so `FROZEN_BOOL_COUNT` stays at 71 (`check_capability_records.py --strict` reports 71 on both base and head). The contract text in `IRadioBackend.h` and `docs/architecture/radio-capabilities-map.md` are updated to match. **Models** - New signal `TransmitModel::tunePowerCommandIssued(int)`. Only `setTunePower()` emits it, after `commandReady`, mirroring `rfPowerCommandIssued`. `applyChanges()` (status decode) never emits it. - `RadioModel` connects to it in the constructor's model-lifetime block, next to `rfPowerCommandIssued` (#4599). It forwards a change only when all of these hold: - `isTuning()` is true - a live `TxActivity::Tune` contribution exists - the backend declares `tunePowerAppliesLive` - The connect checks `tunePowerAppliesLive` before calling `setTunePower`. The behaviour is the same for every real backend, because the base verb is a no-op. The check makes the contract explicit: the field promises the verb is real, and a backend that doesn't declare it is never handed the call. - Both the forward and the drop-notice gate use one predicate, `RadioModel::tuneCarrierLive()`: `isTuning()` AND a live `TxActivity::Tune`. The gate is `routed = canTransmit && transmitDriveControl && (!isTuning() || (tuneCarrierLive() && tunePowerAppliesLive))`. While tuning, a value that is not forwarded live always keeps its notice: a backend without the field, or a tune state decoded off the radio with no TUNE this client admitted. - Every `TransmitDriveControl` is built with the field stated explicitly, as the ADDING A FIELD rule requires: `true` on HL2 and Icom, `false` on Flex (whose tune power travels as command-plane text) and ANAN. Sim and RTL declare no record. **HL2** - `Hl2Backend::setTunePower` writes only the drive register, through `applyDrive()`, so the clamp and the transmit gate still apply. It doesn't touch `m_rfPowerPercent`, band memory or `notifyOperatingStateChanged`, and it doesn't route through `setTxPower` or re-call `setTune(true)`. - It acts only while `m_tuning` is true. It also acts only while the TUNE operation, stored as `m_tuneOperation` in `setTune(true)`, still permits dispatch. That optional defence is included: a TUNE whose admission was revoked or emergency-stopped takes no drive change. Every unkey and the connection reset clear `m_tuneOperation`, so it never outlives the carrier. - `tunePowerAppliesLive = true` in the HL2 capabilities. **Icom** - `IcomCivBackend::setTunePower` writes CI-V `14 0A` (the tune drive) while tuning and does nothing otherwise. It also requires the same admission `setTune(true)` checked: the TUNE operation stored there, and the backend's transmit context. The operation is cleared at every unkey and session reset. A refusal is logged on `aether.icom.tx`, because the verb returns nothing to `RadioModel`. `tunePowerAppliesLive = true` in the Icom capabilities. - **Restore fix.** `setTxPower` during TUNE now records the value in `m_preTuneTxPowerPercent`, which the unkey (and the disconnect emergency path) writes back. It no longer writes over the tune drive. - The raw write is factored into `writeTxPowerLevel()`. The TUNE re-request branch in `setTune(true)` and the `tx.power` controls scrub both use it, so neither can stamp the tune drive into the restore slot. - **Readback during TUNE.** The CI-V read that confirms the tune-drive write (`14 0A`) is no longer published as `TransmitDelta::rfPower` while tuning. The periodic poll already skipped that read during TUNE. Without this, `TransmitModel::rfPower()` showed the tune level. A relative control (the power wheel, or a CAT/TCI read-modify-write) would then step from that level, and the result would become the value the unkey restores. Example: 100% RF and 10% tune, then one wheel step, left the radio at 11%. *Visible effect:* on Icom the RF power slider no longer jumps to the tune level while TUNE is keyed. It keeps the operator's RF power, matching Flex. (Found by code review of this branch.) **Evidence for the readback leak, and its limits.** This comes from the code path and a scripted-reply unit test. It is **not hardware-verified**: no Icom was on the bench. - Icom has no tune-carrier command, so TUNE writes the tune level into `14 0A`. - `sendUserCommand()` queues a confirming read of that level 60 ms later (`queueRead(..., now + 60)`). - The `kRfPower` reply handler published it as `TransmitDelta::rfPower`. - `icom_backend_seam_test` scripts that CI-V reply. It does not observe a real radio's answer. **Still wanted:** a CI-V capture from a real Icom of TUNE plus one RF-power wheel step. It should show the confirming `14 0A` read during TUNE and confirm that the restore after the unkey is the operator's RF power. ## The HL2 transmit tail (found by ON8ST) ON8ST showed from the gateware RTL that the HL2 keeps transmitting after the MOX-off frame: - MOX travels with the samples, not as a register. `dsopenhpsdr1.v:351-352` tags each sample with `ds_cmd_ptt`. - In `PTTTX`, `radio.v` drains the keyed samples already queued in the FIFO (`tx_buffer_latency`, 20 ms default, `radio.v:934`). AetherSDR never writes 0x17, so the default applies. - It then holds the last sample for `ptt_hang_time` (12 ms default, `radio.v:935`, 1036-1047). `tx_on` stays high until the state machine reaches `NOTX` (`radio.v:1002`). - Drive is not buffered. `ad9866ctrl.v:134-140` loads the TX gain as soon as the command arrives. So restoring RF drive one frame after MOX-off still landed under roughly 20 + 12 ms of full-scale tune carrier. ON8ST's unit (gateware 74.2) unkeyed itself 40-54 ms after the host stopped sending keyed frames. The same applies to the receive filter switch at unkey. **Why a timer, not `tx_on`.** `tx_on` is reported only in the discovery / alternate-port reply: `resp_control` (`control.v:899`) feeds `usopenhpsdr1.v:292` (`6'h20`). The EP6 stream's responses carry `ptt_resp = cw_on | ext_ptt` instead (`control.v:456`, 472-475), which a host MOX never raises. `MetisProtocol.cpp:546` is in `parseDiscoveryReply()`. Reading `tx_on` mid-stream would mean polling the discovery port during TX, which the stream-free telemetry design avoids because that reply preempts EP6 (`Hl2TelemetryCadence.h`). Gateware without `EXTENDED_RESP` also sends zeros there, so a "`tx_on` low" trigger would fail open. **Fix (`Hl2Backend`).** - Every key-up arms a transmit tail of `kTxTailHoldMs` = 70 ms, after the MOX-off is queued. - 70 ms is never below the existing RX unkey hold (a `static_assert` pins it). It covers the gateware's 20 + 12 ms default tail and ON8ST's 40-54 ms measurement. The RX-hold measurement of the same tail on one HL2 was 51.66-66.15 ms, n = 11. - While the tail is pending, `applyBandFilter()` keeps the transmit filter byte, and `setTxPower()` records an owed RF drive without writing it. - When the tail ends, the receive filter and the owed RF drive are applied. - A re-key cancels the tail, so a restore is never applied over a new carrier, and the drive stays owed to the next unkey's tail. - A disconnect or connection reset drops the tail. Icom is unchanged: its PTT-off is queued before the power restore, and the radio owns its own timing. **Maintainer decisions (Jeremy):** 1. A fixed 70 ms timer ends the tail. There is no `tx_on` poll, and 0x17 is not written. 2. A voice key inside the tail of a TUNE release keeps the tune drive for that transmission. The restore is cancelled, never applied over the new carrier, and RF drive returns at the next unkey's tail. 3. The transmit filter byte is held for the tail on every unkey, including CW break-in elements. ## Out of scope (unchanged) - No changes to TCI `tune_drive` or MIDI power limits. Note that TCI `tune_drive` and the automation `transmit tunepower` verb go through `TransmitModel::setTunePower`, so on HL2/Icom they now also apply live while TUNE is keyed, as they already did on Flex. - No slider dimming or tooltip UX for backends that don't declare the field. - No tune-power ceiling tied to RF power. Flex keeps them independent, and so does this PR. - Drag flood is accepted as is, matching RF power: no coalescing and no debounce. ## Tests (all socket-free; nothing keys real hardware) - `transmit_seam_drop_notice_test`: - Flag on: `setTunePower(30)` arrives exactly once, with no re-key and no drop notice. - Flag off: no call, and the notice stays. - Not tuning: no call, and the next `startTune` carries the value. - A decoded tune power while keyed (`applyChanges`, a different value) sends no call. - After `stopTune`, no call. - A tune state decoded off the radio (`applyChanges`, `tune=true`) with no live Tune activity: no call, and the drop notice stands. This pins the Tune-activity check on its own, in both the forward and the gate. - `hl2_tx_gate_test` reads the 0x09 bank bytes off the real EP2 packet builder. | Step | 0x09 bank result | |---|---| | RF power 40% | drive 102 | | TUNE at 10% | drive 25 | | live 60% | drive 153, PA on | | 150% | 255 | | −5% | 0, PA off | | release | 102 restored | | 80% after release | no bank, no keyed frame | | gate closed while tuning | 0, PA off | | TUNE operation emergency-stopped | no bank | | release | stored TUNE operation dropped | | stopped client | no bank | The same file has the transmit-tail tests, with the hold parked so each test ends the tail itself: - The unkey's queued calls post no RF-drive restore; walked packet by packet with an event filter on `MetisClient`. - The tail ending restores RF drive. - A re-key inside the tail cancels it; no restore under the new carrier, and the next unkey's tail pays it. - TUNE re-keyed inside the tail keeps its tune drive. - RF power set during the tail waits for it. - A live tune drive with no key-time tune power is still restored. - A disconnect drops the tail. - On a TX-only filter board, the TX byte holds through the tail, and through a retune inside it, until the tail ends. - The real timer ends a 20 ms tail. - `icom_backend_seam_test` checks the CI-V `14 0A` writes: - The capability is declared. - Not tuning: no write. - While tuning: the live 25% is written, with no re-key. - RF 50% during TUNE: no write, and it is held for restore. - A TUNE re-request writes 15% and keeps 50% held. - A `tx.power` scrub during TUNE re-asserts the live 15% and keeps 50% held. - Release writes 50%. - After release: no write, and the stored TUNE operation is dropped. - A TUNE whose operation was emergency-stopped: no write. - A lapsed transmit context with the operation still live: no write, and one warning logged. - An RF power readback of the tune level during TUNE publishes no `rfPower`. The premise is checked first: the same readback outside TUNE does publish. ### Mutation results (each mutation made its test fail, then the file was restored and verified by sha256 against HEAD) | Mutation | Result | |---|---| | RadioModel connect deleted | RED | | gate reverted to `!tuneKeyed` | RED | | flag ignored (gate + connect) | RED | | wired from `tunePowerChanged` instead | RED | | `isTuning`/Tune-activity check removed | RED | | Tune-activity check alone removed | RED | | HL2 `m_tuning` guard removed | RED | | HL2 implemented via `setTxPower` | RED | | HL2 `applyDrive` bypassed | RED | | HL2 tune-operation check removed | RED | | HL2 flag not declared | RED | | HL2 tail deferral removed (restore at unkey) | RED | | HL2 re-key cancel removed | RED | | HL2 `setTxPower` tail check removed | RED | | HL2 filter predicate ignores the tail | RED | | HL2 disconnect cancel removed | RED | | HL2 `setTunePower` not marking drive owed | RED | | Icom restore fix reverted | RED | | Icom `setTunePower` guard removed | RED | | Icom `setTunePower` no-op | RED | | Icom TUNE re-request via `setTxPower` | RED | | Icom scrub via `setTxPower` | RED | | Icom flag not declared | RED | | Icom tune-drive readback published during TUNE | RED | | Icom tune-operation check removed | RED | | Icom tune operation not stored at `setTune(true)` | RED | | Icom tune operation not cleared at unkey | RED | | Icom transmit-context check removed | RED | | Icom refusal warning removed | RED | | Drop-notice gate ignores `tuneCarrierLive` | RED | | Forward uses `isTuning()` alone | RED | | HL2 tune operation not cleared at unkey | RED | ## Merge with main The branch was cut from `2d5712946`. It now carries signed merges of `origin/main` up to `c7513e125` (27 commits, including #6062, #6005, #5979, #5982, #5976, #6011, #5980, #6019, #5981, #6012 and #6050 on HL2, and #6049 in `RadioModel`). Every merge was clean. The files both sides touched (`Hl2Backend.cpp/.h`, `IRadioBackend.h`, `RadioModel.cpp`, `radio-capabilities-map.md`) needed no manual resolution, and none of main's changes in them touch keying, drive or TUNE. Everything below was re-run on the current head. ## Static gates (merge base `c7513e125` vs merged head, findings diffed per file) `check_engine_boundary.py --strict`, `gen_touchpoint_manifest.py --check`, `check_capability_records.py --strict` and `check_command_plane.py --strict` all produce identical findings on base and head once line numbers are normalised: - Engine boundary: 102 tracked findings, 0 blocking. - Touchpoint manifest: up to date. - Capability records: 71 bools. - Command plane: 401 commands across 32 files. No ratchet moved, and no new wire text was added. `gen_seam_probe_table.py --check` is clean (51 signals): this PR adds a seam verb but no seam signal. ## Not verified - No live-radio test on HL2 or Icom; per the TX rules, nothing was keyed. The Icom path, including the readback leak and its guard, is proven only against the backend's CI-V output and scripted CI-V replies. A real-Icom CI-V capture of TUNE plus one RF-power wheel step is still wanted. - Icom's RF-power readback is suppressed during TUNE. CI-V cannot tell the confirming read of the tune drive from a front-panel change, so an RF-power knob turned on the radio during TUNE is not seen and the unkey restores the pre-TUNE value (the code comment says so). - The HL2 transmit-tail fix has no hardware test. Its 70 ms rests on the gateware source defaults and on bench measurements made before it existed: ON8ST's 40-54 ms unkey, and the RX unkey-hold measurement of the same tail. Nothing here observed `tx_on` or RF output with this change. - Full default ctest at -j8 on the merged head, with every test whose source uses a socket API excluded (523 of 620 run; the pty-backed `thumbdv_queue_test` and four new socket tests from main are excluded too): all pass except `connection_panel_size_test`, which is a known local failure (#5811). `transmit_seam_drop_notice_test`, `hl2_tx_gate_test` and `icom_backend_seam_test` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ures. Neither file existed at this branch's previous base, so neither conflicted and neither appeared in the rebase: hl2_rxdsp_adaptive_taps_test arrived with aethersdr#5981 and reroute_dead_controls_test with aethersdr#6049, and both call signatures this branch widens. hl2_rxdsp_adaptive_taps_test: four buildChannel(cfg, false, 0) calls become (cfg, NoiseBlanker::Off, 0, NoiseBlankerFill::Zero). These cases assert filter tap lengths across a rebuild and never engage a blanker, so off with a zero fill is what they already had. reroute_dead_controls_test: the fake backend's setSliceNoiseBlanker override takes the kind and fill, and records kind != Off as the bool its rows already assert — the same reading a radio with one blanker gives the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #5578 (thanks to W5TSU for the report and the 64 ms arithmetic), together with #5954, which already removed the delay outside CW with minimum phase — this is the CW half. Also closes the CW half of row 4.1 of #5678. CW keeps linear phase by design, so there the 8192-tap RX bandpass still delays by (taps − 1)/2.
What changes
Hl2RxDsp::rxFilterTapsFor()now picks the filter length:WdspChannel::setFilterTaps(feat(dsp): runtime setters for the RX filter length and phase mode. Principle VIII. #5878).WdspChannel::setFilterTaps()losingbeginControlOperation()),addNotch()refuses the notch the same way it handles a notch WDSP refuses, so a 50 Hz notch is never realised at 100 Hz on the 4096 filter. The next notch, mode or filter change retries.setFilterTaps()holdsg_setupMutex(the process-global FFTW planner lock) on the hl2-io thread for the six FIR re-plans only; the channel stop stays outside it. That is the same class of costsetMode()already pays for the phase change.Why 4096 and not the 2048 in row 4.1
Measured through a real
WdspChannel, 2048 taps leave a signal 50 Hz outside the CW filter edge at −33 dB, against −109 dB at 4096 and −114 dB at 8192. That holds at every CW width, because the skirt is a fixed number of Hz. 2048 would let a CW neighbour through about 80 dB louder than today.Measured (offline, AGC off)
Tests
New
hl2_rxdsp_adaptive_taps_test(socket-free, ~10.5 s wall clock). It includes a negative control asserting that 2048 fails the skirt criterion. The exact 2048-sample latency delta is asserted on the impulse response's centre of energy (within 2 samples); the half-amplitude onset is checked within 3 ms. Live legs cover the refused raise, a rebuild where the mode moved mid-build (both directions) and a rebuild that keeps the width hysteresis; each goes red when its line inHl2RxDsp.cppis mutated.hl2_rxdsp_unmute_return_test's CW-vs-USB margin goes from 60 to 30 ms, because CW without a notch now delays (4096−1)/2 samples (42.7 ms) instead of 85 ms. The check still catches the regression it exists for: with minimum phase dropped outside CW,usbReturngoes to ~128 ms, the CW−USB gap goes negative, and both that check andusbReturn < 80 msfail (mutation-checked).Listening test (2026-09-30, at
bf9fe0a5): on the air, receive only: an HL2 on 40 m CW at 500/250/100 Hz, with this PR's head in an integration build. It was clean, with no ringing, clicks or smearing heard. This is one operator's ear, not a side-by-side comparison againstmain.Update against main: merged current
maininto this branch (no rebase). The conflicts were comment condensation from #6072 against this branch's comment edits, the private declarations beside #5982'spushSquelchToChannel(), and adjacenttests.cmakeregistrations; both sides were kept. A length change does not disturb #5982's squelch:RXASetNCresizes the filter cores (including fmsq's noise filter) but leaves every squelch stage's run flag and threshold alone, and CW has no squelch stage.🤖 Generated with Claude Code