Skip to content

feat(hl2): route APF and the AGC-off level to WDSP. Principle VIII. - #6050

Merged
ten9876 merged 14 commits into
aethersdr:mainfrom
on8st:hl2/apf-and-agc-off-level
Oct 3, 2026
Merged

ten9876 merged 14 commits into
aethersdr:mainfrom
on8st:hl2/apf-and-agc-off-level

Conversation

@on8st

@on8st on8st commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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 (aetherd: migrate receive controls behind the backend seam #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 aetherd: consolidate slice receive-control routing #5919's SliceDspRequest::Feature::Apf when aetherd: consolidate slice receive-control routing #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):

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

on8st and others added 4 commits September 30, 2026 16:23
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>
@on8st
on8st requested a review from a team as a code owner September 30, 2026 14:35

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. Connect. createPanadapter → pushApf(r) with the default 600 Hz; receiver 0 is in USB.
  2. Operator sets the CW pitch to 800 Hz. setCwPitch(800) skips receiver 0 (not CW). Hl2RxDsp::m_apfCenterHz stays 600.
  3. Operator switches the slice to CW with APF on. setSliceMode pushes 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 the setShift in setSliceMode's if (r->dsp) block, or
  • move pushApf(r) above the cwBfoHz(...) == 0.0 → continue in setCwPitch, 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::setApf has no unchanged-parameters early-out (inline on WdspChannel.cpp:787). SetRXASPCWFreq/Bandwidth/Gain each land on SetRXADoublepoleFreqs/Gain, and doublepole.c has no guard of its own — each one runs CalcDoublepoleFilter, which rebuilds a ≥2048-tap FIR through build_doublepole_2sided's fftw_plan_dft_1d(..., FFTW_PATIENT). So one setApf() call is three full redesigns, under g_setupMutex (which is fftwPlannerMutex() — I checked, there is no planner race here), on the I/O thread that paces EP2. applyApf() now fires on every setMode, every installChannel, pushInitialState, createPanadapter, setCwPitch and every slider step, and open() 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. setMinimumPhase in the same class already has exactly this early-out and cites exactly this planner cost. return true when enabled/centerHz/bandwidthHz/gain all match m_config would remove essentially all of it.
  • docs/architecture/radio-capabilities-map.md:150 now contradicts the code (inline on Hl2Backend.cpp:2409). That row still reads hasAudioPeakingFilter | ✅ | ❌ | ❌ with HL2 as the second column, and its Effect text says the row's only effect is Flex slice 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, ...) at slice_apf_agc_off_seam_test.cpp:224 asserts nothing and prints [PASS]. It proves the base verbs compile and don't crash, which is worth keeping — but MinimalBackend could record and the test could assert nothing was recorded, or the line could be a comment rather than a green check.
  • SetRXASPCWSelection is declared in aether_wdsp.h:211 and never called. The design depends on selection staying at RXA.c's construction default of 0 (double-pole) — which I verified in RXA.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. xdoublepole in mode 2 (which is how RXA.c builds it, RXA.c:443) does in[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. applyChanges guards 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 new d.agcOffLevel guard is not cosmetic: AgcCalibrationDialog.cpp:280 wires agcOffLevelChanged straight into AgcTCalibrator::onValueChanged, and HL2 now publishes that field on every emitSliceState. 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. SetRXAAGCFixed does fixed_gain = pow(10, dB/20) (wcpAGC.c:549) and xwcpagc multiplies by it only under if (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/990 and VfoWidget.cpp:2116/2121 both 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_nc divides 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_doublepole is built with mode 2 and xdoublepole mode 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: setApf takes g_setupMutex but not fftwPlannerLock(), and the doublepole design creates plans. WdspChannel.cpp:59 binds g_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), so create() fails on the new APF predicate and not incidentally. Non-vacuous.
  • Rebuild and install ordering. installChannel applies the AGC-off level and the APF after setMode, which is required because apfInCircuit() reads m_config.mode; setMode assigns m_config.mode before calling applyApf(); setCwPitch assigns m_cwPitchHz before 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().hasAudioPeakingFilter was 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/setAgcFixedGain refuse a transmit channel. I found no Flex-visible change beyond the agcOffLevel echo 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

Comment thread src/core/backends/hl2/Hl2Backend.cpp Outdated
Comment thread src/core/dsp/WdspChannel.cpp
Comment thread src/core/backends/hl2/Hl2Backend.cpp
Comment thread tests/slice_apf_agc_off_seam_test.cpp Outdated
Comment thread third_party/wdsp/include/aether_wdsp.h
on8st and others added 6 commits September 30, 2026 18:14
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>
@on8st

on8st commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

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 463386b0. No rebase and no force-push.

Bad news first.

  • The blocker was a real defect, and our test walked straight past it. setCwPitch() sent the APF only to receivers already in CW. 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. That contradicts pushApf()'s own comment, which says the APF is "sent unconditionally". Section 6 of hl2_apf_agc_off_test went through exactly that sequence. It read the centre only after a setSliceApf() that re-pushed the pitch, and that hid the fault. Your diagnosis is correct in every detail.
  • One premise in the setApf nit does not hold for the vendored WDSP. I added the early-out anyway, for a different reason. CalcDoublepoleFilter has its own unchanged-parameters guard (third_party/wdsp/upstream/doublepole.c:263). It builds the FIR analytically (build_doublepole_1eff, doublepole.c:142, called at :272), and it plans an FFT only when the length changes (setNc_fircore, :274). build_doublepole_2sided is not on this path. A no-op setApf() therefore never caused "three full redesigns". A pitch or level change that really moves the design rebuilds it once per parameter that moved. What a no-op call did cost is below.
  • The claim in our own aether_wdsp.h comment that "every call rebuilds the impulse response" was wrong for the same reason. It is corrected in 4aac7f1f.

Hl2Backend.cpp:5398, stale APF centre (38e3760a). I took your second option. pushApf(r) now runs for every receiver before the not-CW continue in setCwPitch() (Hl2Backend.cpp:5400). That is what its contract already said. Hl2RxDsp therefore never holds a stale centre, whichever path runs it later: a mode change, a rebuild or a reconnect. setSliceMode() alone would have covered only the mode change. The test now reads apfCenterHz right after entering CW, with nothing in between (tests/hl2_apf_agc_off_test.cpp:444). It also runs your sequence with the APF on: pitch moved in LSB, then back to CW, centre checked (:475).

WdspChannel.cpp:787, early-out (0947d1dc). setApf() now returns true before the control handshake and the lock when the request matches what WDSP holds (WdspChannel.cpp:800). This is the setMinimumPhase() shape. With WDSP's own guard in place, the saving is the lock, not a redesign. g_setupMutex is the process-global FFTW planner lock, and it was taken on the I/O thread that paces EP2. A no-op call made during another channel's rebuild waited out that rebuild's planning and changed nothing. A no-op call could also be refused while a callback was in flight. Exact equality is correct here, because m_config is written only by an accepted setApf() or open(), and nothing else writes SPCW. The test is section 2b (tests/hl2_apf_agc_off_test.cpp:245). It holds the planner lock itself and makes an unchanged call, which must return within 2 s. Without the early-out, that call blocks until the lock is released. The check then fails instead of hanging.

docs/architecture/radio-capabilities-map.md:150 (d04f80cc). The row now shows HL2 ✅ and says who runs the filter on each family. It also records that on HL2 the flag is an exception to hasRadioSideDsp, in the same shape as hasHostNoiseBlanker, and not a tier under it: the HL2 has no firmware DSP, and the P/CW gate reads this flag alone. The tier table's "Means" cell at :312 is widened to match.

slice_apf_agc_off_seam_test.cpp:224, check(true, …) (67645086). A base verb has no state to read back. So "no-op" can only mean what an observer sees: it emits nothing. The test now spies every signal in the backend's meta-object, calls both verbs, and asserts that no signal fired. It also asserts that more than one signal was being watched, so an empty spy list cannot pass it (tests/slice_apf_agc_off_seam_test.cpp:241, :243).

aether_wdsp.h:211, SetRXASPCWSelection (4aac7f1f). I chose to call it: open() now sets selection 0 explicitly, before the other SPCW calls (WdspChannel.cpp:1360). I did not drop the declaration. Everything this PR reasons about is the double-pole: apfParametersValid()'s divide by the centre, the level map against calc_dpole_nc, and one positive centre serving CWL and CWU through the mode-2 I-into-Q copy. If a WDSP update started create_apfshadow elsewhere, every SPCW call would land silently on the matched or gaussian filter, and both scale the gain by √2. Dropping the declaration would leave that assumption in place. Calling it turns the assumption into an invariant. To be clear about the test coverage: on today's WDSP the call is a no-op (apfshadow.c:48 returns early when the selection is unchanged), so no test can fail when it is removed. I then checked whether the existing measured tests would notice a wrong selection. They do not. With open() forced to selection 1 (matched), every measured APF check still passed, including the >10 dB lift and the CWL centre. So the explicit call enforces the invariant, and no test pins it. I didn't add one, because the only differences I could measure would be tied to a particular filter's exact response.

Review body, the other points.

  • Issue fit. No issue describes this defect, so there is no Fixes line. I searched the tracker for the APF, the peaking filter, agc_off_level and the AGC-off level. The work is G2 of our per-radio inventory of silent controls, summarised in our #5262 comment, which names APF among the non-Flex slice controls that reach nothing. The nearest open issue is aetherd: consolidate slice receive DSP and audio control routing #5917, "consolidate slice receive DSP and audio control routing". Its scope includes moving APF encoding behind IRadioBackend, and aetherd: consolidate slice receive-control routing #5919 implements it. That is the routing half of this PR, not the HL2/WDSP half. Level AGC-OFF don't working when use from an external midi interface #5384 (AGC-OFF level from a MIDI controller, closed) is a different defect. I'll add Refs #5262, #5917 to the body.
  • New seam verbs as public surface. Agreed that they need a maintainer's nod. The offer in the body stands: I can split feat(seam) into its own PR, or fold the two verbs into aetherd: consolidate slice receive-control routing #5919's request types when that lands.
  • Neither value is persisted or restored. That is deliberate and matches the noise blanker's precedent. After a reconnect the off level returns to 10 (the 10 dB AGC Off every HL2 ran before this PR) and the APF returns to off. I'll add one line to the body saying so.
  • The APF discards Q. Confirmed: xdoublepole mode 2 overwrites Q with I before filtering (doublepole.c:208-211). WDSP's panel stage is built with no copy (RXA.c:542), so without the APF the two output channels are I and Q. With it running, both are derived from I. We have not measured whether that difference is audible on this path in CW. It is now documented at the seam, in the setApf() comment in WdspChannel.h (4e95237a). There is no behaviour change.
  • Neither new test is in a ctest -R step in ci.yml. Correct. The per-PR gate is frozen (check_ci_test_gate.py), so the measured assertions are local evidence. The results are below.

Verification. Each new check fails against a throwaway local commit that undoes its fix: pushApf back below the continue, the early-out disabled, and the base setSliceApf emitting sliceChanged. In that build, hl2_apf_agc_off_test fails on exactly its three new checks (2b and both centre checks), and slice_apf_agc_off_seam_test fails on exactly its new check. The other 554 tests pass. Full ctest at 4e95237a: 556/556 passed (Ninja, Qt 6.8.3, RelWithDebInfo, macOS arm64). The static checks pass at 4e95237a: engine boundary, touchpoint manifest, seam probe table and its scanner, test registration, capability records (71, frozen at 71) and their parser, command plane (413/413), CI test gate, network timeouts, theme seed and shader dialects. No src/gui file changed. Nothing was transmitted.

73, ON8ST

@ten9876

ten9876 commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

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.

@ten9876 ten9876 self-assigned this Oct 3, 2026
ten9876 and others added 3 commits October 2, 2026 19:16
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 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at 0d734cb5 (maintainer landing pass).

Maintainer decisions (KK7GWY, 2026-10-02):

  1. The AGC-off level uses main's typed route (#5907), with no parallel verb. Hl2Backend::requestSliceAgc handles Field::OffLevel and passes every other field to IRadioBackend::requestSliceAgc. agcOffLevelCommandIssued and setSliceAgcOffLevel are gone, and SliceModel::setAgcOffLevel is exactly main's. Done in a new commit, 9fa82a48, with no history rewrite.
  2. IRadioBackend::setSliceApf is accepted as an interim verb that folds into #5919's SliceDspRequest::Feature::Apf when #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::setAgcOffLevel took main's version.
  • 4eb5da20 brought in #6013 and 0d734cb5 brought 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.
  • setApf early-out: 0947d1dc. §2b passes.
  • Caps-map row: d04f80cc.
  • check(true): 67645086. Now rewritten for the requestSliceAgc route; it also asserts the base drops OffLevel.
  • SetRXASPCWSelection called in open(): 4aac7f1f.
  • Persistence and mono notes: in the body and the WdspChannel.h comment.

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::requestSliceAgc not 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.

@ten9876
ten9876 merged commit 6fdadfa into aethersdr:main Oct 3, 2026
5 checks passed
rfoust pushed a commit that referenced this pull request Oct 3, 2026
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.
ten9876 added a commit that referenced this pull request Oct 3, 2026
…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>
ten9876 added a commit that referenced this pull request Oct 4, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants