Repository navigation
feat(hl2): receive squelch per mode family, with an HL2-measured threshold map. Principle II. - #5982
Conversation
…ethersdr#5678 row 1.5) The SQL button and level were live on the HL2 and reached nothing: Hl2Backend never overrode setSliceSquelch and no WDSP squelch stage was ever switched on. WdspChannel::setSquelch(on, level) routes the slice's 0..100 level to the stage for the mode in force and forces the other two off: FM -> fmsq, threshold 10^(-2*level/100) (1.0 .. 0.01) AM, SAM -> amsq, threshold -160 + 1.6*level dB (-160 .. 0 dBFS) LSB, USB, DSB -> ssql, threshold 0.0075*level (0 .. 0.75) CW, DIG, other-> none Routing and maps are pihpsdr's rx_set_squelch (dl1ycf/pihpsdr src/receiver.c), except CW, which pihpsdr puts on amsq: the app disables SQL in CW/data unless hasModeIndependentSquelch, so a CW stage here would be invisible and unswitchable. The HL2 declares that capability false explicitly. setMode() and open() re-apply, so the squelch follows mode changes and survives reconfigure(); Hl2RxDsp holds the pair outside Config and re-applies it at every channel install; Hl2Backend stores it per receiver, pushes it wherever the blanker is pushed, and echoes it in slice state. No vendored patch: upstream wdsp.h already declares all six setters; the prototypes are added to the aether_wdsp.h facade. Tests: wdsp_channel_test gains a routing test (maps, per-mode stage, mode switching, rebuild, TX refusal) and an audible gate test (FM and AM: noise muted with squelch on, audible off, a signal above threshold opens). New hl2_rxdsp_squelch_test covers held-before-configure, mode moves, configure() and the asynchronous rebuild. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sured map (aethersdr#5678 row 1.5) On-radio check d155 of b26ab7c found two defects: * USB muted at level 0. pihpsdr's SSB choice, WDSP ssql, counts a zero crossing only when the step across it exceeds 0.01 (ssql.c create_ftov eps), so on this chain's quiet audio (AGC ceiling 39 dB, band noise at -45 dBFS) it never counts one and never unmutes, even at threshold 0. Measured muted at levels 0, 1, 10 and 50 (d156). SSB/DSB now use amsq, the level squelch g0orx pihpsdr ran in every mode, and level 0 runs no stage at all in any mode, so "0 = open" is structural. * AM muted a strong broadcast station at 50. amsq compares a 10 ms average of |IQ| after the receive bandpass with 10^(dB/20) in wire full-scale units. Descending level sweeps on the HL2 (d156, default RF gain, EFHW) placed a no-signal floor at -120..-112 dBFS, the 9.420 MHz broadcast carrier at -96..-88 and busy 40 m at -80..-72, so pihpsdr's -160 + 1.6*level puts its midpoint (-80) above a strong station. The map is now -140 + 0.7*level dBFS: midpoint -105, ~10 dB over the floor and ~15 dB under the carrier. FM keeps pihpsdr's map, which measured right. Tests: the gate test now runs at HL2 levels (-116 dBFS noise, -92 dBFS signal, verified against RXA_S_AV at amsq's capture point) with the HL2 AGC, adds a USB case, and asserts level 0 passes noise; the routing test pins level 0 as no-stage in every family. Reverting the AM map makes the gate test fail (carrier muted at 50). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Issue fit
Yes. #5678 row 1.5 reads "No squelch in any mode, while the SQL button is live", route "import WDSP fmsq.c". The diff closes it: Hl2Backend now overrides setSliceSquelch, holds the pair per receiver, pushes it at the same three sites as the blanker (createPanadapter 1296, the setter 3825, pushInitialState 8841 — I diffed the call-site lists and they match exactly), and WdspChannel routes it to a stage by mode family. The second commit's on-radio findings (ssql held shut, pihpsdr's level map muting a broadcast station at 50) are the kind of correction the roadmap row could not have anticipated, and both are pinned by tests.
CI is green on all five checks at d4ffe15 (build, check-macos, check-windows, Static checks, Sanitizer configures), and I re-ran the three static gates locally against the head checkout: check_capability_records.py --strict reports 71/71 frozen (this PR adds no new RadioCapabilities bool — it assigns an existing one), check_command_plane.py --strict 0 blocking, check_engine_boundary.py 0 blocking.
Scope
| File(s) | What it changes | Claimed? | Verdict |
|---|---|---|---|
Hl2Backend.{h,cpp} |
setSliceSquelch override, Receiver::squelch{On,Level}, pushSquelch, slice-state echo, hasModeIndependentSquelch = false |
yes | in scope |
Hl2RxDsp.{h,cpp} |
request held outside Config, re-applied at every channel install |
yes | in scope |
WdspChannel.{h,cpp} |
setSquelch, squelchStageFor, two level maps, applySquelchLocked from setMode/open, AppliedSquelch readback |
yes | in scope — shared file, but see nit 2 |
third_party/wdsp/include/aether_wdsp.h |
4 declarations for symbols upstream already exports | yes | in scope; no vendored patch — I confirmed upstream/RXA.c creates amsq/fmsq/ssql all with run = 0, and CMakeLists.txt globs every upstream/*.c, so nothing new is compiled or patched |
docs/architecture/radio-capabilities-map.md |
records the HL2 hasModeIndependentSquelch declaration |
yes | in scope |
tests/{hl2_rxdsp_squelch_test.cpp,wdsp_channel_test.cpp,tests.cmake} |
new registered target + two new checks | yes | in scope |
Everything in the diff is explained by row 1.5. No CHANGELOG entry (correct). No UI file touched, so no personal-preference surface: the level map is a first implementation with measurements behind it, not a changed default. No removed guard — the only deleted lines are the capability comment's third reservation, which the diff replaces with a statement of what now carries it.
Socket-test disclosure: none. I grepped both added/modified test sources for QTcpServer|QTcpSocket|QUdpSocket|QLocalServer|bind(|listen(|connectToHost|Fake* — zero hits. hl2_rxdsp_squelch_test is registered in tests.cmake and builds real WDSP channels offline.
Blockers
None.
Nits (non-blocking)
- A refused control operation is invisible outside the log.
Hl2RxDsp::setSquelchwarns and returns whenWdspChannel::setSquelchrefuses; the request then differs from what WDSP is running until the nextconfigure(). The blanker has the same shape but surfaces the divergence throughm_nbApplied{On,Level}in the bridge readback — squelch'sappliedSquelch()has no production consumer, so a support bundle shows only the warning line. Inline comment onHl2RxDsp.cpp:422. - ANAN carries the identical defect and the shared half is now written.
grep -rn "quelch" src/core/backends/anan/returns nothing, soAnanBackenddoes not overridesetSliceSquelcheither, whileVfoWidget/RxAppletenable SQL for it in every non-CW/digital mode.applySquelchLockedis generic and ANAN already runsWdspChannel, so the follow-up is a backend-sidesetSliceSquelch+ push, not new DSP. Not this PR's job — worth a row on #5678. Inline onWdspChannel.cpp:789. appliedSquelch()is a non-atomic cross-thread read (WdspChannel.h:546, returning a reference tom_appliedSquelch, written underg_setupMutexon the control thread). Test-only today so it is not a live race, but the blanker made its applied pairstd::atomicfor exactly this reason. Worth a one-line "test observation only; make it atomic before any production reader" in the header.- HL2 squelch is session-only.
setSliceSquelchdoes not callnotifyOperatingStateChanged()(the neighbouringsetSliceAgcdoes, citing #4909), and I found no app-side persistence ofsquelchOn/squelchLevelanywhere. Same as the blanker, so this is consistency rather than a regression — but the PR body reads as if the control is now fully wired, and "gone by the next launch" is worth one sentence there.
What I tried to break
- "fmsq/amsq/ssql all default to
run = 0, so nothing gates before we ask." Readupstream/RXA.c:154/219/515— all threecreate_*calls pass0for run. The claim holds, andssqlbeing left untouched is therefore safe rather than lucky. - "a larger fmsq threshold is a more open squelch" and "amsq's threshold is dBFS." Read the setters:
SetRXAFMSQThresholdwritestail_thresh = threshold, unmute_thresh = 0.9 * threshold;SetRXAAMSQThresholddoespow(10, dB/20)thentail = 0.9 * thresh, unmute = thresh. Both facade comments match the source, including the 0.9× asymmetry and the inverted sense on fmsq. - "amsq captures before the AGC and gates at the end; S_AV is measured at the same point." Read
xrxa(RXA.c:640-682):xnbp→xmeter(smeter)→xsender→xamsqcap, andxamsqis second-to-last beforexresample. The test's justification for readingRXA_S_AVas the level amsq triggers on is correct —xsenderbetween them does not modify the buffer. - A stage left running across a mode change.
applySquelchLockedwrites both run flags unconditionally on every application, and it is called fromsetMode,setSquelchandopen— so AM→FM cannot leave amsq gating FM on carrier level.squelchStageForcovers every enumerator ofMode(13 of 13, noNfm/Dfmmembers to miss), so theNonefallthrough is reachable only for the families that are meant to have no stage. - The rebuild/reconfigure paths.
open()re-applies inside its owng_setupMutexscope (WdspChannel.cpp:1317), andHl2RxDsp::installChannelapplies aftersetModeand before the first block.canPushToChannel()holds the request during a background build, andhl2_rxdsp_squelch_testcase 4 pins that the old channel'sapplicationscounter does not move while the build runs. - TX contamination.
setSquelchrefuses on a transmit channel,setMode's squelch call is inside theDirection::Receivebranch, andopen()'s is too — a TXA channel never reachesSetRXA*SQ*. The routing test asserts the refusal. - A command loop through the new slice-state echo.
SliceModel::applyChanges(SliceModel.cpp:1598-1616) assignsm_squelchOn/m_squelchLeveland emits onlysquelchChanged, neversquelchCommandIssued— so the echo cannot re-entersetSliceSquelch. Principle II holds.RxAppletalso skips the slider update in Auto mode, so the tick-by-tick echoes do not overwrite the operator's margin. - Regression risk for the other
WdspChannelusers. ANAN and the TX path now getSetRXAFMSQRun(0)/SetRXAAMSQRun(0)on every RXopen()andsetMode(), writing the state those stages already had — no behaviour change, and both setters are acsDSPlock plus one store, so no allocation on the control path. - Would the tests pass against the unfixed code? No. The routing test's HL2-midpoint assertion (
-105 dBFSmust sit between the measured floor and a strong carrier) fails on pihpsdr's-160 + 1.6·level, and the gate test drives real audio through a real channel at the levels d156 measured, so emptyingapplySquelchLockedfails it. The map assertions on their own do restate the formula, but they are not the load-bearing checks.
Not verified: anything runtime. I did not build or run a test, and I have no radio — every statement above is read from the head checkout at /tmp/aetherclaude/pr-5982, the vendored WDSP source, or CI status. The d155/d156 on-radio measurements, the pihpsdr rx_set_squelch map, and the claim that ssql's eps = 0.01 never counts a crossing on this chain's audio are external to this repo and I could not check them; if the level map is ever revisited, those three are what would need re-measuring.
Recommendation
Approve with nits. The routing is structurally sound — both run flags written every time, level 0 as a guarantee rather than a low threshold, re-application at every point where WDSP forgets — and the second commit shows the author measured the first one on hardware and corrected it rather than defending it. The four nits are observability and follow-up, none of them merge-gating. Next step: a one-line header note on appliedSquelch()'s threading, and a row on #5678 for the ANAN sibling.
🤖 aethersdr-agent · cost: $10.9483 · model: claude-opus-5
…pplied snapshot (aethersdr#5982 review) Review on aethersdr#5982: * Hl2RxDsp::setSquelch only warned when WdspChannel refused the change (a control operation racing a processIq callback), leaving WDSP behind the request until the next configure(). A refusal now marks the request pending, and it is retried at the top of every processIqBlock() — same thread, before that block's processIq(), so nothing of ours is in flight — until the channel takes it. The warning is logged on the edge into pending only. * WdspChannel::appliedSquelch() returned a reference to a plain struct written on the control thread. It now returns a by-value snapshot under a dedicated mutex (not g_setupMutex, which is the process-wide FFTW planner lock and can be held for a whole plan). A test-only refuseControlOperationsForTest() hook makes the refusal deterministic; hl2_rxdsp_squelch_test drives refused -> pending -> converged, including a refusal that persists for several blocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Re-checked on the radio at
Same outcome as |
Main's comment sweep (aethersdr#6072) condensed blocks next to this branch's additions. Four files conflicted. - Hl2Backend.cpp: main now holds the receiver by pointer in finishReceiverDspBuild, so pushSquelch beside pushNoiseBlanker takes *r. Main's condensed mode-list comment is kept, without "and no squelch" for FM, which this PR makes false. Main's condensed TX-slice comment is kept, with the squelch echo above it. - Hl2Backend.h, Hl2RxDsp.cpp: main's condensed comments, this branch's members and the pending-squelch retry beside them. - WdspChannel.h: main's condensed noise-blanker comment, this branch's setSquelch declarations above it. The automatic merge had placed refuseChannelControlForTest() between main's comment for channelConfig() and that function in Hl2RxDsp.h; it now follows the function. This branch's own added comments are cut to the comment rule. The setSquelch contract stays as one table (stage and threshold per mode); the measurement narrative behind the maps is in the PR body. No behaviour changes in the merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e VIII. Drop the external lab-notebook references and defect narrative from the squelch comments in favour of the measured levels and a (aethersdr#5982) tag, fold the squelch into Hl2RxDsp's install-step list, and drop the stale ssql mention from the routing test header. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Approving at 751f399f4: on8st's three commits, plus two merges of main (#6045, #6007, #6021, #6005, #5976, all clean) and a comment-only commit.
What this pass changed
df362f37c: condensed the squelch comments to AGENTS.md § Comments. The lab-notebook and defect-history references are replaced by the measured levels and(#5982). The squelch is added toHl2RxDsp's list of what the install step re-applies. The stale "ssql" mention in the test header is gone. No behaviour change.
Review findings
- Refused squelch change (thread 1): fixed in 73bb48d, pinned by
hl2_rxdsp_squelch_testcase 5. - Cross-thread
appliedSquelch()read (thread 3): fixed in 73bb48d with a mutex snapshot; the lock order is fixed and has no inversion. - ANAN sibling (thread 2): out of scope here. Proposed as a new row on #5678.
Maintainer decision (Jeremy)
The automated review pass found that the panadapter SQL line and Auto SQL assume Flex's −160 + level scale, while the HL2's amsq gate is at −140 + 0.7·level dBFS. The maintainer chose to land on8st's hardware-measured map unchanged. The limitation is stated in the PR body, and the follow-up is #6092. The map is not a change to any default value: the HL2 had no squelch before, the default stays off / level 20 (SliceModel's), and hasModeIndependentSquelch = false is the same as the undeclared default.
Verified by running it, at the merged tree
- Full build.
- 85/85 hl2, wdsp, slice and squelch tests pass. Socket-owning tests were excluded.
- All four static gates are clean: engine-boundary 0 blocking, manifest up to date, capability records 71/71, command-plane 0 blocking.
- Mutations, each red and then restored:
- FM routing → None
- AM/SSB routing → None
- the per-block retry dropped
- pihpsdr's
-160 + 1.6·levelmap restored (the AM audio-gate test fails: "a signal above threshold did not open the squelch") - the
setModere-apply dropped - the
open()re-apply dropped
Verified by reading
- Every mode string the HL2 accepts after #6007 maps to a
WdspChannel::Mode, andsquelchStageForcovers all 13. - In
pushInitialState,setModeand thensetSquelchare queued to the same thread, andsetModere-applies the squelch anyway, so their order doesn't matter.
Not verified by me
- Anything on a radio. The on-radio evidence is on8st's: the tables in the body and the re-check at 73bb48d.
…Principle VII.
The read path already refused. `get hostnb` declines without
RadioCapabilities::hasHostNoiseBlanker, arguing that an empty success is a test
passing against a radio where the question is meaningless. The write path six
thousand lines down had no equivalent check, so `slice dsp nb2 on` put a Flex or
an Icom slice into NoiseBlankerKind::Advanced — a kind no backend on those
families can map onto anything.
Not a harmless no-op, for three reasons that compound:
* It sticks. SliceModel::applyChanges is deliberately written so a radio's
bool `nb` echo cannot pull a host kind back down, so nothing the radio says
afterwards clears it. Only a full NB off does.
* The button lies. VfoWidget::refreshNbControls keys the label on the kind
alone — only the fill row is capability-gated — so the NB button reads
"NB2" on a radio that has no NB2.
* IcomCivBackend::setSliceNoiseBlanker folds anything non-Off to plain "on",
which is faithful to the wire and means nothing upstream ever notices the
disagreement.
The invariant is stated in IRadioBackend.h:764 and SliceModel.h:317, and until
now was enforced nowhere. The GUI was never the hole: the NB2 leg of the button
cycle is already gated on m_hasHostNoiseBlanker (VfoWidget.cpp:2674). The bridge
was the only unguarded door to Advanced.
Placed with the control-name validation rather than inside the nb/nb2 branch,
because on such a radio `nb2` is not a control given bad arguments — it is not a
control at all. That also puts the refusal ahead of slice resolution, so a radio
with no slice yet is told about the capability instead of "no slice available",
which would send a caller looking for a slice on a radio that can never have the
control.
Reads the capability rather than RadioModel::hasHostNoiseBlanker(), which is
additionally gated on isConnected(): that gate exists to keep a BUTTON off an
unconfirmed radio, and no other branch of `slice dsp` refuses for being
disconnected. Same read as `get hostnb`.
New target automation_nb2_capability_test drives the real dispatcher with a stub
backend and an injected slice. Four mutations, all killed: the guard deleted,
the guard widened to `nb` as well, the polarity inverted, and the guard moved
below slice resolution — the last proving the placement and not just the
presence.
Rebased onto b714cd3. aethersdr#5982's hl2_rxdsp_squelch_test landed between this
branch's old base and the rebase and builds a channel to prove the squelch
survives a rebuild swap, so its buildChannel() call is adapted to the kind/fill
signature; the blanker is incidental there and stays off. 599/599 ctest with one
deliberate skip, twelve ratchets clean, check_command_plane exactly at its 406
raw-command baseline across 32 files, and the colour delta +0 on all three
counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
…2 — 43 ms sooner. Principle VIII. (#5981) 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: | case | taps | |---|---| | CW | 4096 | | CW with any notch, or a passband under 100 Hz | 8192 | | every mode outside CW | 8192 (unchanged) | - The length is raised *before* WDSP sees the first notch, so the 50 Hz notch floor holds. - Switching uses `WdspChannel::setFilterTaps` (#5878). - Dropping back to 4096 needs 20 Hz of margin above the threshold, so dragging a filter edge doesn't flip the length on every step. A rebuild (sample-rate change) measures that margin from the outgoing chain's length, so the length at the same settings is the same before and after. - **No hysteresis on the notch transition, by design.** Adding the first notch or removing the last costs one filter refill each way, but each one happens at a moment the operator caused, by a discrete action, not on every step of a drag. Holding the long filter after the last notch would keep CW at the longer latency with nothing needing it. - If the raise to 8192 is refused (`WdspChannel::setFilterTaps()` losing `beginControlOperation()`), `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()` holds `g_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 cost `setMode()` 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) - **CW onset** (on8st's #5578 method): 128.00 → **85.33 ms** (exactly 2048 samples less). - **CW return after unkey:** 128 → 86 ms. - **4096 vs 8192 for widths ≥ 100 Hz:** same centre gain and −6 dB width, ≥ 109 dB down from 50 Hz outside the edges. At the threshold width itself (100 Hz) 4096 taps lose −0.00 dB at the centre (98 Hz −6 dB width at both lengths); at 50 Hz 4096 would lose 0.40 dB, which is why narrower passbands stay at 8192. - **Live 4096 → 8192 switch under a steady tone:** no NaN and no spike (peak 1.00× steady), level unchanged, and a **155 ms refill** during which audio is more than 6 dB down. The operator hears that once when adding the first notch, removing the last, or crossing 100 Hz. - **Notch across the switch:** −103.4 dB, against −103.5 dB on a chain already at 8192. - **Outside CW:** 8192 vs 2048 differs by 0.3 ms at minimum phase, so those modes are untouched. ## 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 in `Hl2RxDsp.cpp` is 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, `usbReturn` goes to ~128 ms, the CW−USB gap goes negative, and both that check and `usbReturn < 80 ms` fail (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 against `main`. **Update against main:** merged current `main` into this branch (no rebase). The conflicts were comment condensation from #6072 against this branch's comment edits, the private declarations beside #5982's `pushSquelchToChannel()`, and adjacent `tests.cmake` registrations; both sides were kept. A length change does not disturb #5982's squelch: `RXASetNC` resizes 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
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>
…Principle VII.
The read path already refused. `get hostnb` declines without
RadioCapabilities::hasHostNoiseBlanker, arguing that an empty success is a test
passing against a radio where the question is meaningless. The write path six
thousand lines down had no equivalent check, so `slice dsp nb2 on` put a Flex or
an Icom slice into NoiseBlankerKind::Advanced — a kind no backend on those
families can map onto anything.
Not a harmless no-op, for three reasons that compound:
* It sticks. SliceModel::applyChanges is deliberately written so a radio's
bool `nb` echo cannot pull a host kind back down, so nothing the radio says
afterwards clears it. Only a full NB off does.
* The button lies. VfoWidget::refreshNbControls keys the label on the kind
alone — only the fill row is capability-gated — so the NB button reads
"NB2" on a radio that has no NB2.
* IcomCivBackend::setSliceNoiseBlanker folds anything non-Off to plain "on",
which is faithful to the wire and means nothing upstream ever notices the
disagreement.
The invariant is stated in IRadioBackend.h:764 and SliceModel.h:317, and until
now was enforced nowhere. The GUI was never the hole: the NB2 leg of the button
cycle is already gated on m_hasHostNoiseBlanker (VfoWidget.cpp:2674). The bridge
was the only unguarded door to Advanced.
Placed with the control-name validation rather than inside the nb/nb2 branch,
because on such a radio `nb2` is not a control given bad arguments — it is not a
control at all. That also puts the refusal ahead of slice resolution, so a radio
with no slice yet is told about the capability instead of "no slice available",
which would send a caller looking for a slice on a radio that can never have the
control.
Reads the capability rather than RadioModel::hasHostNoiseBlanker(), which is
additionally gated on isConnected(): that gate exists to keep a BUTTON off an
unconfirmed radio, and no other branch of `slice dsp` refuses for being
disconnected. Same read as `get hostnb`.
New target automation_nb2_capability_test drives the real dispatcher with a stub
backend and an injected slice. Four mutations, all killed: the guard deleted,
the guard widened to `nb` as well, the polarity inverted, and the guard moved
below slice resolution — the last proving the placement and not just the
presence.
Rebased onto b714cd3. aethersdr#5982's hl2_rxdsp_squelch_test landed between this
branch's old base and the rebase and builds a channel to prove the squelch
survives a rebuild swap, so its buildChannel() call is adapted to the kind/fill
signature; the blanker is incidental there and stays off. 599/599 ctest with one
deliberate skip, twelve ratchets clean, check_command_plane exactly at its 406
raw-command baseline across 32 files, and the colour delta +0 on all three
counts.
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>
…L2 amsq gate referred to the LNA. Principle VIII. (#6127) Fixes #6092. ## What was wrong The panadapter SQL line and Auto SQL assume every radio's squelch level means `−160 + level` dBm. That is Flex's scale. On the HL2 it is wrong in three ways: - **The gate is fixed in raw dBFS.** amsq opens at `−140 + 0.7·level` dBFS (`WdspChannel::levelSquelchThresholdDbfsForLevel`), so every dB of LNA gain moves the antenna-referred point where squelch opens by a dB. on8st's re-run on a real HL2 (#6092, 2026-10-03) shows the map was fitted at LNA −12 dB. At the shipped +20 dB, level 50 sits 24 dB or more below the no-signal floor: it opens below about level 85 and never closes on a carrier. - **The pan is not raw dBFS.** HL2 bins carry `offsetDb() = fullScale − LNA` (−17 dB at +20). The line was drawn on Flex's scale with neither offset. - **Auto SQL thresholds pan bins against a passband detector.** amsq captures after the passband filter and before the AGC (`xamsqcap`, `third_party/wdsp/upstream/RXA.c:650`, after `xnbp(nbp0)` at `:647`, before `xwcpagc` at `:665`). It compares an averaged magnitude against `10^(dB/20)` (`amsq.c:125-126`, `:221-224`). Pan noise is per bin, so the two differ by an amount that depends on both the filter width and the bin width. That is on8st's point on the issue. ## The fix (Jeremy's ruling on #6092: C+R) 1. **The HL2 amsq gate is referred to the LNA.** `gate = −108 + 0.7·L − lnaOffsetDb()` dBFS. It equals on8st's map at LNA −12 and tracks the LNA dB for dB, the same way the AGC ceiling already does. - `WdspChannel` stays family-neutral. `setSquelch(on, level, levelOffsetDb)` adds a caller-supplied offset to the amsq map only, and the channel has no gain of its own to refer to. - The HL2 owns the referral in `Hl2DbReference::levelSquelchOffsetDb()`. That class already "owns everything relating raw dBFS to dBm, plus the AGC setpoint that must move with it" (docs/agents/backends.md: radio-facing logic stays in the family backend). - `Hl2Backend` sends the offset with every squelch push and re-pushes on every LNA change (`pushEffectiveLnaGain`), including auto RF gain steps. `Hl2RxDsp` keeps the last finite offset if it is handed a non-finite one, so its retry loop only ever means "the channel is busy". - **The −12 dB anchor is "supported, not established", in on8st's words.** His 27 Sep no-signal and carrier brackets fit the −12 row, and the busy-40 m bracket does not. The 1:1 shift is supported (carrier moved 10.9 dB for 12 and 19.3 for 20, with 2.1 dB fading between repeats), not proven. - At the default +20 dB LNA, his rows put the working range at a few steps around level 50: noise opened the gate up to about 49, his carrier up to about 54. `radio-capabilities-map.md` says so. 2. **A new optional capability record, `RadioCapabilities::squelchLevelScale`** (`core/backends/SquelchLevelScale.h`). It holds offset, dB per step, modes, and whether Auto SQL may use it. Absent means no SQL line and no Auto SQL. - **Flex:** `{−160, 1, all modes, Auto}`, unchanged behaviour. - **HL2:** `−140 + (G + 12) + (3 − G) + 6.02` = −118.98 + 0.7·L on the pan axis, in AM/SAM/DSB/LSB/USB only. The LNA terms cancel, so the record is a constant. FM is excluded: fmsq is a noise-quieting gate with no dB place. - **Auto SQL is off on the HL2** for the detector-mismatch reason above. If it comes back, the floor should come from the detector side; a pan-derived floor would need both widths applied. - **Icom, the demo, ANAN and RTL-SDR:** `legacyDbmSquelchScale()`, i.e. Flex's `{−160, 1, all modes, Auto}` (maintainer decision on this PR). None of them has a measured map of its own, so their SQL line and Auto SQL behave exactly as on main. On main, ANAN and RTL-SDR had both features in the GUI too: the SQL button is live in voice modes, so the line drew and Auto SQL ran. They therefore get the same record, so nothing regresses. Kiwi keeps its own server-relative branch, untouched. - **The +6.02 dB carrier term is folded in only because it is verified on both sides.** `Hl2Spectrum` divides by half the window sum, so a steady carrier of magnitude A reads `20·log10(A) + 6.02`. `squelch_level_scale_test` pins this against a synthetic carrier. amsq opens on the magnitude itself, which `wdsp_channel_test`'s new carrier case pins: ±2 dB around the gate, at two offsets. A carrier off a bin centre reads up to 1.4 dB lower (Hann scalloping). - **No ratchet growth.** `check_capability_records.py --strict` stays at the frozen count (70/70 on current main): the record is an optional, and its `autoSquelch` bool sits inside the nested struct. 3. **The GUI reads the record.** - `SpectrumWidget` draws the line from `thresholdDb(level)`. Auto SQL computes `levelForThresholdDb(floor + margin)`, which is bit-for-bit the old Flex result across −175..−40 dB, and returns early when the record is absent or has no Auto SQL. - `MainWindow::syncSquelchScaleToUi()` pushes the record for the active slice's mode. It runs on a capability change, an active-slice switch (before `RxApplet::setSlice`), a slice mode change, and when a pan is added. `autoSquelchShouldRunOnSpectrum` gates on it too. - The SQL button cycle skips Auto where Auto is unavailable (RxApplet; VfoWidget mirrors through it). Every route into Auto, including restore, lands on Manual instead. A slice already in Auto drops to Manual with squelch on at the operator's manual level. - The button stays visible and enabled, because Off and Manual still work. The reason goes on its `accessibleDescription` and tooltip (translated), mirrored on the VFO's SQL button. Before a slice is active the gate stays permissive, as it is while disconnected. ## Behaviour changes to note - **Icom, the demo, ANAN, RTL-SDR:** no change. They keep Flex's scale for the line and Auto SQL. - **HL2 FM, CW and data modes:** no SQL line. fmsq has no dB place. - **HL2 manual squelch:** at the default +20 dB LNA, a given level now gates 32 dB higher (in raw dBFS) than before. That restores on8st's measured calibration at the gain he measured, which is the point of the change. ## Validation All of this was run locally on Arch Linux with an offscreen Qt platform. No HL2 or FLEX hardware was touched. The unit tests, the full suite and the static gates were re-run at 1c57410, after main (3e4b14e) was merged in with a signed merge. The click table below was recorded at 33711e2. The reconnect check was run at 8bc6ebc. **Unit tests (socket-free, mutation-checked).** - **`squelch_level_scale_test` (new).** - The gate formula at LNA −12/−4/+8/+20/+48 dB and levels 0..100. - The 1:1 LNA shift. - At level 50, the gate sits between on8st's no-signal and carrier brackets on all four rows he measured: +20 → −73.0 (noise top −73.5, carrier −70.0); +8 → −85.0; −4 → −97.0; −12 → −105.0. - The record each backend publishes: Flex and HL2 their own; Icom, Demo, ANAN and RTL-SDR pinned to `{−160, 1, all modes, Auto}`. - A synthetic carrier through `Hl2Spectrum` reads 20·log10(A) + 6.02, and a carrier at the amsq gate peaks exactly on the drawn line at LNA −12/+8/+20. - Flex's Auto SQL level matches the old `−160 + level` arithmetic across −175..−40 dB. - The mode filter (HL2 FM and CW give no line; absent gives nothing). - **`wdsp_channel_test`.** - The offset reaches amsq and only amsq, and a non-finite offset is refused at the channel. - New carrier-threshold case: a 50% AM carrier 2 dB above the gate opens amsq (audio equals the squelch-off reference), and 2 dB below stays muted (0). Checked at offset 0 (gate −105 dBFS) and at +32 (gate −73). - **`hl2_rxdsp_squelch_test`.** - The offset reaches the channel and survives `configure()`. - A NaN offset keeps the last good one and leaves nothing pending. - Through `Hl2Backend` on its real I/O thread: `setSliceSquelch` lands at the current LNA's offset, `setLnaAutoOffsetDb(10)` moves the gate −10 dB, and 0 moves it back. - **`rx_applet_squelch_reconciliation_test`.** - With Auto unavailable, the cycle runs Off→Manual→Off. - The reason is on both the RxApplet and VFO buttons, and both buttons stay visible and enabled. - A restored Auto intent lands on Manual. - A slice in Auto drops to Manual with squelch on at its manual level. - **Mutation checks.** Each mutant was applied, the test target rebuilt, the test run, and the file restored (sha256-checked). All 16 were killed: - LNA referral returning 0 - slope dropped from `thresholdDb` - the +6.02 term dropped - FM added to the HL2 modes - Flex record removed - cycle skip removed - the `setSqlMode` Auto coercion removed (this one survived at first and was killed by the added restore-path test) - Auto→Manual drop removed - the reason dropped from the description - the VFO mirror description removed - the channel ignoring the offset - the legacy record removed from Icom, from the demo, and from ANAN (three mutants) - the NaN-offset guard in `Hl2RxDsp::setSquelch` - the `pushSquelch` in `Hl2Backend::pushEffectiveLnaGain` **Full local suite.** `ctest -j8` ran 563 tests: 561 passed, one skipped by design, and `connection_panel_size_test` failed, which is known on this host (#5811). Excluded: 68 tests whose sources use a socket API, or whose names match the socket-test families (automation, TCI, rigctl, CAT, discovery, bridge, DAX and so on), classified by grepping each test's sources from `tests.cmake`. **Agent automation bridge (demo sim, offscreen, own socket, `AETHER_AUTOMATION_NO_TX=1`).** - Connected `serial DEMO-0001`; `get radio` returns `AetherSDR Demo`. - The demo now publishes Flex's scale. `dumpTree` shows "Squelch mode" with the description *"Cycle squelch through Off, Manual, and Auto modes"*. - Three `invoke RxApplet/Squelch mode click` calls, reading the button and `get slice` after each: | Click | Button | Slice squelch | |---|---|---| | start | SQL | off, level 20 | | 1 | SQL | on, 20 | | 2 | AUTO | on, 66 (Auto SQL picked floor + 10 + 160 from the pan) | | 3 | SQL | off, 20 | That is main's behaviour, unchanged. - **Reconnect with Auto on (at 8bc6ebc):** `disconnect` and then `connect … DEMO-0001` again, which goes through the connected-but-no-active-slice window. Afterwards the button still reads `AUTO` (slice on, 66), with the Auto-available description, so it is not demoted to Manual. - **The Auto-unavailable path:** only the HL2 publishes it now, so the demo cannot reach it. It is proven in `rx_applet_squelch_reconciliation_test`: the cycle skips Auto, the reason reaches both buttons, a restored Auto intent lands on Manual, and a slice in Auto drops to Manual. **Not verified:** - **No HL2 hardware run.** The HL2 path is proven against on8st's measured rows and synthetic IQ only. Two things still need an on-air check: the anchor (−12 dB) and the 1:1 shift. - **No pan screenshot.** Offscreen, the spectrum renderer is unavailable on this host, with or without `AETHER_NO_GPU=1`, so the line could not be grabbed. ## Static gates (merge base vs head) Run on the merge base and on the head; findings diffed per file. - **`check_engine_boundary.py --strict`:** 102 → 102 findings. The only diffs are line shifts in `MainWindow_Wiring.cpp` from one added include. - **`gen_touchpoint_manifest.py --check`:** up to date after regeneration. One new row, `core/backends/SquelchLevelScale.h`, tagged `universal`, with 2 includers (`SpectrumWidget.h`, `MainWindow_Wiring.cpp`). - **Maintainer decision (Jeremy, on this PR): the new touchpoint is accepted** — `core/backends/SquelchLevelScale.h`, `universal`, 2 GUI includers. It is the record type and its pure mode/Auto predicates. It follows the `NoiseFloorAutoAdjustGate.h` pattern, so the widget, MainWindow and the test read one function rather than copies. - **`check_capability_records.py --strict`:** 70 → 70 booleans. The record is a `std::optional`, and its `autoSquelch` bool is nested. - **`check_command_plane.py --strict`:** 400 → 400 (main's current baseline). - **Also clean:** `check_test_registration`, `check_ci_test_gate`, `check_qsettings`, `gen_seam_probe_table --check`. `check_a11y` reports 16 findings on the 8 touched GUI files, the same as main. Thanks to **@on8st** for the hardware sweeps (the original map on #5982 and the LNA-pinned re-run on #6092), and for the RXA.c/amsq.c analysis of what amsq actually reads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Principle VII.
The read path already refused. `get hostnb` declines without
RadioCapabilities::hasHostNoiseBlanker, arguing that an empty success is a test
passing against a radio where the question is meaningless. The write path six
thousand lines down had no equivalent check, so `slice dsp nb2 on` put a Flex or
an Icom slice into NoiseBlankerKind::Advanced — a kind no backend on those
families can map onto anything.
Not a harmless no-op, for three reasons that compound:
* It sticks. SliceModel::applyChanges is deliberately written so a radio's
bool `nb` echo cannot pull a host kind back down, so nothing the radio says
afterwards clears it. Only a full NB off does.
* The button lies. VfoWidget::refreshNbControls keys the label on the kind
alone — only the fill row is capability-gated — so the NB button reads
"NB2" on a radio that has no NB2.
* IcomCivBackend::setSliceNoiseBlanker folds anything non-Off to plain "on",
which is faithful to the wire and means nothing upstream ever notices the
disagreement.
The invariant is stated in IRadioBackend.h:764 and SliceModel.h:317, and until
now was enforced nowhere. The GUI was never the hole: the NB2 leg of the button
cycle is already gated on m_hasHostNoiseBlanker (VfoWidget.cpp:2674). The bridge
was the only unguarded door to Advanced.
Placed with the control-name validation rather than inside the nb/nb2 branch,
because on such a radio `nb2` is not a control given bad arguments — it is not a
control at all. That also puts the refusal ahead of slice resolution, so a radio
with no slice yet is told about the capability instead of "no slice available",
which would send a caller looking for a slice on a radio that can never have the
control.
Reads the capability rather than RadioModel::hasHostNoiseBlanker(), which is
additionally gated on isConnected(): that gate exists to keep a BUTTON off an
unconfirmed radio, and no other branch of `slice dsp` refuses for being
disconnected. Same read as `get hostnb`.
New target automation_nb2_capability_test drives the real dispatcher with a stub
backend and an injected slice. Four mutations, all killed: the guard deleted,
the guard widened to `nb` as well, the polarity inverted, and the guard moved
below slice resolution — the last proving the placement and not just the
presence.
Rebased onto b714cd3. aethersdr#5982's hl2_rxdsp_squelch_test landed between this
branch's old base and the rebase and builds a channel to prove the squelch
survives a rebuild swap, so its buildChannel() call is adapted to the kind/fill
signature; the blanker is incidental there and stays off. 599/599 ctest with one
deliberate skip, twelve ratchets clean, check_command_plane exactly at its 406
raw-command baseline across 32 files, and the colour delta +0 on all three
counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ciple IV. ## Summary WDSP ships two impulse blankers. #5824 landed the first (ANB, `nob.c`); this adds the second (NOB, `nobII.c`), which deskHPSDR, pihpsdr and Thetis all call NB2. Instead of gating the blanked window to zero it reconstructs it from the samples either side: better when the impulse lands on a wanted signal, worse when the noise is dense enough that those samples are not clean. Every reference client offers both and replaces neither, and so does this. Host-side DSP on our own IQ throughout. No wire protocol, no radio firmware, and no behaviour change for a Flex or an Icom, whose blankers are the radio's. - **`WdspChannel` opens both stages** for the life of a receive channel and runs at most one: `processIq` feeds exactly one stage per block, chosen by the kind. Both are created even when no blanker is on, because `create_nobEXT` allocates and a lazily built stage would allocate on the real-time path the first time someone clicked NB2. - **The transmit hold covers both**, for the reason it covered one: the trigger is relative to a running average, so a transmit period of zeros would decay that average and gate the first real receive samples. - **Kind and fill ride #5919's typed route** (ruling 1). `SliceDspRequest` gains `kind` and `fill` members, carried on every `Feature::Nb` request, and one new field, `Field::Fill`. A change of blanker is an ordinary `Field::Enabled` request: `enabled` says whether one runs, `kind` says which, so a radio with one blanker (Flex, Sim) reads `enabled` exactly as before. `SliceDspRequest::requestedBlanker()` derives the three-state answer in one place, and a request built without a kind still means the first blanker. There is deliberately no `Field::Kind`: the NB button calls `setNbKind()` on every radio, so a new field would have reached Flex and Sim, which do not handle it. - **Host backends apply it through one verb**, `IRadioBackend::setSliceNoiseBlanker(id, kind, level, fill)`, which the HL2, ANAN and Icom `requestSliceDsp` call. Kind, level and fill arrive together, so no ordering between them exists. Icom treats anything but `Off` as on. Flex refuses `Field::Fill` as `Unsupported` rather than encoding it; the fill is only reachable where `hasHostNoiseBlanker` is set (HL2, ANAN), so no operator sees that refusal. - **Two enums, one conversion.** `core/backends/NoiseBlankerKind.h` is the seam's vocabulary; `WdspChannel` keeps its own, because `core/dsp` is a leaf. `core/backends/WdspNoiseBlanker.h` holds the conversion, with `static_assert`s that fail the build if an existing value is renumbered. - **`SliceModel` gains `nbKind()`/`nbFill()`, and `nbOn()` is now derived from the kind**, so there is one source of truth. `setNb(bool)` keeps its meaning: `true` selects the first blanker (ruling 2). rigctl's `NB`, SmartCat's `NB`, TCI's `rx_nb_enable`, `get slice`, the MIDI mapping, the keyboard shortcut and the band-stack entry are untouched. A radio's bool echo cannot downgrade a host NB2 to NB. - **UI: the NB button cycles Off → NB → NB2 → Off** where this host runs the blanker, and stays a plain toggle elsewhere (ruling 2). The label follows the running kind; the `objectName` does not, since the bridge addresses the button by it. An **NB2 fill combo** appears under the DSP grid while NB2 runs, and leaves with the NB button in FM (ruling 4). - **Bridge** (ruling 3): `slice dsp nb2 on <level> <fill>`, refused on a radio without `hasHostNoiseBlanker`, and `get slice` / `get hostnb` report the kind and fill beside the existing `on`. - **`aether_wdsp.h` gained the NOB declarations.** `nobII.c` was already compiled; only the curated header was missing it. ## Review and rulings | Item | Where | |---|---| | Blocker 1: #5919 removed the routing this was built on | Rebased onto `85f2a124` and moved onto `SliceDspRequest` in `340d00b8` | | Ruling 1: widen `SliceDspRequest` with kind and fill | `340d00b8` | | Ruling 2: the cycling button, and `setNb(true)` dropping NB2 to NB | Unchanged, as accepted | | Ruling 3: the bridge surface | Unchanged, as accepted | | Ruling 4: the fill row showing in FM | `3ef4cd36` | | Blocker 2: the run-flag ratchet | Removed in `3ef4cd36` | | Nits: stale line cites, the retired-test reference, the `static_assert` claim, comment length | `3ef4cd36` | **Blocker 2.** The review's mutations showed the ratchet guarded an inert property. A run flag left on the unchosen stage blanks nothing, because `processIq` never feeds that stage. The one mutation that does double-blank (both stages fed, the ANB run flag left on under NB2) passed the ratchet and failed DISCRIMINATION 2 in `hl2_noise_blanker_test`. `tools/check_nb_exclusivity.py`, its `Static checks` step and its `docs/agents/backends.md` entry are gone. The test's closing comment now names the discrimination checks as the exclusivity pin, and the run-flag comment in `WdspChannel::setNoiseBlanker` names the dispatch as the guarantee. **Ruling 4.** The fill row follows `m_nbModeOk`, and `refreshNbControls()` now runs at both places that recompute it: the `modeChanged` handler, and `syncFromSlice()`, whose earlier refresh ran before `m_nbModeOk` was set. The shared DSP level row is unchanged. ## Rebase onto `85f2a124` Main was 38 commits ahead and the branch was `dirty` in eight files. The move onto #5919 is a port, not a marker resolution: - **`SliceModel`** no longer has `noiseBlankerCommandIssued`. `setNb`, `setNbKind` and `setNbFill` publish through main's `notifyReceiveDspIntent`, and `currentDspRequest()` fills `kind` and `fill` into every Nb request. The per-field intent revisions grow from two fields to three. - **`RadioModel`**: the block of direct receive-DSP `connect()`s this branch carried is dropped; `wireSliceReceiveIntentsToBackend()` owns that wiring now. - **`WdspChannel.h`**: main's `FmReceive` and this branch's two enums landed at the same spot; both kept. - **Tests from main, ported**: `backend_receive_contract_test` (from #5919) asserted `Config::noiseBlankerEnabled`, which this branch replaces with the `noiseBlanker` kind, so it now asserts `Impulse`. `anan_noise_blanker_readback_test` keeps main's typed dispatch. New rows in the contract test pin that a Flex reads a host kind as `nb=1` and refuses the fill with no wire text, and `noise_blanker_kind_model_test` asserts on the `SliceDspRequest` the model emits. Main has since taken two Icom commits (to `12df6943`). They touch `IcomCivBackend.{h,cpp}`, which this PR also changes. The merge is clean, and neither side uses a signature the other changed. CI on `3ef4cd36` runs on the merge with `12df6943`. Earlier rebases, onto `b714cd3d` and `732b99a9`, resolved comment-prose conflicts from #6072, accepted #6075's retirement of `wdsp_nb_hold_invariant_test.cpp` (its behavioural successor `wdsp_nb_hold_test` is ported), kept #5982's squelch API, and ported two tests main had added since the earlier bases: `hl2_rxdsp_adaptive_taps_test.cpp` and `reroute_dead_controls_test.cpp`. ## Constitution principle honored Principle IV: clean-room. WDSP's NOB is in-tree. deskHPSDR was read for which knobs exist and what its defaults are (the 3-way choice, the shared threshold, the zero-fill default), and no code was copied. Principle VIII: the blanking, the fill reaching WDSP, single-stage dispatch, the model's echo handling, the request shape, the fill row's FM rule and the applied-state readback are each pinned by a test with a mutation run behind it, listed below. Squashed-from: #5966 Co-authored-by: tropo1234 <299180028+tropo1234@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes row 1.5 of #5678. The SQL button was live on the HL2 and reached nothing, because
Hl2Backendnever overrodesetSliceSquelch.What changes
WdspChannel::setSquelchroutes the slice's 0..100 level to a WDSP squelch stage for the current mode and forces the others off:fmsqamsq(level squelch)hasModeIndependentSquelch = falseSquelch follows mode changes, survives rebuilds and reconnects, and is echoed in slice state. There is no vendored patch: only six existing WDSP setters are prototyped in
aether_wdsp.h.Why not pihpsdr's AM and SSB maps
Measured on a real HL2 (80 m EFHW, receive only) by sweeping the level until the gate opened:
−160 + 1.6·levelputs level 50 at −80 dBFS, which muted a strong broadcast station. The new map puts 50 at −105 dBFS: about 10 dB above the noise floor and 15 dB below that carrier.ssqlonly counts a zero crossing when the audio steps by more than 0.01 per sample. At HL2 audio levels it never opened, even at level 0. SSB therefore uses the level squelch, as older pihpsdr did.Known limitation (maintainer decision)
The panadapter SQL line and Auto SQL both assume a level means −160 + level, which is Flex's scale. On the HL2 that puts the line in the wrong place and Auto SQL does not hold its margin, until #6092 is fixed. Manual SQL is verified on the radio (below). The maintainer chose to land on8st's measured map unchanged and fix this in #6092.
Verified on the radio
After each setting I waited 3 s; closing waits out a hang time of up to 1.5 s.
Tests
wdsp_channel_testrun at HL2 levels (−116 dBFS noise, −92 dBFS signal, HL2 AGC settings), with FM, AM and USB cases. They check that level 0 passes noise and that noise mutes at 50. Restoring the old AM map makes the test fail.hl2_rxdsp_squelch_testcovers configure, mode changes and the background rebuild.ctest -R hl2: 56/56.Interaction with #5919
@rfoust's #5919 moves slice receive control to typed requests, including
requestSliceSquelch, and lists squelch as unsupported for the HL2. Both PRs touchHl2Backend. Whichever lands second should route the new request to this implementation; I'll do the rebase if this one is second.🤖 Generated with Claude Code