Skip to content

feat(hl2): make FFT AVG average — backend-side, as #5782 ruled. Principle VIII. - #5980

Merged
ten9876 merged 9 commits into
aethersdr:mainfrom
on8st:hl2/pan-averaging
Oct 2, 2026
Merged

ten9876 merged 9 commits into
aethersdr:mainfrom
on8st:hl2/pan-averaging

Conversation

@on8st

@on8st on8st commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes row 2.1 of #5678 for the HL2, and partly row 2.2. It follows the ruling on RFC #5782: the HL2 implements setPanAverage / setPanWeightedAverage and engages backendPanAveraging in the same change, so SpectrumWidget's EMA is skipped and the two averages cannot stack. That stacking is the G2 bug #5814 found.

Design

  • FFT AVG is a time constant, 10 ms per step (0–100 → 0–1 s). That's the ANAN/deskHPSDR unit, so one setting means the same smoothing on both families.
  • Frame weight is 1 − exp(−dt/τ), with dt counted in IQ samples. The time constant no longer changes with the fps slider. The one thing still tied to fps is how much noise is averaged out, because only displayed frames are integrated; averaging every FFT would undo the CPU saving the fps cap exists for. The code says so.
  • q1 (domain): power by default. The weighted toggle selects log-recursive, the same meaning as on the ANAN.
  • q3 (shared helper): not yet. This is the only consumer; RTL would be the second, and that is the time to move it into src/core/dsp/. Recorded in Hl2Spectrum.h and on [RFC] Spectrum averaging on raw-spectrum backends: which layer owns it, and in which domain #5782.
  • Persistence: the setting survives zoom rebuilds and reconnects, and is re-applied when a pan is added. fix(gui): the FFT average never reaches the model on a raw-spectrum backend, so it reverts on a pan rebuild #5754's stored FFT AVG now reaches the DSP at connect.
  • Retune: an NCO move (pan drag, or tuning out of the window) drops the average and the partial window the frame-rate shaper holds, so the next frame is new-axis IQ only. A transport gap drops neither: the pre-gap estimate is held, and the gap does not advance the averaging clock.
  • Visible change: at FFT AVG 0 the HL2 trace is now genuinely unaveraged. It used to get the widget's fixed EMA. The ANAN has behaved this way since feat(anan): compute the panadapter with WDSP's display analyzer #5814.

Row 2.2

Partly addressed. Waterfall rows are the same frames, so with FFT AVG above 0 each row is an exponentially weighted average of roughly the last AVG × 10 ms. It is not an integral over the row interval, and at AVG 0 nothing changes. A true per-row integral needs the waterfall rate passed to the backend plus a second accumulator, so it is left for its own change (the open question on #5782).

Tests

hl2_pan_averaging_test:

  • Variance reduction matches theory: ratio 0.994 at τ = 100 ms and 0.996 at 300 ms; the unaveraged control reads 0.990.
  • Step response at τ = 500 ms: 512 ms with 21 ms frames and 426 ms with 100 ms frames. A frame-count depth (the control) gives 536 ms and 2268 ms.
  • Log mode reads the noise floor 2.43 dB below power mode.
  • A retune drop forgets the average; a replayed setting keeps it.
  • After a retune drop, the first frame Hl2RxDsp emits equals the raw new-axis frame (0.00 dB apart; 129 dB without discarding the held window).
  • The setting survives an Hl2RxDsp rebuild.

hl2_backend_seam_test: backendPanAveraging engaged at 10 ms/step and the widget EMA skipped for a connected HL2 (control: disconnected, or a backend that does not average). setPanAverage / setPanWeightedAverage are held on the receiver and reach an injected live chain (FFT AVG 30 → 300 ms, weighted → log-recursive), clamp above range, and ignore an unknown pan id.

Mutations, each red: setPanAverage a no-op; the weighted flag not pushed; the smoothing gate inverted; setAverageTimeMs ignored; the log flag ignored in Hl2Spectrum; no re-apply in installChannel; the retune drop keeping the held window.

On the radio

Measured by @on8st on an HL2 (dummy load, noise only, frames read through the automation bridge):

  • τ = AVG × 10 ms: 251 / 523 / 1005 ms at AVG 25 / 50 / 100 (power), 491 ms at AVG 50 (log). At AVG 50 it is 515 ms at 10 fps and 507 ms at 30 fps, so it is a time constant, not a frame count.
  • Variance falls with AVG: frame-to-frame SD of the floor estimate 0.48 → 0.06 → 0.06 → 0.05 dB at AVG 0 / 25 / 50 / 100.
  • AVG 0 is unaveraged: an LNA step lands in the first frame after the command, 99–100 % on both legs; weighted on and off agree at AVG 0 within 0.07 dB.
  • Power − log: 2.64 / 2.62 dB at AVG 50 / 100, against 2.66 / 2.63 simulated.

Not covered on the radio: the trace by eye, a real signal under averaging, and the retune and persistence paths.

🤖 Generated with Claude Code

…ed. Principle VIII.

aethersdr#5678 row 2.1: on the Hermes-Lite 2 the Display -> FFT AVG slider reached
nothing. aethersdr#5814 landed the seam (IRadioBackend::setPanAverage /
setPanWeightedAverage) and aethersdr#5833 the power-domain accumulator in Hl2Spectrum
that nothing turned on. This wires one to the other.

