Repository navigation
feat(hl2): route APF and the AGC-off level to WDSP. Principle VIII. - #6050
Conversation
SliceModel::setApf, setApfLevel and setAgcOffLevel emitted Flex wire text and nothing else. A non-Flex slice never has commandReady connected, so on every host-DSP radio the APF button, its level slider, the ToggleApf and WheelApf controller actions, the AGC-T slider with AGC Off and the AGC-T calibrator's AGC-off strategy moved the model and reached nothing. Add two seam verbs with no-op defaults, IRadioBackend::setSliceApf(slice, on, level) and setSliceAgcOffLevel(slice, level), and two operator-only SliceModel intents that RadioModel routes to them beside the existing receive-DSP intents. The Flex wire text is unchanged and Flex does not override the verbs, so Flex, Icom, ANAN, RTL and Sim behave as before. applyChanges() now guards agcOffLevel the way it already guards the AGC threshold, because a backend that publishes the value on every slice update would otherwise re-fire AgcTCalibrator's manual-record timer. slice_apf_agc_off_seam_test pins the Flex strings byte for byte, the routing through the production receiver bindings (including the AGC-T knob and the calibrator), and that an echo never returns as a command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HL2 demodulates in WDSP on this host, and every receive channel already
contains both stages: the four SPCW peaking filters behind apfshadow.c and
the fixed gain wcpAGC applies in mode 0. Nothing reached either, so the
APF controls and the AGC-off level were dead on this radio.
Export SetRXASPCW{Selection,Run,Freq,Bandwidth,Gain} through aether_wdsp.h,
add WdspChannel::setApf and setAgcFixedGain (Config-carried, re-pushed by
open(), validated like the FM deviation), and implement the new seam verbs
in Hl2Backend through Hl2RxDsp, per receiver like the noise blanker.
Mappings. AGC-off level uses the AGC-T threshold's own 0.6 dB/unit scale
(0..60 dB), not referred to LNA gain. The slice default of 10 therefore
opens at 6 dB where the channel previously ran a fixed 10 dB. The APF
level maps to bandwidth, because both APF sliders are labelled "APF
bandwidth" with "higher values narrow the peak": 200 Hz at 0, 100 Hz at 50
(RXA.c's default), 50 Hz at 100, geometric. Gain stays at RXA.c's 2.0.
The centre is the CW pitch and follows setCwPitch; the stage runs only in
CWL/CWU and the request survives a trip through other modes. Fixed gain is
pushed in every AGC mode because WDSP applies it only while AGC is Off.
Both survive a sample-rate rebuild and a reconnect, are published in the
slice state so the controls show what the chain runs, and appear in the
dspChains() read-back (apfRun/apfCenterHz/apfBandwidthHz/apfGain from what
WDSP accepted). hasAudioPeakingFilter is now true for HL2.
hl2_apf_agc_off_test measures the audio of a real chain: 50 units of off
level is 30 dB with AGC Off and 0 dB with AGC on; the APF lifts the pitch
over a tone 200 Hz away in CW and does nothing in USB. hl2_backend_test
reads the chain back through the backend. No radio was used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AGC-off level map was level * 0.6 dB. The slice model opens at
level 10, so AGC Off opened at 6 dB, while origin/main runs every HL2
at WdspChannel::Config::agcFixedGainDb = 10 dB whatever the slider
says: AGC Off would have got 4 dB quieter for every HL2 user who never
touched the slider.
Anchor the map on the default instead: 10 + 0.6 * (level - 10), so
level 10 gives exactly 10.0 dB (the offset term is an exact zero) and
the slider spans 4..64 dB at the AGC-T threshold's own 0.6 dB/unit.
hl2_apf_agc_off_test pins the default with ==, against both the
literal 10.0 and WdspChannel::Config{}; hl2_backend_test checks the
bridge read-back of a fresh receiver is exactly 10.0.
APF needs no such fix: SliceModel opens with apf off and RXA.c
constructs SPCW with run = 0, so the default path is not in circuit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bbcd63e2 and ea0f62a5 added the backend half of the APF and AGC-off checks to tests/hl2_backend_test.cpp. That test is retired on main: its add_test sits inside a #[==[ ... ]==] block in tests/tests.cmake, so the additions never compiled and never ran. They pinned nothing. hl2_backend_test.cpp is now byte-identical to origin/main. What those assertions meant to pin - setSliceApf / setSliceAgcOffLevel reaching the receiver's WDSP chain, sliceChanged publishing what the receiver holds, dspChains() reporting what WDSP accepted, a CW pitch change moving the APF centre, and leaving CW stopping the stage while keeping the request - was covered only at the Hl2RxDsp level, not through Hl2Backend. They move to hl2_apf_agc_off_test as section 6, socket-free: a friend Hl2ApfAgcOffTestAccess hands receiver 0 a configured Hl2RxDsp on the backend's I/O thread, the pattern hl2_unkey_hold_test already uses. The test now links Qt6::Network for Hl2Backend. Running them exposed an error the dead copy could never show: they assumed a 700 Hz CW pitch, and Hl2Backend starts at 600. The section now sets the pitch it checks against. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
1. Issue fit
No linked issue. The PR title and the tests.cmake comment ("G2 of the silent HL2 control map") point at an internal work item I could not find with search_issues; the body does not carry a fixes/closes line. Reviewed against the PR's own stated intent, which is coherent and matches the diff: two controls that were Flex wire text only (apf=/apf_level=, agc_off_level=) now reach WDSP on a host-DSP backend. Per GOVERNANCE.md this is a bug fix with a clear root cause, so no RFC is owed; a one-line issue reference in the body would still help the next reader.
I checked that the fix actually reaches an operator, since that is the whole claim. It does: MainWindow.cpp:7909 gates the P/CW APF row on hasAudioPeakingFilter (now true for HL2), and VfoWidget::relayoutDspGrid/m_apfBtn->setVisible(isCw) shows the DSP-grid APF button on mode alone — applyRadioSideDspVisibility() does not AND m_hasRadioSideDsp into m_apfBtn, so that button was already visible on an HL2 in CW and simply did nothing. Both surfaces now work.
2. Scope
| File / group | What it changes | Claimed by the PR? | Verdict |
|---|---|---|---|
IRadioBackend.h |
two new virtual seam verbs, default no-op | yes | In scope. New public surface (a backend interface third parties/other families inherit) — but it is the established setSliceNoiseBlanker shape, and slice_apf_agc_off_seam_test §5 pins the no-op. Worth a maintainer nod, not a blocker |
SliceModel.{h,cpp} |
two typed intents; guards d.agcOffLevel in applyChanges |
yes (the guard is explained in-diff, not in the title) | In scope. The guard is load-bearing, not cosmetic — see §5 |
RadioModel.cpp |
routes the two intents to the seam | yes | In scope |
Hl2Backend.{h,cpp} |
per-receiver state, push sites, capability flip, dspChains() readback, emitSliceState |
yes | In scope |
Hl2RxDsp.{h,cpp} |
the two level maps, CW gating, re-apply across rebuild | yes | In scope |
WdspChannel.{h,cpp} |
setApf/setAgcFixedGain, Config fields, validateConfig, open() re-push |
yes | In scope |
third_party/wdsp/include/aether_wdsp.h |
five SPCW declarations | yes | In scope; one is unused (nit 4) |
tests/* + tests.cmake |
two new registered targets, both joined to AETHER_SETTINGS_CONSUMERS |
yes | In scope |
Everything in the diff is explained by the stated intent. No CHANGELOG.md edit (correct). No new RadioCapabilities boolean — check_capability_records.py --strict reports 71 at the frozen 71. check_command_plane.py reports 413 across 32 files, unchanged, so no new raw Flex text lands above the seam. No new AppSettings keys. No UI restyle, no default flipped for an existing radio (the 37bee1c2 commit exists specifically to keep AGC Off at the 10 dB every HL2 already ran) — nothing here reads as smuggled preference.
No socket tests added or removed. Neither new test opens, binds, listens or connects; hl2_apf_agc_off_test links Qt6::Network but only pulls MetisProtocol.h for kEp6BlockSamples.
3. Blockers
1. The APF centre goes stale when the CW pitch moves while the slice is out of CW — inline on Hl2Backend.cpp:5398.
setCwPitch() skips a receiver entirely when cwBfoHz(r.mode) == 0.0, and cwBfoOffsetHz (Hl2Backend.cpp:288) returns 0.0 for every mode that is not CW/CWU/CWL. The new pushApf(r) sits inside that guard. setSliceMode() re-pushes mode, filter and shift when entering CW, but never the APF — so the centre Hl2RxDsp still holds is whatever the pitch was the last time this receiver happened to be in CW.
Sequence:
- Connect.
createPanadapter→pushApf(r)with the default 600 Hz; receiver 0 is in USB. - Operator sets the CW pitch to 800 Hz.
setCwPitch(800)skips receiver 0 (not CW).Hl2RxDsp::m_apfCenterHzstays 600. - Operator switches the slice to CW with APF on.
setSliceModepushes shift and filter built from 800;Hl2RxDsp::setMode→applyApf()runs the peak at 600 Hz while the tone arrives at 800.
At the default level 50 the bandwidth is 100 Hz, so a 200 Hz error puts the wanted signal on the skirt: APF-on makes it quieter. That is verbatim the failure the same hunk's comment says it exists to prevent ("the peak stays on the old tone while the signal slides off it — the filter would be attenuating the very signal it exists to lift"). Nothing heals it: no radio echoes the APF, and installChannel() re-applies from the same stale m_apfCenterHz.
It also contradicts pushApf's own contract two hunks earlier — "this is safe to send in any mode and is sent unconditionally, like the blanker" — which is true at createPanadapter and pushInitialState but not here.
Either fix works; the second is the narrower change:
- add
pushApf(*r);beside thesetShiftinsetSliceMode'sif (r->dsp)block, or - move
pushApf(r)above thecwBfoHz(...) == 0.0 → continueinsetCwPitch, which is what the comment already claims.
The test would not have caught this. hl2_apf_agc_off_test §6 does setCwPitch(700) then setSliceMode(0,"CW"), which walks straight into the stale path — but it does not read apfCenterHz at that point, and the next setSliceApf(0, true, 80) pushes the pitch explicitly and masks it. A check of apfCenterHz immediately after the mode change into CW, with no intervening setSliceApf, is the assertion that fails today.
4. Nits (non-blocking)
WdspChannel::setApfhas no unchanged-parameters early-out (inline onWdspChannel.cpp:787).SetRXASPCWFreq/Bandwidth/Gaineach land onSetRXADoublepoleFreqs/Gain, anddoublepole.chas no guard of its own — each one runsCalcDoublepoleFilter, which rebuilds a ≥2048-tap FIR throughbuild_doublepole_2sided'sfftw_plan_dft_1d(..., FFTW_PATIENT). So onesetApf()call is three full redesigns, underg_setupMutex(which isfftwPlannerMutex()— I checked, there is no planner race here), on the I/O thread that paces EP2.applyApf()now fires on everysetMode, everyinstallChannel,pushInitialState,createPanadapter,setCwPitchand every slider step, andopen()adds three more per channel open. The case that bothers me is not the drag the header defends — it is that an operator who never enables the APF now pays three redesigns on every mode change, to keep a stage that is off, off.setMinimumPhasein the same class already has exactly this early-out and cites exactly this planner cost.return truewhenenabled/centerHz/bandwidthHz/gainall matchm_configwould remove essentially all of it.docs/architecture/radio-capabilities-map.md:150now contradicts the code (inline onHl2Backend.cpp:2409). That row still readshasAudioPeakingFilter | ✅ | ❌ | ❌with HL2 as the second column, and its Effect text says the row's only effect is Flexslice set <n> apf=— both untrue after this PR. AGENTS.md's capability rule asks for the caps-map doc alongside the declaration; the freeze checker passes because no boolean was added, so nothing catches the drift.check(true, ...)atslice_apf_agc_off_seam_test.cpp:224asserts nothing and prints[PASS]. It proves the base verbs compile and don't crash, which is worth keeping — butMinimalBackendcould record and the test could assert nothing was recorded, or the line could be a comment rather than a green check.SetRXASPCWSelectionis declared inaether_wdsp.h:211and never called. The design depends onselectionstaying at RXA.c's construction default of 0 (double-pole) — which I verified inRXA.c:507— so the declaration is correct but dead. Either call it explicitly (cheap, and makes the dependency enforced rather than assumed) or drop the declaration.- Neither value is persisted or restored, so a reconnect returns the off-level to 10 and the APF to off while the AGC-T threshold beside it is restored from
m_restoredState.agcThreshold. Consistent with the noise blanker's existing precedent, so I read it as a decision — worth one line in the body so it reads that way. - Enabling the APF forces the audio to mono.
xdoublepolein mode 2 (which is how RXA.c builds it,RXA.c:443) doesin[2i+1] = in[2i+0]— I is copied into Q before filtering. Harmless for ordinary CW, but if anything on this path ever produces genuinely different left/right audio in CW, APF-on collapses it. WDSP's own design, not this PR's, but undocumented at the seam.
5. What I tried to break, and could not
- The APF/AGC-off echo feedback loop.
setSliceApf→emitSliceState→sliceChanged→applyChanges→apfChanged→VfoWidget::syncDsp.applyChangesguards all three (SliceModel.cpp:1524/1528/1602), so an echo at an unchanged value emits nothing and never re-enters a setter. The PR's claim here is accurate — and the newd.agcOffLevelguard is not cosmetic:AgcCalibrationDialog.cpp:280wiresagcOffLevelChangedstraight intoAgcTCalibrator::onValueChanged, and HL2 now publishes that field on everyemitSliceState. Without the guard the calibrator would re-record a sweep point on every VFO step. The seam test pins exactly that. - The AGC-off dB map against WDSP.
SetRXAAGCFixeddoesfixed_gain = pow(10, dB/20)(wcpAGC.c:549) andxwcpagcmultiplies by it only underif (a->mode == 0)(wcpAGC.c:167), so "applies exactly when AGC is Off" and "50 units = 30 dB of audio" both hold, and the default level 10 → exactly 10.0 dB with no rounding. - The "higher level = narrower peak" direction.
PhoneCwApplet.cpp:988/990andVfoWidget.cpp:2116/2121both label the slider "APF bandwidth" with "Higher values narrow the peak", so the decreasing geometric map matches what the UI promises. The default 50 → 100 Hz is RXA.c's own construction bandwidth (RXA.c:441). The FIR-length comment is right too:calc_dpole_ncdivides by 1.7 then thresholds at 80/40, i.e. 2048 taps down to ~68 Hz bandwidth and 4096 below, at 48 kHz. - The CWL claim.
create_doublepoleis built with mode 2 andxdoublepolemode 2 copies I into Q, so one positive centre genuinely serves both sidebands — the "CWL runs the APF on the positive pitch" assertion is not a guess. - An FFTW planner race. This was my main hypothesis:
setApftakesg_setupMutexbut notfftwPlannerLock(), and the doublepole design creates plans.WdspChannel.cpp:59bindsg_setupMutex = AetherSDR::fftwPlannerMutex()— same mutex. Refuted. - Vacuous refusal assertions.
WdspChannel::Config bad; bad.apfBandwidthHz = 0.0;only proves something if the default Config is otherwise valid; it is (Receive, 48 k/48 k/48 k, USB, 150–3000), socreate()fails on the new APF predicate and not incidentally. Non-vacuous. - Rebuild and install ordering.
installChannelapplies the AGC-off level and the APF aftersetMode, which is required becauseapfInCircuit()readsm_config.mode;setModeassignsm_config.modebefore callingapplyApf();setCwPitchassignsm_cwPitchHzbefore its loop. The deferred-push path (canPushToChannel()false during a background rebuild) holds both requests and re-applies at the swap. All correct. - Flex.
capabilities().hasAudioPeakingFilterwas already true for Flex, the wire text is byte-identical (pinned), and both new seam verbs are base no-ops for every other family.setApf/setAgcFixedGainrefuse a transmit channel. I found no Flex-visible change beyond theagcOffLevelecho now being edge-triggered, which is the intended fix.
Findings are reasoned from the code and the vendored WDSP sources in /tmp/aetherclaude/pr-6050, not reproduced at runtime — I have no build and cannot run the measured assertions. Worth noting alongside the green badge: neither new test is in any ctest -R step in ci.yml, so the five passing checks prove this compiles on three platforms and clears the static gates; the measured 30 dB and >10 dB assertions are the author's local evidence only. That is normal for this repo, not a criticism of the PR.
6. Recommendation
Request changes — one blocker, and it is a one-line fix. The engineering here is strong: the level maps are anchored on WDSP's own construction values rather than invented, the refusals are checked and logged instead of swallowed, the applyChanges guard anticipates a real calibrator hazard, and the tests measure audio rather than mirroring fields back at themselves — which is why the one path they don't cover is the one that breaks. Fix the stale APF centre (push the APF from setSliceMode, or drop the CW guard in setCwPitch so pushApf behaves the way its own comment says), add the assertion that reads apfCenterHz right after a mode change into CW with no intervening setSliceApf, and update the caps-map row. The setApf early-out and the maintainer call on the two new seam verbs can follow either way.
Thanks for the unusually legible comments on the WDSP side — the aether_wdsp.h block on apfshadow saved me a lot of time, and every claim in it checked out against upstream/.
🤖 aethersdr-agent · cost: $15.0086 · model: claude-opus-5
Review of aethersdr#6050 (blocker): setCwPitch() re-sent the APF only to receivers currently in CW, because pushApf() sat below the "not CW -> continue" guard. Hl2RxDsp holds the APF centre across a trip out of CW and runs it again from setMode() on the way back in, and setSliceMode() does not re-send the APF. So a pitch moved while the slice was in USB never reached the receiver, and re-entering CW ran the peak on the old pitch. At the default 100 Hz bandwidth a 200 Hz error puts the tone on the skirt, so APF-on made the wanted signal quieter. pushApf() now runs for every receiver before the CW test, which is what its own contract ("safe to send in any mode, sent unconditionally") already said. The held centre can then never be stale, whichever path later runs it: a mode change, a rebuild or a reconnect. hl2_apf_agc_off_test section 6 already walked this path but read the centre only after a setSliceApf() that re-pushed the pitch and hid the fault. It now reads apfCenterHz right after entering CW with nothing in between, and repeats the review's sequence with the APF on (pitch moved in LSB, then back to CW). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of aethersdr#6050 (nit): Hl2RxDsp::applyApf() runs on every mode change, rebuild and pitch change, mostly to keep an off stage off, and each call took the control handshake and g_setupMutex. WDSP does not redesign the filter for these calls. CalcDoublepoleFilter (doublepole.c) returns early when centre, bandwidth and gain are all unchanged, and it builds the FIR analytically (build_doublepole_1eff), with no FFTW plan unless the length changes. So the review's "three full redesigns" does not happen. The lock does still cost something: g_setupMutex is the process-global FFTW planner lock, taken on the I/O thread that paces EP2. A no-op call behind another channel's rebuild would wait out that rebuild's planning to change nothing. A no-op call could also be refused while a callback was in flight. setApf() now returns true before the handshake and the lock when the request equals what m_config says WDSP holds, the same shape as setMinimumPhase(). Exact equality is right: m_config is written only by an accepted setApf() or open(), and nothing else writes SPCW. hl2_apf_agc_off_test section 2b holds the planner lock and makes an unchanged call. It must return within 2 s. Without the early-out it blocks until the lock is released, and the check fails instead of hanging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of aethersdr#6050 (nit): SetRXASPCWSelection was declared and never called. The design depended on create_apfshadow (RXA.c) starting on selection 0, the double-pole. The reviewer offered two options: call it, or drop the declaration. This calls it, once, in open() and before the other SPCW calls. Everything WdspChannel and Hl2RxDsp reason about is the double-pole: apfParametersValid() guards its divide by the centre, the level map is sized against calc_dpole_nc, and "one positive centre serves CWL and CWU" depends on its mode-2 I-into-Q copy. If a WDSP update started on another selection, every SPCW call would silently land on the matched or gaussian filter, which scale the gain by sqrt(2). Stating the selection turns that assumption into an invariant. On today's WDSP the call is a no-op (apfshadow.c returns early when the selection is unchanged), so it costs nothing. For the same reason no test can fail without it today. The aether_wdsp.h comment claimed that every SPCW call rebuilds the impulse response. CalcDoublepoleFilter only rebuilds when a parameter changed. The comment now says so and records the explicit selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of aethersdr#6050 (nit): slice_apf_agc_off_seam_test section 5 ended in check(true, ...), which printed [PASS] and could never fail. It proved only that the two base verbs compile and do not crash. A base verb has no state to read back. What "no-op" can mean to an outside observer is that it emits nothing. The check now spies every signal the backend's meta-object declares, calls both verbs, and asserts that no signal fired. It also asserts that more than one signal was watched, so an empty spy list cannot make it pass by accident. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of aethersdr#6050 (nit): the capabilities map still showed HL2 as ❌ for hasAudioPeakingFilter, and it described the flag's only effect as Flex `slice set <n> apf=`. After this PR both are wrong. Hl2Backend declares the flag true, and the APF runs in this host's WDSP. check_capability_records.py passes regardless, because no boolean was added. It does not notice a value that flips. The field row now shows HL2 ✅, says who runs the filter on each family, and records that on HL2 the flag is an exception to hasRadioSideDsp, the same shape as hasHostNoiseBlanker, not a tier under it. The P/CW gate in MainWindow reads the flag alone. The tier table's "Means" cell is widened to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of aethersdr#6050 (nit): enabling the APF discards the chain's Q. RXA.c builds the double-pole in mode 2 (CWL + CWU), and xdoublepole copies I into Q before filtering (doublepole.c), so any left/right difference the chain carried into that stage is gone after it. This is WDSP's design and harmless for ordinary CW, but nothing at the seam said so. The setApf() comment now does, so a future stereo CW path is not surprised by it. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. The blocker was real, and so were four of the five nits. Each is handled in its own commit on top of Bad news first.
Hl2Backend.cpp:5398, stale APF centre ( WdspChannel.cpp:787, early-out ( docs/architecture/radio-capabilities-map.md:150 ( slice_apf_agc_off_seam_test.cpp:224, aether_wdsp.h:211, Review body, the other points.
Verification. Each new check fails against a throwaway local commit that undoes its fix: pushApf back below the 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. |
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
…liceApf interim, condense comments. Principle VIII. Maintainer decisions (KK7GWY, 2026-10-02): - The AGC-off level rides main's typed route (aethersdr#5907): SliceModel emits receiveAgcRequested(Field::OffLevel) and Hl2Backend now overrides requestSliceAgc, handling OffLevel itself and passing every other field to IRadioBackend::requestSliceAgc. The parallel agcOffLevelCommandIssued signal and setSliceAgcOffLevel verb are gone. - setSliceApf stays as an interim seam verb that folds into aethersdr#5919's SliceDspRequest::Feature::Apf when that lands. slice_apf_agc_off_seam_test now records requestSliceAgc(OffLevel) and checks the base drops it; its agc_off_level wire-text check is gone (Flex sends that from FlexBackend now). hl2_apf_agc_off_test drives the off level through requestSliceAgc and checks a Mode request still reaches the chain's AGC. Comments are condensed to AGENTS.md § Comments, including a duplicated ADC-pairing block left by the merge. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Brings in aethersdr#6013 (adjacent tests/tests.cmake registrations); no conflict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AADGw3HVn5FMMVrx2RQZ9g
Brings in aethersdr#6049 (RadioModel/SliceModel reroutes and an adjacent tests/tests.cmake registration); no conflict. Its band-stack AGC recall reaches requestSliceAgc, which Hl2Backend passes to the base for every field but OffLevel. 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 0d734cb5 (maintainer landing pass).
Maintainer decisions (KK7GWY, 2026-10-02):
- The AGC-off level uses main's typed route (#5907), with no parallel verb.
Hl2Backend::requestSliceAgchandlesField::OffLeveland passes every other field toIRadioBackend::requestSliceAgc.agcOffLevelCommandIssuedandsetSliceAgcOffLevelare gone, andSliceModel::setAgcOffLevelis exactly main's. Done in a new commit,9fa82a48, with no history rewrite. IRadioBackend::setSliceApfis accepted as an interim verb that folds into #5919'sSliceDspRequest::Feature::Apfwhen #5919 lands. This is stated at the verb, in the body, and in a note on #5919.
Branch update (Wave 3): merged main three times, signed, with no rebase or force-push.
bf052e5b: the conflicts were adjacent additions next to #5982 squelch, #5980 pan averaging and #5981 filter length, plus test registrations; both sides were kept.SliceModel::setAgcOffLeveltook main's version.4eb5da20brought in #6013 and0d734cb5brought in #6049, both cleanly.
Review findings (aethersdr-agent, 2026-09-30):
- Blocker, stale APF centre: fixed by on8st (
38e3760a). Mutation-checked here: sending the APF only to CW receivers turns both §6 centre checks red. setApfearly-out:0947d1dc. §2b passes.- Caps-map row:
d04f80cc. check(true):67645086. Now rewritten for therequestSliceAgcroute; it also asserts the base drops OffLevel.SetRXASPCWSelectioncalled inopen():4aac7f1f.- Persistence and mono notes: in the body and the
WdspChannel.hcomment.
Landing-pass changes (9fa82a48):
- The route change and test rewrite above.
- Comments condensed to AGENTS.md § Comments, including a duplicated ADC-pairing block the merge had left.
- Mutation-checked:
Hl2Backend::requestSliceAgcnot handling OffLevel fails three §6 checks, and not delegating other fields to the base fails the new Mode check.
Verified empirically (Arch Linux, RelWithDebInfo, at 0d734cb5):
- Full build. Non-socket ctest 549/550. The one failure is
connection_panel_size_test, which fails locally (#5811). Socket-owning tests were excluded; both new tests are socket-free. - Static gates pass with findings identical to main's. The capability-record count is unchanged, and Flex is untouched (it overrides neither verb).
Not verified here: hardware. on8st's receive-only HL2 readings predate the route change. The HL2/WDSP half is the same code, and the new route is covered by the tests. No radio was used in this pass, and nothing was transmitted.
Green freshness: CI and Static Checks on 0d734cb5 were created at 2026-10-03T03:22:14Z. Main's tip is 5ef09b0f4 (#6049, 03:09:45Z), which is older, so the green covers current main.
Implements approved RFC #6091: an operator-enabled CTR2 relay over TCP and USB HID link v0, with byte-exact forwarding, bounded queues and lifecycle teardown tied to AetherSDR's radio. Preserves an unsent CLOSED notification across repeated USB link faults and adds a socket-free, mutation-checked regression test. Six focused suites passed locally; Linux, macOS, Windows, Static checks and CodeQL passed on the PR head. Clean integration with current main was verified separately after HL2 PR #6050 landed. Hardware USB interoperability and mid-TX firmware teardown remain disclosed verification limits.
…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>
…6119) **Maintainer ruling (2026-10-03):** the default is accepted; the AGC-off level persists per receiver in this PR, capability-shaped, in the radio's settings document, with no schema bump; stale DIGU/DIGL band-stack bookmarks are follow-up #6142. Refs #5629 DIGU and DIGL open with AGC off on the Hermes-Lite 2, and the AGC-off level that then sets their gain is remembered per receiver. **Limits first.** 1. **The default changes for every Hermes-Lite 2.** Ruled: accepted. Selecting DIGU or DIGL turns AGC off on that receiver, and a receiver that comes up in DIGU or DIGL does too. Only the `hl2` family is touched. ANAN-G2 runs the same AGC and keeps the operator's mode; RTL-SDR offers no data mode. Neither was measured or changed. 2. **DIGU/DIGL are still up to 29 dB quieter at the default level; what is new is that the operator's correction now sticks.** With AGC off the gain is the AGC-off level (#6050): 10 dB at the default level of 10, against an AGC ceiling of 39 dB at the default threshold. The 29 dB is the difference of the two settings, not a measurement. The default level is unchanged. An operator who raises the level (the AGC-T slider while AGC is off) gets it back on the next launch, on that receiver. What is not covered: - **No schema bump** (ruled). The field is optional and `kSchemaVersion` stays 2, as it did when the AGC pair was added (#4911). An older build that captures over the document drops the array, and the level is 10 again after a downgrade. - **A receiver is identified by its index.** Closing the first of two receivers moves the second to index 0, and the next capture stores its level there. - **The gate reuses two existing declarations.** #6050 added no capability of its own for the off level; `hasAgcThreshold` is documented as "threshold/off level". The field is read and written when the backend declares the `Agc` settings domain and `hasAgcThreshold`. No new boolean (the capability-record count is frozen at 70). 3. **Follow-up #6142, not in this PR: a band-stack bookmark still carries its own AGC.** A bookmark saved in DIGU/DIGL before this change stores the AGC of that time, medium on a stock profile, and recalling it puts medium back (`RadioModel::recallBandStackReceiveDsp`). A bookmark saved after it stores off. Read from the source, not run. 4. **The reason for the default is an offline measurement, and this PR adds no new one.** The decode figures are #5629's: WSJT-X's FT8 decoder fed through the shipped WDSP chain, no radio, no real band, no static crashes. Nothing was received on hardware for this PR. 5. **§18.5 is corrected in place.** ## What changed **The default** (`src/core/backends/hl2/Hl2Backend.cpp`): | event | AGC on that receiver | |---|---| | mode changes from a non-data mode to DIGU or DIGL | the current mode is held, AGC goes to off | | connect restores the receiver into DIGU or DIGL | the remembered mode is held, AGC goes to off; a later link-up after EP6 silence does not repeat it | | the remembered AGC is seeded on a receiver already in DIGU or DIGL | the seeded mode is held, AGC stays off | | a receiver is created from the first one while that is in DIGU or DIGL | it copies the mode, the off and the held mode | | DIGU ↔ DIGL | unchanged | | the same data mode is selected again | unchanged, so an operator's own AGC choice survives | | mode changes from DIGU/DIGL to a non-data mode | the held mode is restored | | the operator changes the AGC mode while in DIGU/DIGL | that is their choice: it is kept on leaving, and nothing is restored | | a threshold change while in DIGU/DIGL | AGC stays off, and the held mode is still what gets remembered | One function, `Hl2Backend::followModeAgc`, holds the rule; every site that writes a receiver's mode or seeds its AGC calls it. **The remembered AGC pair stays the operator's.** It is one flat setting for the radio. The data mode's "off" never reaches it: `setSliceAgc()`, the seed and the capture all record the held mode instead. **The AGC-off level, per receiver** (the ruling): - **Where it is stored.** In the radio's `OperatingState` document in `radio_settings`, scope `(hl2, <MAC>)`, as one array: `"agcOffLevels": [37, 52]`, one 0..100 entry per receiver index, `-1` for a receiver without one. No flat `AppSettings` key. - **The pattern.** `docs/agents/settings.md` names one pipeline for state the radio cannot hold: a typed field in `RestoredRadioState`, written and read only by `RadioStateMemory`, gated by the backend's declaration. The AGC pair uses it, so the level sits beside the pair as `RestoredRadioState::agcOffLevels`. The other shape in that document, the per-domain `ext` sub-objects, holds the per-band maps of `RfGain` and `TxSetpoints` only. - **Capability-shaped.** `RadioStateMemory` loads and stores the array only for a backend that declares the `Agc` domain and `hasAgcThreshold`. There is no family check. - **Capture.** `Hl2Backend::requestSliceAgc()` records the level for that receiver and asks for a capture; `currentOperatingState()` reports the list. - **Restore.** `applyRestoredState()` validates each entry (outside 0..100 is dropped, not clamped) and `seedReceiverAgc()` gives each receiver its own entry at connect, where the AGC pair is seeded. `pushInitialState()` and `finishReceiverDspBuild()` already push the receiver's level to WDSP (#6050). - **A receiver opened later** (`createPanadapter()`) takes the entry for its index, else the default. A receiver that is closed keeps its entry. - **No entry, or no array:** the default of 10. A document written before this change loads as before. - **No migration.** `settings.md` asks for one when a key is renamed or restructured; this is a new optional field. - **Other families do nothing with it.** Flex, Sim and Icom declare no settings domains. ANAN-G2 declares `hasAgcThreshold` but not the `Agc` domain, and RTL-SDR declares neither, so `RadioStateMemory` hands them an empty list and writes no array for them. No backend other than `Hl2Backend` reads the field. **Docs and comments:** - `docs/HERMES.md` §13: item 30 is marked done with the measured result; item 26 no longer lists the `Wideband` tap as open; item 28 records that the HL2's tap-kinds answer is `Demod` only. - `docs/HERMES.md` §18.5: the `tap` paragraph states the measured mechanism, the level step of limit 2, that the level is remembered per receiver, and that the default is HL2-only. - `docs/HERMES.md` §5 (AGC): a paragraph on the per-receiver AGC-off level: field, document, gate, seed, validation. - `docs/architecture/radio-capabilities-map.md`: the `clientSettingsDomains` row names the field, its gate and which families ignore it. - `src/core/backends/hl2/Hl2RxDsp.h` and `src/core/dsp/WdspChannel.h`: the `blockForOutput` comments no longer claim a burst feed is deterministic. ## Tests All without a radio. | test | what it pins | |---|---| | `hl2_tci_signaling_test`, `testDigitalModeDefaultAgc` | the mode-change rows of the table, "the data mode's off is not captured", and the capture fallback before any AGC was set | | `hl2_tci_signaling_test`, `testAgcOffLevelCapture` | without a socket: a restored `{20, 61}`, receiver 0 set to 37 → one capture request and the capture `{37, 61}` | | `hl2_state_restore_test` (default) | restore into DIGU; a second link-up after an operator AGC choice; the seed on a receiver already in DIGL | | `hl2_state_restore_test` (off level) | level 37 on receiver 0 and 52 on receiver 1 → `currentOperatingState()` → `RadioStateMemory::store` → a new backend → `RadioStateMemory::load` → `applyRestoredState()` → connect: each receiver publishes its own level, **each WDSP channel reports the matching fixed gain** (`dspChains()`, what the channel accepted), and the next capture is unchanged. Also: a change asks for a capture; a radio swap does not carry the levels across; a document without the array gives 10 on both receivers and 10 dB in the channel; an out-of-range entry falls to the default and its neighbour holds | | `radio_state_memory_test` | the array round-trips with a hole; it is one array in the `OperatingState` document; a backend without `hasAgcThreshold` is handed none and still gets the AGC pair; a backend without the `Agc` domain is handed none; a store without the capability writes no array; a document without the field loads; a state carrying only levels is not "nothing stored" | | `hl2_pan_create_async_test` | a receiver created beside a DIGU one opens on off and returns to the first receiver's held AGC; a receiver opened later takes the level remembered for its index | **Fail-before, the off level.** The new tests on `bbddff97`'s source, plus the one struct field they need to compile (a throwaway commit, full suite): ten checks red. ``` [FAIL] an AGC-off level change asks for a capture [FAIL] the capture carries each receiver's AGC-off level [FAIL] a capture before the connect keeps the remembered AGC-off levels [FAIL] a new backend restores each receiver's own AGC-off level [FAIL] and each WDSP channel accepted the restored fixed gain [FAIL] the restored AGC-off levels are captured again unchanged [FAIL] an out-of-range AGC-off level falls to the default; its neighbour holds [FAIL] the per-receiver AGC-off levels round-trip, holes included [FAIL] they are one array in the radio's OperatingState document [FAIL] a new receiver opens on the AGC-off level remembered for its index ``` Two mutations of the head, each in a full-suite run: | mutation | red checks | |---|---| | `seedReceiverAgc()` does not write the receiver's level (the restore) | 4, all in `hl2_state_restore_test`: the restore, the WDSP gain, the second capture, the out-of-range case | | `currentOperatingState()` does not report the levels (the capture) | 5, all in `hl2_state_restore_test`: the capture, the capture before connect, and the three restore checks that depend on it | **Fail-before, the default** (earlier heads): the mode-change checks were red on main when the PR was opened (5 of 12); restore into DIGU, the seed in DIGL and the pan seed were red on `540e149e`'s source. **Full suite at the head** (macOS, Apple Silicon, Qt 6.12.0, RelWithDebInfo): 612 of 615 at `-j8`, the same three on the serial rerun. None is from this PR: - `hl2_pan_create_async_test`, one check, "the reuser has its own chain" (#6113). It fails on clean main at this branch's merge base `d9de044c`, and passes in the tree merged with main at `5fae2f86`. - `peripheral_auth_dialog_test` (#6008). It fails on clean main at `d9de044c` and in the merged tree. - `vkamp_connection_test`, one check, "out-of-range antenna ports never reach the wire": the #5254 race in the test. It passed in the run of the same sources one commit earlier (the last commit is docs only). The tree merged with main at `5fae2f86` (a throwaway merge, not pushed): 616 of 617, `peripheral_auth_dialog_test` only. `git merge-tree` against main at `52b6f7ad` is clean; that tree was not built. **Static checks,** every step of the "Static checks" job of `static-checks.yml` run locally at the head: all pass, including the capability-record convention (70 booleans, unchanged) and the QSettings ratchet. No `src/gui` file is touched, so the a11y step has nothing to scan. The "Sanitizer option configures" job was not re-run for this head; no CMake file changed. ## Not verified - **On a radio.** No HL2 was connected and nothing was decoded. - **Across a real restart of the app.** The tests go through `RadioStateMemory` and the settings store in one process, with a new backend object. `RadioModel`'s debounced capture and its flush on disconnect and on quit are the existing path for every field and were not driven for this one. - **The level drop.** The 29 dB in limit 2 is arithmetic on two settings. - **The AGC mode push.** The tests pin the AGC mode the backend holds and publishes; that WDSP receives the mode is not asserted. The fixed gain is asserted from the channel. - **More than two receivers, and closing a receiver,** for the off level. The index rule in limit 2 is read from the source. - **Band-stack recall** (limit 3) is read from the source, not run. - **Static crashes and other asynchronous interferers**, which #5629 named as the one regime where AGC off could behave differently. - **Windows** locally. macOS by the author; Linux in the maintainer pass (merged with main at `a8cfd9901`: the socket-free suite, 507 of 508, the one failure the known local `connection_panel_size_test`, #5811). Windows only through CI. 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>
Summary
On a Hermes-Lite 2, two receive controls had no route to the DSP that runs on this host:
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.apf=/apf_level=). That is why HL2 declaredhasAudioPeakingFilter = falseand the APF row stayed hidden, although WDSP builds a peaking-filter stage into every receive channel.Seam
SliceModel::setAgcOffLevelemitsreceiveAgcRequested(SliceAgcRequest::Field::OffLevel)→IRadioBackend::requestSliceAgc(aetherd: migrate receive controls behind the backend seam #5907).Hl2Backendnow overridesrequestSliceAgc: it handlesField::OffLevelitself and passes every other field toIRadioBackend::requestSliceAgc(the pairedsetSliceAgc). No new verb.IRadioBackend::setSliceApf(sliceId, on, level)is a defaulted no-op.SliceModelemitsapfCommandIssued(on, level)from the operator setters only, never from status application (Principle II), andRadioModel::wireBackendReceiverState()routes it next to the squelch route. It folds into aetherd: consolidate slice receive-control routing #5919'sSliceDspRequest::Feature::Apfwhen aetherd: consolidate slice receive-control routing #5919 lands.apf=/apf_level=text from SliceModel is byte-identical, and itsagc_off_level=text comes fromFlexBackend::requestSliceAgcas on main. Icom, RTL, Sim and ANAN keep the base no-op and still declarehasAudioPeakingFilter = false.agcOffLevelbranch inSliceModel::applyChanges()gets the same unchanged-value guard as the threshold beside it. HL2 now echoes the off level on every slice-state publish, andAgcCalibrationDialogwiresagcOffLevelChangedto the calibrator.Maintainer decisions (KK7GWY, 2026-10-02): (1) the AGC-off level uses main's
requestSliceAgcroute, not a parallel verb; (2)setSliceApfis accepted as an interim verb until #5919's request type replaces it.HL2
SetRXAAGCFixed, via a newWdspChannel::setAgcFixedGain(). The mapping isgain_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.apfshadow:SetRXASPCWSelection/Run/Freq/Bandwidth/Gain, declared inaether_wdsp.h), viaWdspChannel::setApf():open()selects the double-pole explicitly. An unchangedsetApf()returns before the control handshake and the FFTW planner lock.hasAudioPeakingFilteris nowtruefor HL2. The caps map row says the host runs it.Configand applied again on channel rebuild, reconnect and new panadapter, the same way the noise blanker is.dsp.chainsas what WDSP last accepted (apfRun,apfCenterHz,apfBandwidthHz,apfGain,agcFixedGainDb).Tests
Both new tests are socket-free and registered in
tests/tests.cmake.slice_apf_agc_off_seam_test(a recording backend installed throughRadioModel's production receiver bindings):RadioModelroutes the APF tosetSliceApfand the off level torequestSliceAgc(Field::OffLevel)for the right slice, including through the AGC-T knob and the AGC-T calibrator's sweep and restore.OffLevelrather than callingsetSliceAgc.hl2_apf_agc_off_test(a realHl2RxDspand WDSP channel, measured audio):setApf()that does not wait on the planner lock;Hl2Backend:setSliceApf/requestSliceAgc(OffLevel)→ WDSP → published slice state anddsp.chains. A pitch set outside CW is the centre on entering CW, and aModerequest still reaches the chain's AGC and leaves the off level alone.Hl2Backend::requestSliceAgcnot handlingOffLevel;Results at the head (Linux, RelWithDebInfo):
reroute_dead_controls_test); the one failure isconnection_panel_size_test, which fails locally (Four tests fail deterministically on clean main locally: the a11y announcement fixture never receives focus (offscreen) #5811).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.
apfRunfalseapfRuntrue, 600 Hz (pitch 600), 100 Hz, gain 2apfRuntrue, 600 Hz, 100 Hz, gain 2apfRunfalse, model still onapfRuntrue, 600 HzNot covered
Update against main
Merged
maininto this branch (no rebase). The conflicts were adjacent additions next to #5982's squelch, #5980's pan averaging and #5981's filter length inHl2Backend,Hl2RxDspandWdspChannel, plus test registrations; both sides were kept.SliceModel::setAgcOffLeveltook 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 reachesrequestSliceAgc, whichHl2Backendpasses to the base for every field butOffLevel.Refs #5262, #5917.
73, ON8ST
🤖 Generated with Claude Code