WHAT ONE STEP MEANS: 10 ms of averaging TIME CONSTANT, so 0..100 is 0..1 s
(Hl2Backend::averageTimeMsForStep). The same unit and the same law as the
ANAN's WDSP analyzer (history weight exp(-dt/tau)), so one number means one
time constant on both host-averaged families.

WHY A TIME, NOT FRAMES (the fps coupling on8st raised on aethersdr#5782). Hl2RxDsp's
shaper only transforms DISPLAYED frames, so a depth in frames would span 5x
the time at 5 fps that it spans at 25. Hl2Spectrum now blends each frame with
alpha = 1 - exp(-dt/tau), dt counted in IQ samples since the previous frame
(including the ones accumulate() discards): the stream is the clock. A
continuous exponential sampled at any instants is exactly this recursion, so
moving the fps slider needs no drop and no re-derivation. What stays
fps-dependent, and is documented rather than hidden: the amount of noise
reduction, because only displayed periodograms are integrated. Integrating
every FFT would undo the shaper's saving and wants its own measurement.

q1 (DOMAIN): power by default, log-recursive behind the weighted toggle —
the same meaning the toggle has on the ANAN (off = linear/power, on = log).
The widget's toggle defaults off, so the HL2 default is the arithmetically
correct power average (aethersdr#5794); operators who prefer deskHPSDR's flatter
log look can have it, and a G2 and an HL2 at the same settings agree.

q3 (SHARED HELPER): not now. ANAN needs no accumulator (WDSP has one), so
Hl2Spectrum is the only consumer; lifting it to src/core/dsp/ with one user
is speculative. RTL would be the second, and that is the moment.

§8: Hl2Backend engages RadioCapabilities::backendPanAveraging{10} in this
same change, so SpectrumWidget's SMOOTH_ALPHA EMA is skipped and the two
never stack. The predicate MainWindow asks twice is now one function
(gui/ClientFftSmoothingGate.h) so a test can ask it too. Note the visible
consequence, same as on the G2: at FFT AVG 0 the HL2 trace is now genuinely
unaveraged, where before it carried the fixed 0.35 dB-domain EMA.

State the average must survive, and state it must not:
- Hl2RxDsp holds the setting as members and re-applies it in
  installChannel(): every zoom constructs a fresh Hl2Spectrum, and Config is
  assigned whole, so either home would have lost it on the first span change.
- Hl2Backend::Receiver stores the operator's values and pushes them where the
  noise blanker is pushed (new pan, pushInitialState), so a reconnect or an
  added pan starts at the operator's averaging. The connect-time push from the
  saved widget value (aethersdr#5754) already exists in MainWindow_Wiring.
- An NCO move (setPanCenter, setSliceFrequency leaving the window) drops the
  average via Hl2Spectrum::dropAverage() — the retune hook aethersdr#5833's header said
  its caller would owe. A transport gap still does not drop it.

ROW 2.2 (waterfall row = one snapshot). Rows are the same frames, paced by a
drop in RadioModel, so with FFT AVG > 0 each row is now an exponentially
weighted integration over roughly the averaging time: a burst any pan frame
caught stays in the history instead of surviving only if its own frame hit
the gate. It is NOT an integration over the row interval, it vanishes at
FFT AVG 0, and bursts between pan frames remain unseen. Integrating over the
row interval independently of FFT AVG needs the row rate pushed below the
seam and a second accumulator; left for its own change. RadioModel.h's
comment is updated to say exactly this.

Tests: hl2_pan_averaging_test — mapping; per-bin variance on noise falls by
alpha/(2-alpha) (control: raw CV^2 ~ 1); 63% step response at ~tau for both
21 ms and 100 ms frame intervals, within one interval plus one FFT window
(control: a frame depth tuned to the fast rate is >3x slower at the slow
one); the toggle moves the floor ~2.5 dB
(log vs power); dropAverage() gives the raw frame bit for bit and a replayed
setting keeps the average (control: ghost without a drop); averaging survives
an Hl2RxDsp rebuild. hl2_backend_test: record engaged at 10 ms/step and the
widget EMA skipped for a connected HL2 (control: disconnected / no record).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st
on8st requested a review from a team as a code owner September 27, 2026 14:06

@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

Yes, and it closes the specific half that was left open. #5678 row 2.1's remaining work was "make the stored FFT AVG actually average". RFC #5782's ruling (ten9876, 2026-09-21) set three obligations for whoever wrote the HL2 change, and all three are met in this diff:

  • q2 seam — the two defaulted verbs setPanAverage(panId, int) / setPanWeightedAverage(panId, bool). Signatures match IRadioBackend.h:427/436 exactly; RadioModel::requestPanAverage (RadioModel.cpp:6383-6391) and requestLocalPanWeightedAverage (:6404-6421) already route to them through shapesDisplayRatesLocally(), so no new plumbing was needed and none was added.
  • §8, the load-bearing one — "HL2 will need to engage that record at the same moment it gains backend-side averaging, not afterwards, or it reproduces the stacking on the G2 that #5814 found." c.backendPanAveraging = BackendPanAveraging{kMsPerAverageStep} lands in the same commit as the averaging. Pinned by the new hl2_backend_test check.
  • q1 domain — power by default, log only behind the weighted toggle, which is #5794's arithmetic carried forward rather than re-litigated.

The one RFC question this PR answers silently is q3 — see Nits.

2. Scope

File / group What it changes Claimed? Verdict
Hl2Spectrum.{h,cpp} setAverageTimeMs / setLogAverage / dropAverage / blendAlpha, sample-rate ctor arg, m_samplesSinceFrame Yes — the averaging itself In scope
Hl2RxDsp.{h,cpp} m_spectrumAverageMs/m_spectrumLogAverage members + re-apply in installChannel() Yes — RFC §8 bullet 1 verbatim In scope
Hl2Backend.{h,cpp} the two seam overrides, kMsPerAverageStep, pushPanAveraging, dropPanAverage, the capability Yes In scope
RadioCapabilities engagement backendPanAveraging for HL2 Yes — the §8 obligation In scope
ClientFftSmoothingGate.h (new) extracts the widget-EMA predicate MainWindow asked in two places Implied by the gating test In scope — see note below
MainWindow.cpp both call sites route through the new gate Yes In scope, behaviour-identical (verified below)
docs/architecture/radio-capabilities-map.md, docs/theming/canonical-tokens.md caps-map row + token doc AGENTS.md requires the caps-map row In scope
RadioModel.{h,cpp} comment corrections only — no code Yes (they were stale the moment this landed) In scope
tests/hl2_pan_averaging_test.cpp, tests.cmake, hl2_backend_test.cpp new test + gating checks Yes In scope

Nothing in the diff is unexplained by the issue and the RFC ruling. No CHANGELOG.md entry (correct). No new settings key, no new wire verb, no new dependency, no TX-path contact. ClientFftSmoothingGate.h is new source surface but not new public/protocol surface — a constexpr free function in AetherSDR, no Qt type, mirroring PanZoomModeGate.h in the same directory, and AGENTS.md's capability rule wants a gating test that can ask the predicate without linking the GUI. I'd have accepted the inline expression too, but this is not smuggled scope.

Preference check: no default moves. Hl2Spectrum still constructs at m_averageTimeMs = 0 / m_logAverage = false, the widget's weighted default is still false (SpectrumWidget.h:1620), and Receiver::panAverage = 0. A fresh HL2 connect looks exactly as it does today until the operator moves the slider. That is the property that makes this a feature and not a restyle.

3. Blockers

None.

4. Nits (non-blocking)

  • RFC #5782 q3 is answered by deletion rather than by a decision. ten9876's ruling says q3 (a shared accumulator in src/core/dsp/, which the triage read as "src/core/dsp/ is right") is "genuinely the HL2 change's to answer." This PR answers it — keep it in Hl2Spectrum — and removes the paragraph in Hl2Spectrum.h that recorded q3 as open, but never states the call or its reasoning. With RadioModel.cpp:6376 now reading "a backend that does not override setPanAverage() (RTL) still does no averaging", the next RTL change writes this a third time, which is the duplication RFC §5 rejected Option B for. Worth one sentence in the header saying the accumulator stays HL2-private and why, plus a comment on #5782 closing q3 so it does not sit open against a PR that already decided it. This is a maintainer call, not a code defect.
  • A transport gap's effect on dt is undocumented. reset() (the spectrumGapDiscards path, Hl2RxDsp.cpp:666) clears m_acc but touches neither the average nor m_samplesSinceFrame. So after a multi-second EP6 dropout the first frame back gets dt ≈ 21 ms, not the real gap length, and the pre-gap average survives the outage nearly intact. I think this is the right answer — the signal during a gap is unknown, so holding the last estimate beats inventing a decay — and "the stream is the clock" in setAverageTimeMs()'s block implies it. But the reset() paragraph argues at length about retune-vs-gap wanting opposite answers and then says nothing about what a gap does to the average's clock, which is the one question a reader arrives at that paragraph with.
  • Neither new check runs in CI. grep ctest .github/workflows/ci.yml shows filters for only aether_dv_waveform_no_args|thumbdv_queue_test|dstar_model_test|digital_voice_waveform_process_test, cross_needle_meter_test, mac_nr_filter_test and asr_gpu_probe_test. hl2_pan_averaging_test and hl2_backend_test compile in CI and never execute there. That is the project's standing setup and not this PR's doing — recording it because the whole evidentiary weight of this change rests on the author's local run of a test no gate will re-run.

CodeGuard: all five CG-PATH-001 hits are refuted. MainWindow.cpp:9540-9542 is a QLabel string for the NR2 FFTW-wisdom dialog, :9654 a setText in its cancel handler, and :9773-9775 the macOS bundle walk QDir(QFileInfo(QCoreApplication::applicationFilePath()).dir()); d.cdUp(); d.cdUp();. None is attacker-influenced, none is within ~3000 lines of either hunk this PR touches in that file. Dropped.

Socket-test disclosure: none. hl2_pan_averaging_test opens no socket — it links Qt6::Network only because aethercore does, constructs a bare QCoreApplication, and drives Hl2Spectrum/Hl2RxDsp directly. No QTcpServer/QUdpSocket/bind/listen/connectToHost, no peer process, no Fake* radio class. Correct layer per AGENTS.md: DSP arithmetic → socket-free CTest.

5. What I tried to break

Findings below are reasoned from the head checkout at /tmp/aetherclaude/pr-5980; I have no build and ran nothing.

  • Double-averaging, the failure #5814 hit on the G2. Chased every consumer of the operator's number above the seam. m_fftAverage is written by setFftAverage and read only by serialization (SpectrumWidget.cpp:1043, :1771) and menu re-sync (:2632, :3180, :3187) — no render path. The only client average is SMOOTH_ALPHA at SpectrumWidget.cpp:8449, gated by m_clientFftSmoothing at :8435, which is what the new gate turns off. So exactly one average survives. Held.
  • The MainWindow refactor's equivalence. Old: !(connected && caps...has_value()). New: clientFftSmoothingEnabled(connected, has_value()) → !(connected && backendAveragesPan). The onConnectionStateChanged hunk drops connected && from the local, but the gate re-ANDs it. Identical truth table on all four inputs, and the disconnect fallback (widget smooths again) is preserved. Held.
  • Retune ghosting — the one #5794 named as unhandled. Read both dropPanAverage call sites in full, not just the diff context. setPanCenter (Hl2Backend.cpp:4133) already early-returns on hz == r->ncoHz, so a 30 Hz drag pushes the drop only on frames where the NCO actually moved, and setSliceFrequency (:3520) drops only inside the std::abs(hz - r->ncoHz) > usableHz branch where the NCO re-centres. Neither fires on a re-push of an unchanged value, so a settings replay cannot silently wipe a running average. Held. I looked for a third NCO-moving site and found none — setPanBandwidth and every rate change go through configure() → buildChannel() → a fresh Hl2Spectrum, where there is nothing to drop.
  • Survival across a zoom, RFC §8 bullet 1's exact trap. installChannel (Hl2RxDsp.cpp:270-276) re-applies from m_spectrumAverageMs/m_spectrumLogAverage, not from Config — and m_config is field-assigned right above, so a setting parked in Config would indeed have been lost. The no-op guards cooperate rather than fight: on a fresh object setAverageTimeMs(0) early-returns (nothing to drop) while setAverageTimeMs(300) applies, and the two setters clear each other's mode (setAverageFrames(1) still takes effect when a time is in force, because the guard also tests m_averageTimeMs == 0.0). I walked all four combinations of (time set / frames set) × (replay / change) looking for a state where averaging silently reverts to none, and found none.
  • Log/power state contamination. setLogAverage drops on every flip, and logDomain = blending && m_logAverage means a m_logAverage = true left standing while averaging is off cannot leak a dB value into the non-blending path. m_haveAverage is set after the loop and only if (blending), so a pass with averaging off cannot leave a stale "we have a state". The continue in the log branch skips the power-domain log, correctly — that branch emits the dB state directly. Held.
  • The parity claims in the header, which are the ones I most expected to be decorative. They are not. AnanBackend.cpp:39 is also kMsPerAverageStep = 10, so "10 ms on both" in the caps-map row is real. AnanBackend::setPanWeightedAverage (:1255-1265) maps on → setSpectrumLogAverage(true) = kAverageLogRecursive, off → linear-recursive — so "on = log, off = power, the same meaning as ANAN" is accurate. One wrinkle I chased: AnanPanAnalyzer::Settings::logAverage defaults to true, which would make ANAN's default log and HL2's power — but MainWindow_Wiring.cpp:3208 pushes requestLocalPanWeightedAverage(panId, sw->fftWeightedAvg()) (false) on every pan add, so the analyzer's internal default is overridden at connect and the two families do agree in practice. Held, though it holds because of that push rather than by construction.
  • The caps-map row's columns. The table at docs/architecture/radio-capabilities-map.md:109 is | Field | Flex | HL2 | Sim |, so | absent | **10 ms** | absent | puts 10 ms on HL2 and leaves Flex/Sim untouched. ANAN appears in the prose, as it does on every other row. Correct.
  • The test, attacked as an implementation mirror. It is not one. §1 is static_assert on pure arithmetic plus the composition identity (1-α_{dt/2})² == (1-α_dt), which is the property that makes the setting a time and would fail for any frame-count implementation. §2 checks measured per-bin CV² against α/(2-α) — the closed-form steady-state variance of an EMA of i.i.d. exponentials — with the un-averaged spectrum as a positive control at CV² ≈ 1, so a broken average shows up as a ratio miss, not as a hand-tuned threshold. §3's control is the decisive one: it runs the same step through setAverageFrames(depth) and asserts depthSlow > 3 × depthFast, i.e. it proves the fixture can see the fps coupling it claims the timed path removes. §4's ±2.5 dB target is γ·10/ln10 = 2.507 from E[ln X] = ln E[X] − γ, not a recorded output. §5's bins == raw bit-for-bit assertion is genuinely exact — I traced both paths and the blending branch's first frame computes state = power; power = state; then the identical 10·log10(power + 1e-24), same doubles, same float. Emptying dropAverage() breaks §5; emptying setAverageTimeMs() breaks §1, §2, §3 and §6. This test earns its conclusions.
  • dt == 0. blendAlpha returns 0 for non-positive dt, which would freeze a bin. Can computeFrame ever see m_samplesSinceFrame == 0? process() increments before the == m_fftSize boundary test, so at least one sample is always counted, and back-to-back frames inside one oversized block each get fftSize. No reachable zero. Also checked m_samplesSinceFrame for unbounded growth at fps 0 (accumulate-only): uint64_t at 384 kHz is not a concern.
  • What I could not check: anything on real hardware, and any claim about how this looks on a waterfall. The PR body's on-air / local-ctest numbers I could not reproduce — and per the nit above, no CI gate re-runs the new test either, so the maintainer is taking the author's local run on trust. The m_haveAverage/dropAverage allocation-free promise I verified by reading (assign on a construction-sized vector) rather than by measuring.

6. Recommendation

Approve with nits. This is the change RFC #5782's ruling asked for, it lands the §8 capability engagement in the same commit as the averaging it gates (the specific failure mode the ruling warned about), and the new test is theory-anchored with real controls rather than a mirror of the implementation. I attacked the double-average path, both retune sites, the zoom-survival trap the RFC named, the domain-contamination cases, and the cross-family parity claims, and all of them held.

Concrete next step: ask @on8st for one sentence in Hl2Spectrum.h recording the q3 call (accumulator stays HL2-private, and why) and a comment on #5782 closing q3, so the RFC's last open question isn't left looking unanswered against the PR that answered it. Neither needs to hold the merge.

Thanks for the honesty in the header about what the time constant doesn't fix — the "RESPONSE time is fps-invariant; NOISE REDUCTION is not" paragraph is the sentence a future reader will need, and it would have been easy to leave out.


🤖 aethersdr-agent · cost: $9.1873 · model: claude-opus-5

Comment thread src/core/backends/hl2/Hl2Spectrum.h Outdated
Comment thread src/core/backends/hl2/Hl2Spectrum.h Outdated
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (5 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5980/src/gui/MainWindow.cpp:9542
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5980/src/gui/MainWindow.cpp:9654
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5980/src/gui/MainWindow.cpp:9773
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5980/src/gui/MainWindow.cpp:9774
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5980/src/gui/MainWindow.cpp:9775

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $9.3674 · model: claude-opus-5

…stream clock

Review on aethersdr#5980:
- q3 was closed by deleting the paragraph that held it open. State the
  decision where it was: the accumulator is HL2-private on purpose (one
  consumer; the RTL backend would be the second, and that change should move
  it to src/core/dsp/ rather than write it again).
- Say what a transport gap does to the averaging clock: reset() leaves
  m_samplesSinceFrame and the average alone and lost samples are never
  counted, so the pre-gap estimate is held and decays in stream time.
  Deliberate.

Comment-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Receive-side measurement on real hardware: the repaired HL2, this PR in an integration build (3e6c37f5), antenna input on a dummy load (noise only), frames read through the automation bridge.

Two expectations failed, first:

  • Within-frame spread at AVG 100: it was 6.4 dB (power) and 6.9 dB (log) against the ≤ 6 dB I had set in advance, and slightly above AVG 50 (5.6 and 6.5 dB). It is marginal, but it is not the monotonic fall I expected.
  • My control failed. An LNA gain step at AVG 0 took about 340 ms to settle, where I expected at most about 80 ms, i.e. two frames. So something other than FFT AVG smooths or settles after an LNA change. I have not explained it yet, and I do not attribute it to this PR. It does mean the step-response figures below measure averaging on top of that settling.

What holds:

  • The time constant is AVG × 10 ms, as the PR states: τ was 251 / 523 / 1005 ms at AVG 25 / 50 / 100 (power), and 491 ms at AVG 50 (log). At AVG 50 it is the same at 10 fps and 30 fps (515 and 507 ms), so it is a time constant, not a frame count.
  • Variance falls with AVG. The floor estimate's frame-to-frame SD went 0.48 → 0.06 → 0.06 → 0.05 dB (AVG 0/25/50/100, power), and similarly in log.
  • AVG 0 is unaveraged. The floor SD and spread match an unaveraged simulation. Weighted on and off agree at AVG 0 (within 0.07 dB).
  • Power minus log at AVG 50/100 is 2.64 / 2.62 dB, against 2.66 / 2.63 simulated. The PR's fixture reads 2.43.

Not covered: how the trace looks to an eye (the pan grabs came back identical, so that check was undetermined), a real signal under averaging, reset and persistence.

73, ON8ST

@on8st

on8st commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my measurement comment above. The failed AVG 0 control was my fitting script, not the
radio. At AVG 0 the LNA step is complete in the first frame after the command, 99–100 % on both legs, so
nothing else smooths or settles. My fit only looked at frames from 150 ms onward, where the step was
already over, and fitted a time constant to noise. The 5 s "down" value is the edge of its search grid.
Anchoring the fit to the pre-step level gives 5 and 14 ms. The AVG 25/50/100 time constants and the
10-vs-30 fps result stand as posted. Please disregard the sentence that they measure averaging on top of
other settling.

73, ON8ST

Main's comment sweep (aethersdr#6072) condensed the blocks this branch had
rewritten, and its test re-homing deleted tests/hl2_backend_test.cpp.

Conflicts and how each was resolved:
- Hl2Backend.cpp: main now holds the receiver by pointer in
  finishReceiverDspBuild, so pushPanAveraging beside pushNoiseBlanker
  takes *r.
- Hl2Backend.h, Hl2RxDsp.h: main's condensed comments, this branch's
  declarations beside them.
- Hl2Spectrum.{h,cpp}: main's condensed comments, corrected where this
  PR makes them false. setAverageFrames is no longer "not yet wired":
  the operator's FFT AVG arrives through setAverageTimeMs. reset() no
  longer asks for a separate retune drop: dropAverage() is it.
- RadioModel.{h,cpp}: main's condensed comments; HL2 is no longer listed
  as not averaging.
- MainWindow.cpp: both call sites keep clientFftSmoothingEnabled().
- tests/hl2_backend_test.cpp: main's deletion stands. The three checks
  this branch had added there (backendPanAveraging engaged at 10 ms,
  the widget EMA skipped when connected, and the control) move to
  capabilitiesSeam() in hl2_backend_seam_test, where main re-homed the
  capability checks.

This branch's own added comments are cut to the comment rule. The two
statements the review asked for are kept in short form: a transport gap
does not advance the averaging clock (setAverageTimeMs), and the
accumulator is HL2-private on purpose (RFC aethersdr#5782 q3, at m_avgPower).
No behaviour changes in the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ten9876 ten9876 self-assigned this Oct 2, 2026
ten9876 and others added 3 commits October 2, 2026 11:49
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ple VIII.

Hl2RxDsp::dropSpectrumAverage() cleared the running average but kept the
partial window accumulate() holds between due frames, so the first frame
after an NCO move was old-axis IQ, taken whole as the new average's seed:
the ghost the drop exists to remove survived for a full time constant.
It now calls Hl2Spectrum::reset() as well. Not via onSequenceGap(), so the
gap-discard counter stays a count of transport gaps.

hl2_pan_averaging_test section 7 feeds Hl2RxDsp the way the fan-out does
(one frame due, then capped, so the shaper holds fft-1 old samples), drops,
and compares the first frame against the raw new-axis frame: 129.49 dB
apart before, 0.00 after.

hl2_backend_seam_test now injects a configured receiver chain and checks
that setPanAverage / setPanWeightedAverage are held on the receiver and
reach the live spectrum (300 ms, log-recursive), clamp above range, and
ignore an unknown pan id. Nothing exercised the backend half before.

Comments condensed to AGENTS.md's five-line budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876

ten9876 commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Landing pass at 673d6275.

  • CodeGuard CG-PATH-001 (5 hits): refuted. The review above already shows why. None of the flagged MainWindow.cpp lines is in this PR's hunks. Both of this PR's changes in that file route the existing predicate through clientFftSmoothingEnabled(), and no path touches it.
  • @on8st's HL2 measurements (09-30, with the correction) are now the PR body's "On the radio" section. That section becomes the squash message. It covers τ = AVG × 10 ms, the same τ at 10 and 30 fps, the variance falling with AVG, AVG 0 being unaveraged, and power − log ≈ 2.6 dB. One measured miss stays open and does not block: the within-frame spread at AVG 100 (6.4 / 6.9 dB) was not below AVG 50. The spread across bins includes the floor's fixed shape, which no time average removes. The frame-to-frame SD, which averaging is meant to lower, did fall as predicted.

ten9876 and others added 3 commits October 2, 2026 12:54
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@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 cde3c37c. #5678 row 2.1 lands as RFC #5782 ruled. The q2 seam is the two defaulted verbs: RadioModel::requestPanAverage and requestLocalPanWeightedAverage reach them through shapesDisplayRatesLocally(). §8 is met: backendPanAveraging{10} is engaged in the same change, so the widget's EMA does not stack. q1 and q3 are answered and recorded.

Fixed in this pass

  1. The retune drop seeded the new average from old-axis IQ (automated pass, verified). Hl2RxDsp::dropSpectrumAverage() kept the partial window that accumulate() holds between due frames. 642dfc86 adds Hl2Spectrum::reset() before dropAverage() (Hl2RxDsp.cpp). Proof: hl2_pan_averaging_test §7 compares the first frame after the drop with the raw new-axis frame. They were 129.49 dB apart before and 0.00 dB after. The frame stays at 1024 points, so a window is 2.7–21.3 ms (384k–48k), shorter than a drag's ~33 ms between moves, and frames keep flowing during a drag.
  2. The backend half had no test. Nothing checked that setPanAverage / setPanWeightedAverage reach the chain. 642dfc86 adds a check to hl2_backend_seam_test that injects a receiver chain. It shows the values are held on the receiver, FFT AVG 30 reaches the chain as 300 ms, and weighted reaches it as log-recursive. It also shows FFT AVG clamps above range and an unknown pan id is ignored. The test binds nothing.
  3. Comments condensed to AGENTS.md § Comments (Hl2Spectrum.h, the test header). The q3 decision (Hl2Spectrum.h:114) and the gap-clock sentence (:41) are kept. Both threads were answered and resolved with these lines.
  4. Main merged (a20c66ae, 673d6275, 8f158618, 93acbd3b, cde3c37c; the last two after #6062 landed, as the landing order required). The conflict with #5982's squelch in Hl2Backend.{h,cpp} was adjacent lines only: both push calls and both struct fields are kept. The others merged cleanly.

Verified empirically

  • Full ctest without socket-owning tests: 499/500 pass. The one failure is connection_panel_size_test, which fails locally on this machine (#5811). After the #6062 merge, the full socket-free ctest passed 501/502; the one failure is again connection_panel_size_test. After the final merge (#6094, #6087), the affected tests passed 18/18 and the static gates were rerun clean.
  • Mutations, each red: setPanAverage a no-op; the weighted flag not pushed; clientFftSmoothingEnabled inverted; setAverageTimeMs ignored; the log flag ignored in Hl2Spectrum; no re-apply in installChannel; the retune drop keeping the held window.
  • Static gates, main against head: engine-boundary, touchpoint manifest, capability records and command plane show no new findings, only line shifts. ClientFftSmoothingGate.h is a gui header, not a new gui→engine touchpoint.
  • The tests/hl2_backend_test.cpp that #6075 deleted is not resurrected. Its assertions are in hl2_backend_seam_test unchanged.

Reasoned, not driven

  • The Flex/Sim path is unchanged. Only ANAN and HL2 set backendPanAveraging, and the gate's truth table matches the old inline expression; the seam test pins it. The demo was not driven because no bridge readback exposes the widget's smoothing flag.
  • Theme canon: docs/theming/canonical-tokens.md only says HL2 now engages the record. There is no new token and no colour change. The visible effect (an unaveraged HL2 trace at AVG 0) is the §8 consequence #5782's ruling specified.

Hardware evidence (@on8st, an HL2 on a dummy load): τ = 251 / 523 / 1005 ms at AVG 25 / 50 / 100; 515 vs 507 ms at 10 vs 30 fps; power − log 2.64 / 2.62 dB.

Not verified

  • The retune path on a real radio. A few ms of IQ captured before the NCO changed can still sit at the leading edge of the first window after the drop. The Hann window weights those samples down, and there is no HL2 here to measure them.
  • The trace by eye.

@ten9876
ten9876 merged commit f2acec1 into aethersdr:main Oct 2, 2026
5 checks passed
ten9876 added a commit to on8st/AetherSDR that referenced this pull request Oct 3, 2026
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
ten9876 added a commit that referenced this pull request Oct 3, 2026
…6050)

## Summary

On a Hermes-Lite 2, two receive controls had no route to the DSP that
runs on this host:

- **The AGC-T slider with AGC Off** moved `SliceModel::agcOffLevel`, and
the HL2 backend ignored the request, so every HL2 ran AGC Off at WDSP's
channel default of 10 dB (`WdspChannel::Config::agcFixedGainDb`)
whatever the slider said.
- **The CW audio peaking filter (APF)** was Flex wire text only (`apf=`
/ `apf_level=`). That is why HL2 declared `hasAudioPeakingFilter =
false` and the APF row stayed hidden, although WDSP builds a
peaking-filter stage into every receive channel.

## Seam

- **AGC-off level: main's typed route.** `SliceModel::setAgcOffLevel`
emits `receiveAgcRequested(SliceAgcRequest::Field::OffLevel)` →
`IRadioBackend::requestSliceAgc` (#5907). `Hl2Backend` now overrides
`requestSliceAgc`: it handles `Field::OffLevel` itself and passes every
other field to `IRadioBackend::requestSliceAgc` (the paired
`setSliceAgc`). No new verb.
- **APF: one new seam verb, interim.**
`IRadioBackend::setSliceApf(sliceId, on, level)` is a defaulted no-op.
`SliceModel` emits `apfCommandIssued(on, level)` from the operator
setters only, never from status application (Principle II), and
`RadioModel::wireBackendReceiverState()` routes it next to the squelch
route. **It folds into #5919's `SliceDspRequest::Feature::Apf` when
#5919 lands.**
- **Flex is unchanged.** It overrides neither verb, its
`apf=`/`apf_level=` text from SliceModel is byte-identical, and its
`agc_off_level=` text comes from `FlexBackend::requestSliceAgc` as on
main. Icom, RTL, Sim and ANAN keep the base no-op and still declare
`hasAudioPeakingFilter = false`.
- The `agcOffLevel` branch in `SliceModel::applyChanges()` gets the same
unchanged-value guard as the threshold beside it. HL2 now echoes the off
level on every slice-state publish, and `AgcCalibrationDialog` wires
`agcOffLevelChanged` to the calibrator.

**Maintainer decisions (KK7GWY, 2026-10-02):** (1) the AGC-off level
uses main's `requestSliceAgc` route, not a parallel verb; (2)
`setSliceApf` is accepted as an interim verb until #5919's request type
replaces it.

## HL2

- **AGC-off level → `SetRXAAGCFixed`**, via a new
`WdspChannel::setAgcFixedGain()`. The mapping is `gain_dB = 10 +
0.6·(level − 10)`, so the range is 4..64 dB. **The default level 10
gives exactly 10.0 dB**, so AGC Off sounds the same until the operator
moves the slider. The slope matches the AGC-T threshold's 0.6 dB/unit.
WDSP applies the fixed gain only in AGC mode 0, so the value is pushed
in every mode and takes effect when AGC is switched off.
- **APF → WDSP SPCW** (`apfshadow`:
`SetRXASPCWSelection/Run/Freq/Bandwidth/Gain`, declared in
`aether_wdsp.h`), via `WdspChannel::setApf()`:
- The filter is centred on the CW pitch in audio Hz, and it follows a
pitch change on every receiver, whether or not it is in CW at the time.
- Level 0..100 sets the bandwidth, because the control is labelled "APF
bandwidth" / "Higher values narrow the peak". The map is 200 Hz → 50 Hz,
geometric. The default of 50 gives **100 Hz at gain 2.0, which is
RXA.c's own construction design**.
- The stage runs only in CWL/CWU. The request is kept through a trip to
SSB and comes back with CW.
- `open()` selects the double-pole explicitly. An unchanged `setApf()`
returns before the control handshake and the FFTW planner lock.
- While it runs, both audio outputs derive from I (WDSP's mode-2
double-pole copies I into Q).
- `hasAudioPeakingFilter` is now `true` for HL2. The caps map row says
the host runs it.
- Both settings are held per receiver outside `Config` and applied again
on channel rebuild, reconnect and new panadapter, the same way the noise
blanker is.
- Both appear in `dsp.chains` as what WDSP last **accepted** (`apfRun`,
`apfCenterHz`, `apfBandwidthHz`, `apfGain`, `agcFixedGainDb`).
- **Not persisted, on purpose:** like the host noise blanker. After a
reconnect the off level is 10 again and the APF is off.

## Tests

Both new tests are socket-free and registered in `tests/tests.cmake`.

- **`slice_apf_agc_off_seam_test`** (a recording backend installed
through `RadioModel`'s production receiver bindings):
  - Flex's APF wire text is byte-identical.
- The operator setters emit the APF intent with enable and level
together.
- `RadioModel` routes the APF to `setSliceApf` and the off level to
`requestSliceAgc(Field::OffLevel)` for the right slice, including
through the AGC-T knob and the AGC-T calibrator's sweep and restore.
- Status application never re-emits a command, and an echo at an
unchanged off level notifies nothing.
- The base verbs emit no signal, and the base drops `OffLevel` rather
than calling `setSliceAgc`.
- **`hl2_apf_agc_off_test`** (a real `Hl2RxDsp` and WDSP channel,
measured audio):
  1. the level maps;
2. refusals, and an unchanged `setApf()` that does not wait on the
planner lock;
3. AGC Off: 50 units is 30 dB of audio, and with AGC on the off level
changes nothing;
4. APF in CWU lifts the pitch over a tone 200 Hz away by more than 10
dB, outside CW it is held and inert, and CWL is centred on the pitch;
  5. both survive a rebuild;
6. through `Hl2Backend`: `setSliceApf` / `requestSliceAgc(OffLevel)` →
WDSP → published slice state and `dsp.chains`. A pitch set outside CW is
the centre on entering CW, and a `Mode` request still reaches the
chain's AGC and leaves the off level alone.
- **Mutation-checked** (each goes red, and green again when restored):
  - sending the APF only to receivers already in CW;
  - `Hl2Backend::requestSliceAgc` not handling `OffLevel`;
  - not delegating other fields to the base.

**Results at the head (Linux, RelWithDebInfo):**
- Full build. Non-socket ctest 549/550 (including #6049's
`reroute_dead_controls_test`); the one failure is
`connection_panel_size_test`, which fails locally (#5811).
- Static gates: engine boundary, touchpoint manifest, capability records
(frozen count unchanged), command plane, test registration and the CI
test gate all pass, with findings identical to main's.
- on8st's earlier run: 556/556 on macOS.

### Hardware evidence (receive only)

Hermes-Lite 2 (gateware 74) with the antenna switch on the dummy load,
read through the automation bridge with TX blocked. This run was at an
earlier head, when the AGC-off level still used its own verb. The
HL2/WDSP half is the same code, and the route change is covered by the
tests above.

| # | Condition | Expected | Observed | Result |
|---|---|---|---|---|
| A1 | AGC Off, off level 10 | fixed gain 10 dB | 10 dB | PASS |
| A2 | AGC Off, off level 40 | 28 dB | 28 dB | PASS |
| A3 | AGC Off, off level 100 | 64 dB | 64 dB | PASS |
| P1–P3 | APF not requested (USB / CW / CWL) | not running | `apfRun`
false | PASS |
| P4 | APF on, CW | running at CW pitch | `apfRun` true, 600 Hz (pitch
600), 100 Hz, gain 2 | PASS |
| P5 | APF on, CWL | running at CW pitch | `apfRun` true, 600 Hz, 100
Hz, gain 2 | PASS |
| P6 | APF requested, USB | held, not running | `apfRun` false, model
still on | PASS |
| P7 | back to CW | running again | `apfRun` true, 600 Hz | PASS |

## Not covered

- No listening check of the APF on hardware. The audible effect is
covered by the offline measurements (sections 3 and 4).
- The APF level slider was not swept on hardware.
- No transmit path is touched, and nothing was transmitted.

## Update against main

Merged `main` into this branch (no rebase). The conflicts were adjacent
additions next to #5982's squelch, #5980's pan averaging and #5981's
filter length in `Hl2Backend`, `Hl2RxDsp` and `WdspChannel`, plus test
registrations; both sides were kept. `SliceModel::setAgcOffLevel` took
main's version, and the following commit moved the HL2 route onto it
(decision 1 above). Two later merges brought in #6013 and #6049 cleanly;
#6049's band-stack AGC recall reaches `requestSliceAgc`, which
`Hl2Backend` passes to the base for every field but `OffLevel`.

Refs #5262, #5917.

73, ON8ST

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
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>
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