Skip to content

feat(dsp): run WDSP's second impulse blanker from the NB button. Principle IV. - #5966

Merged
jensenpat merged 5 commits into
aethersdr:mainfrom
tropo1234:anan-nb2
Oct 7, 2026
Merged

jensenpat merged 5 commits into
aethersdr:mainfrom
tropo1234:anan-nb2

Conversation

@tropo1234

@tropo1234 tropo1234 commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

WDSP ships two impulse blankers. #5824 landed the first (ANB, nob.c); this
adds the second (NOB, nobII.c), which deskHPSDR, pihpsdr and Thetis all call
NB2. Instead of gating the blanked window to zero it reconstructs it from the
samples either side: better when the impulse lands on a wanted signal, worse
when the noise is dense enough that those samples are not clean. Every
reference client offers both and replaces neither, and so does this.

Host-side DSP on our own IQ throughout. No wire protocol, no radio firmware,
and no behaviour change for a Flex or an Icom, whose blankers are the radio's.

  • WdspChannel opens both stages for the life of a receive channel and
    runs at most one: processIq feeds exactly one stage per block, chosen by
    the kind. Both are created even when no blanker is on, because
    create_nobEXT allocates and a lazily built stage would allocate on the
    real-time path the first time someone clicked NB2.
  • The transmit hold covers both, for the reason it covered one: the
    trigger is relative to a running average, so a transmit period of zeros
    would decay that average and gate the first real receive samples.
  • Kind and fill ride aetherd: consolidate slice receive-control routing #5919's typed route (ruling 1). SliceDspRequest
    gains kind and fill members, carried on every Feature::Nb request,
    and one new field, Field::Fill. A change of blanker is an ordinary
    Field::Enabled request: enabled says whether one runs, kind says
    which, so a radio with one blanker (Flex, Sim) reads enabled exactly as
    before. SliceDspRequest::requestedBlanker() derives the three-state answer
    in one place, and a request built without a kind still means the first
    blanker. There is deliberately no Field::Kind: the NB button calls
    setNbKind() on every radio, so a new field would have reached Flex and
    Sim, which do not handle it.
  • Host backends apply it through one verb,
    IRadioBackend::setSliceNoiseBlanker(id, kind, level, fill), which the
    HL2, ANAN and Icom requestSliceDsp call. Kind, level and fill arrive
    together, so no ordering between them exists. Icom treats anything but
    Off as on. Flex refuses Field::Fill as Unsupported rather than
    encoding it; the fill is only reachable where hasHostNoiseBlanker is set
    (HL2, ANAN), so no operator sees that refusal.
  • Two enums, one conversion. core/backends/NoiseBlankerKind.h is the
    seam's vocabulary; WdspChannel keeps its own, because core/dsp is a
    leaf. core/backends/WdspNoiseBlanker.h holds the conversion, with
    static_asserts that fail the build if an existing value is renumbered.
  • SliceModel gains nbKind()/nbFill(), and nbOn() is now derived from
    the kind
    , so there is one source of truth. setNb(bool) keeps its
    meaning: true selects the first blanker (ruling 2). rigctl's NB,
    SmartCat's NB, TCI's rx_nb_enable, get slice, the MIDI mapping, the
    keyboard shortcut and the band-stack entry are untouched. A radio's bool
    echo cannot downgrade a host NB2 to NB.
  • UI: the NB button cycles Off → NB → NB2 → Off where this host runs the
    blanker, and stays a plain toggle elsewhere (ruling 2). The label follows
    the running kind; the objectName does not, since the bridge addresses the
    button by it. An NB2 fill combo appears under the DSP grid while NB2
    runs, and leaves with the NB button in FM (ruling 4).
  • Bridge (ruling 3): slice dsp nb2 on <level> <fill>, refused on a radio
    without hasHostNoiseBlanker, and get slice / get hostnb report the
    kind and fill beside the existing on.
  • aether_wdsp.h gained the NOB declarations. nobII.c was already
    compiled; only the curated header was missing it.

Review and rulings

Item Where
Blocker 1: #5919 removed the routing this was built on Rebased onto 85f2a124 and moved onto SliceDspRequest in 340d00b8
Ruling 1: widen SliceDspRequest with kind and fill 340d00b8
Ruling 2: the cycling button, and setNb(true) dropping NB2 to NB Unchanged, as accepted
Ruling 3: the bridge surface Unchanged, as accepted
Ruling 4: the fill row showing in FM 3ef4cd36
Blocker 2: the run-flag ratchet Removed in 3ef4cd36
Nits: stale line cites, the retired-test reference, the static_assert claim, comment length 3ef4cd36

Blocker 2. The review's mutations showed the ratchet guarded an inert
property. A run flag left on the unchosen stage blanks nothing, because
processIq never feeds that stage. The one mutation that does double-blank
(both stages fed, the ANB run flag left on under NB2) passed the ratchet and
failed DISCRIMINATION 2 in hl2_noise_blanker_test.
tools/check_nb_exclusivity.py, its Static checks step and its
docs/agents/backends.md entry are gone. The test's closing comment now names
the discrimination checks as the exclusivity pin, and the run-flag comment in
WdspChannel::setNoiseBlanker names the dispatch as the guarantee.

Ruling 4. The fill row follows m_nbModeOk, and refreshNbControls() now
runs at both places that recompute it: the modeChanged handler, and
syncFromSlice(), whose earlier refresh ran before m_nbModeOk was set. The
shared DSP level row is unchanged.

Rebase onto 85f2a124

Main was 38 commits ahead and the branch was dirty in eight files. The move
onto #5919 is a port, not a marker resolution:

  • SliceModel no longer has noiseBlankerCommandIssued. setNb,
    setNbKind and setNbFill publish through main's notifyReceiveDspIntent,
    and currentDspRequest() fills kind and fill into every Nb request. The
    per-field intent revisions grow from two fields to three.
  • RadioModel: the block of direct receive-DSP connect()s this branch
    carried is dropped; wireSliceReceiveIntentsToBackend() owns that wiring now.
  • WdspChannel.h: main's FmReceive and this branch's two enums landed at
    the same spot; both kept.
  • Tests from main, ported: backend_receive_contract_test (from aetherd: consolidate slice receive-control routing #5919)
    asserted Config::noiseBlankerEnabled, which this branch replaces with the
    noiseBlanker kind, so it now asserts Impulse.
    anan_noise_blanker_readback_test keeps main's typed dispatch. New rows in
    the contract test pin that a Flex reads a host kind as nb=1 and refuses the
    fill with no wire text, and noise_blanker_kind_model_test asserts on the
    SliceDspRequest the model emits.

Main has since taken two Icom commits (to 12df6943). They touch
IcomCivBackend.{h,cpp}, which this PR also changes. The merge is clean, and
neither side uses a signature the other changed. CI on 3ef4cd36 runs on the
merge with 12df6943.

Earlier rebases, onto b714cd3d and 732b99a9, resolved comment-prose
conflicts from #6072, accepted #6075's retirement of
wdsp_nb_hold_invariant_test.cpp (its behavioural successor
wdsp_nb_hold_test is ported), kept #5982's squelch API, and ported two tests
main had added since the earlier bases: hl2_rxdsp_adaptive_taps_test.cpp and
reroute_dead_controls_test.cpp.

Constitution principle honored

Principle IV: clean-room. WDSP's NOB is in-tree. deskHPSDR was read for which
knobs exist and what its defaults are (the 3-way choice, the shared threshold,
the zero-fill default), and no code was copied.
Principle VIII: the blanking, the fill reaching WDSP, single-stage dispatch,
the model's echo handling, the request shape, the fill row's FM rule and the
applied-state readback are each pinned by a test with a mutation run behind it,
listed below.

Test plan

  • Local build passes on 85f2a124 (cmake --build build).
  • Full local ctest on 85f2a124: 656 tests, 655 passed, 1 failed,
    one deliberate skip (app_settings_safety_explicit-profile-path-isolation).
    The failure is pgxl_panel_test: two pixel-exact geometry assertions at
    pgxl_panel_test.cpp:553-554, added by fix(pgxl): fit the docked panel's controls and readings. Principle XI. #6036. It fails here when run
    alone too, and passes in main's Full Suite on 85f2a124. It links only
    Qt, and none of the 44 files in its include closure is changed by this
    PR, so a main build here has the same inputs: the difference is this
    machine's environment, not this PR.
  • Every Static checks step run locally with --strict: the twelve
    tools/check_*.py, the touchpoint and seam-probe manifests, the four
    checker self-tests, and the hardcoded-colour ratchet (+0 on all three
    counts
    ). check_command_plane sits at its 369-command baseline across
    32 files. The accessibility step, run as CI runs it on the two changed
    src/gui/ files, reports 5 warnings, the same 5 as main's copies
    (m_playBtn, m_sqlSlider); none is from this PR.
  • CI: all five checks green on 3ef4cd36 (build, check-macos,
    check-windows, Static checks, Sanitizer option configures), run on
    the merge with 12df6943.
  • Measured against real WDSP (hl2_noise_blanker_test, linear-phase
    chain, 24 impulses/second on a steady tone). Impulse peak in the
    demodulated audio: 0.6265 with no blanker, 0.2705 with NB,
    0.2717 with NB2 zero-filling, 0.2573 with NB2 interpolating,
    which also keeps the highest surviving tone RMS of the three. The engine
    is unchanged since; the figures are from 732b99a9, the 10-04 review
    reproduced them on 484edefb, and the test passes on this head.
  • Mutations for what changed since the review: five, all killed. The
    fill row ignoring the FM gate; each of the two mode sites not
    refreshing; the Flex fill refusal removed; a kind change sent as a
    Level request.
  • Mutations from earlier heads: mapping Advanced onto the first
    blanker makes NB2's output bit-identical to NB's (DISCRIMINATION 1);
    dropping SetEXTNOBMode makes the two fills identical
    (DISCRIMINATION 2); applying SliceDelta::nb naively turns NB2 into NB;
    echoing the request instead of reading the installed channel fails the
    applied-kind readback; and four mutations of the slice dsp nb2
    capability guard. The review's M1+M2 shows DISCRIMINATION 2 also
    catches double-blanking.
  • Not mutation-run: SliceDspRequest::requestedBlanker(). It lives in
    ReceiveCommand.h, under most of aethercore, so each mutation costs
    two near-full rebuilds. noise_blanker_kind_model_test asserts its
    answer for NB2.
  • Behaviour verified on a real radio (ANAN-G2, 2026-09-26) on the
    pre-aetherd: consolidate slice receive-control routing #5919 head, not this one
    : the button cycle and its label, the fill
    row appearing only on NB2, no tick on switching, NB2 and its fill
    surviving a sample-rate change. The routing beneath that changed in the
    rebase, and the FM fill-row fix is unit-tested only, so the bench
    check has not been repeated on this head
    .
  • NOT TESTED on the bench: on-air impulse blanking for either blanker,
    whether the five fills differ audibly, the level slider biting, and the
    transmit hold. No impulse noise source here, and no TX on the G2 yet.
    The transmit hold is tested against WDSP for the first blanker only
    (wdsp_nb_hold_test); nothing exercises it with NB2, which is the
    thinnest part of this change.

Checklist

  • Commits are signed (all four show as verified on GitHub)
  • No new flat-key AppSettings calls. None added.
  • Code is clean-room (Principle IV)
  • All meter UI uses MeterSmoother. No meter UI touched.
  • Documentation updated: docs/automation-bridge.md (the nb2 control and
    the kind/fill readback) and the HL2 backend design's seam table. The
    docs/agents/backends.md entry an earlier head added is removed again.
  • Security-sensitive changes reference a GHSA. Not applicable.

🤖 Generated with Claude Code

@tropo1234
tropo1234 requested a review from a team as a code owner September 26, 2026 18:27
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this — the CI red here is one static gate, not your C++. Everything else in the run is fine, and the three build jobs (build, check-macos, check-windows) were still in progress when I looked, so nothing has reported a compile problem.

What failed

Static checks → step Hardcoded-colour ratchet (run 36262621831). Every other step in that job passed, including the engine/UI dependency direction, the capability-record and command-plane gates, and the a11y analysis.

That step runs tools/audit_colours.py --src src --compare-src <merge-base> --strict (.github/workflows/static-checks.yml:366). It's a delta gate: three metrics — unique_colours, total_references, and setstylesheet — may not rise above the PR's merge base.

Why your diff trips it

It's the third metric, and it isn't about colours at all. setstylesheet counts raw setStyleSheet( call sites in src/ (tools/audit_colours.py:105, counted at line 359). Your NB2 fill row adds exactly one:

auto* lbl = new QLabel("NB2");
lbl->setStyleSheet(kLabelStyle);   // <-- +1 call site

in src/gui/VfoWidget.cpp, in the new m_nbFillContainer block. You reused the existing kLabelStyle constant, which is the right instinct and is why the colour metrics stay flat — but the metric tracks call sites, so reusing a constant doesn't clear it. Its own error text says as much: "the count tracks call sites, not colours, so migrating a colour will not clear it."

Two ways to fix it

Preferred — route through ThemeManager. ThemeManager::applyStyleSheet() contains no setStyleSheet substring, so folding a site onto it retires a counted call site and adds none. src/gui/VfoWidget.cpp already includes core/ThemeManager.h and already uses this shape at line 187. The worked precedent is src/gui/RadioSetupDialog.cpp:133-205 (from #5896, merged as 33c0032), which defines kLabelStyleTemplate with {{token}} placeholders and a one-line applyLabelStyle() helper.

One honest caveat if you go this route: kLabelStyle's own colours are #8aa8c0 (which tools/migrate_colours.py:130 maps to color.text.secondary) and, via kDisabledLabelRule, #5e6e7c — and that second one has no canonical token today. So a full template conversion of kLabelStyle isn't a drop-in. Also note the trap called out at RadioSetupDialog.cpp:194-199: passing a raw-hex string through applyStyleSheet satisfies the counter while leaving the widget with nothing to re-resolve on a theme switch. Don't do that.

Smallest change that clears the gate. Add a file-local helper in VfoWidget.cpp and route the new label plus at least one existing setStyleSheet(kLabelStyle) site through it:

static QLabel* makeDspCaptionLabel(const QString& text)
{
    auto* lbl = new QLabel(text);
    lbl->setStyleSheet(kLabelStyle);
    return lbl;
}

There are 13 other setStyleSheet(kLabelStyle) sites in that file (lines 1598, 1620, 1644, 1662, 1672, 1707, 1716, 1738, 1748, 2107, 2119, 2470, 2878). Folding the new row plus one existing site onto the helper nets zero; folding several nets negative, which the ratchet is happy about.

Reading the CI output yourself

The step prints the delta per metric before it fails:

=== ratchet (vs PR base) ===
  OK   unique_colours      ...  (base ..., +0)
  OK   total_references    ...  (base ..., +0)
  OVER setstylesheet       ...  (base ..., +1)

So you can confirm which of the three moved without guessing. You can also reproduce it locally:

git worktree add /tmp/colour-base $(git merge-base HEAD origin/main)
python tools/audit_colours.py --src src --compare-src /tmp/colour-base/src --summary-only --strict

Reviewer comments

No Copilot or human review comments on the PR yet, so there's nothing flagged that relates to this failure.

Nothing else needs touching — the rest of the change (NoiseBlankerKind.h, the HL2/ANAN backends, WdspChannel, and the tests) is clean past every other static gate. Nice work on the NB2 fill row; the tooltip wording is a good call too.


References: .github/workflows/static-checks.yml:336-375 (the gate and why it's a delta), tools/audit_colours.py (the scanner), docs/theming/canonical-tokens.md (token table), #4569 / RFC #3076 (why the ratchet exists).


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

@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 fixes/closes #N in the body, and no linked issue — this is a feature PR reviewed against its own stated intent. GOVERNANCE.md wants an RFC for architectural changes; this one does change a seam verb's signature (setSliceNoiseBlanker(id, on, level) → (id, kind, level, fill)) and adds a bridge sub-verb, so whether it needed an RFC or a ruling first is a maintainer call — I'm naming it, not calling it a violation, because the shape (host-side WDSP stage behind an existing capability flag) is the same shape #5824 already landed under.

Against its own intent it delivers: both WDSP stages are created for the channel's life, at most one runs, the fill reaches SetEXTNOBMode, the transmit hold covers both, the seam verb carries the whole state in one call, and the bridge can drive and read it. The test work is unusually good — see "what I tried to break".

2. Scope

File / group What it changes Claimed? Verdict
WdspChannel.{h,cpp} NoiseBlanker/NoiseBlankerFill enums, NOB created + run-flagged, fill pushed, both stages held yes in scope
third_party/wdsp/include/aether_wdsp.h declares the 12 *NOB* EXT entry points (header only; nobII.c already compiled by the glob) yes in scope
NoiseBlankerKind.h, WdspNoiseBlanker.h (new) seam vocabulary + the one enum-conversion point, static_assert-pinned yes in scope
IRadioBackend.h, SliceDelta.h verb signature + nbKind/nbFill deltas yes new seam surface — maintainer decision
anan/*, hl2/*, icom/* all three overrides updated; ANAN packs the applied triple into one atomic int yes in scope
SliceModel.{h,cpp}, RadioModel.cpp nbKind/nbFill, kind-wins applyChanges, troubleshootingSnapshot gains kind/fill yes in scope
AutomationServer.cpp slice dsp nb2, nbKind/nbFill in the slice snapshot yes new public bridge surface — maintainer decision
VfoWidget.{h,cpp} NB button cycles Off→NB→NB2→Off, relabels, NB2 fill combo row yes in scope (see nit 2)
docs/automation-bridge.md, docs/architecture/aetherd-hl2-backend-design.md document the above; gen_bridge_docs.py --check still passes yes in scope
tests/* (6 modified, 1 new), tests/tests.cmake mechanical signature updates + three genuinely new assertions yes in scope

Nothing in the diff is unexplained by the stated change. No CHANGELOG.md entry (correct). No unrelated files, no removed guards, no formatting churn. No socket tests added, modified or removed — I read the added/modified test sources directly in the head checkout, not the working tree.

3. Blockers

None. The three items below are real but none of them breaks a user on the GUI path or fails on current main.

4. Findings (non-blocking, ranked)

  1. slice dsp nb2 is not capability-gated, while its read counterpart is. get hostnb refuses on a radio without hasHostNoiseBlanker (AutomationServer.cpp:5245), with a comment explaining exactly why an empty success is worse than a refusal. The write path six thousand lines down has no such check, so slice dsp nb2 on on a Flex or an Icom puts SliceModel into Advanced. Consequences I can trace in source: the VFO button relabels itself "NB2" on a radio with no NB2 (refreshNbControls keys the label on kind alone, only the fill row is capability-gated); IcomCivBackend::setSliceNoiseBlanker folds it to plain "on"; and it is sticky, because applyChanges was deliberately written so the radio's nb=1 echo cannot pull it back down to Impulse. This contradicts the invariant the PR asserts in three separate comments ("Advanced is only reachable where hasHostNoiseBlanker is set"). Inline comment with a suggested guard.

  2. The NB2 fill row outlives the NB button in FM. m_nbModeOk = !isFm hides m_nbBtn (VfoWidget.cpp:3455), but neither mode-recompute site (:4809, :5393) calls refreshNbControls(), and refreshNbControls doesn't consult m_nbModeOk. On an HL2/ANAN slice running NB2, switching mode to FM should leave an orphaned "NB2 [Interpolate]" row with no button above it. Honest caveat: the shared DSP level row looks like it behaves the same way — I see no popDspLevelTarget(LvlNB) on the FM path either — so this is the new row inheriting an existing quirk rather than a new class of bug. Reasoned from code; I cannot drive the GUI headless.

  3. setNb(true) downgrades NB2→NB from every CAT path. The PR reasons carefully that a radio's bool echo must not downgrade a host kind, and applyChanges implements exactly that rule (only Off↔on transitions are taken from a bare nb). The bool setter has no matching rule: setNb(true) unconditionally selects Impulse. Callers are rigctl set_func NB 1 (RigctlProtocol.cpp:1945), SmartCat NB1; (SmartCatProtocol.cpp:1063), TCI rx_nb_enable (TciProtocol.cpp:1437) and the MIDI binding setNb(v > 0.5f) (MainWindow_Controllers.cpp:1954). Any client that re-asserts its current state — and TCI clients mirroring a broadcast back is a pattern this tree has hit before — silently knocks NB2 down to NB. Mirroring applyChanges' rule in the setter is one line. That said, "the bool door means the first blanker" is also a defensible reading and it is documented, so I'd call this a maintainer call rather than a defect.

5. Nits

  • m_nbKind and m_nbActive are both memory_order_relaxed and stored kind-then-gate. processIq's comment says "a kind seen here is a stage that is running", which is stronger than relaxed ordering actually guarantees — a reader can observe the new gate with the old kind for one block. The consequence is benign (the unchosen stage's WDSP run flag is 0, so xanbEXT/xnobEXT copies in place and one block goes unblanked), but the comment overstates the guarantee.
  • noise_blanker_kind_model_test is registered in tests.cmake and joins AETHER_SETTINGS_CONSUMERS correctly, but it is not in the frozen .github/ci-test-gate.txt, so the per-PR lane builds it and never runs it — it first executes on full-suite.yml after merge. Normal for this repo; flagging so nobody reads the green badge as "the new test passed".
  • The NB2 discrimination checks compare doubles with !=. That's the right direction here (bit-identity is the bug signature), but it means the check can only ever be falsely green, never falsely red — worth the one-line note it doesn't have.

6. What I tried to break, and could not

  • The vendored WDSP side. create_nobEXT's parameter list in aether_wdsp.h matches upstream/nobII.c:608 exactly, including the argument order; nobII.c is already picked up by the file(GLOB upstream/*.c) so no build wiring was missed. The "neither xanb() nor xnob() allocates" claim guarding the real-time path holds: every malloc0/_aligned_free in nobII.c is inside create_nob/destroy_nob. The a->legacy buffer is a fixed 2048 complex samples and would overflow on a larger block — but it is touched only by xnobEXTF, and this code calls xnobEXT with its own staging buffer, so there is no block-size cap on the path actually used. MAX_SAMPLERATE is 1536000 in both nob.c and nobII.c, so dline/awave/hwave are not undersized at any rate this client runs.
  • ANAN's single-int packing (AnanRxDsp.h): level + 128·kind + 1024·fill. I checked the decode against the whole domain — level 0..100, kind 0..2, fill 0..4 — max state 4452, every field extracts exactly, and no valid state collides with the -1 "no installed channel" sentinel.
  • Exclusivity. Both run flags are written unconditionally outside the per-kind branches in setNoiseBlanker, so I could not construct a call sequence leaving ANB and NOB both running; the newly-selected stage is flushed and the stopped one deliberately is not.
  • Sibling call sites. Every override of the changed verb is updated (hl2, anan, icom); FlexBackend doesn't override it. The seam probe table generated from IRadioBackend.h covers signals only, so the method-signature change can't stale it — confirmed by running gen_seam_probe_table.py --check (52 signals, up to date), plus gen_bridge_docs.py --check, gen_touchpoint_manifest.py --check, gen_theme_seed.py --check, check_engine_boundary/check_command_plane/check_capability_records/check_test_registration --strict and check_ci_test_gate.py — all clean on the head checkout.
  • The VFO cycle's re-entrancy. setNbKind(Advanced) emits nbChanged synchronously from inside the toggled(false) handler, so connectLeveledDsp's slot re-checks the button mid-emission. It's wrapped in QSignalBlocker, so no recursion; and pushDspLevelTarget does removeAll-then-append, so the resulting doubled push cannot leak a stale stack entry. The cycle Off→NB→NB2→Off traces correctly, and collapses to Off→NB→Off when m_hasHostNoiseBlanker is false.
  • Band-stack restore (MainWindow.cpp:5476) looked like an obvious NB2-downgrade path, but it writes raw slice set N nb= wire text rather than calling setNb(), so it can't reach a host blanker at all. Withdrawn.
  • The tests themselves. The two discrimination checks in hl2_noise_blanker_test would fail against the plausible fake implementation (mapping Advanced onto the ANB, or never pushing SetEXTNOBMode) — they are not assertions of the implementation back to itself. anan_noise_blanker_readback_test asserts the applied kind while a deferred request holds a different one, which is the only arrangement that can tell a readback from an echo. wdsp_nb_hold_invariant_test's new runFlagsUnconditional check ships with its own broken miniature as a positive control. And the body's record of two candidate checks that were written and removed — one unfalsifiable, one that failed on correct code — is the kind of negative result most PRs quietly drop.
  • CI on e67ebb5: build, check-windows, check-macos, Static checks and Sanitizer-configures all green. check-windows passing rules out the usual MSVC trap on new DSP code.

Not verified, and unverifiable from here: I have no build and no run, so every statement above is reasoned from source, not reproduced. The measured peak/RMS figures in the body (0.6265 / 0.2705 / 0.2717 / 0.2573) I could not reproduce. The claim that the fill row was clipped on the bench before the relayoutToCurrentContent() call, and that the call fixes it, is untestable headless.

7. Recommendation

Needs maintainer decision, then approve with nits. The engine work is careful and well-evidenced and I could not break it. Two decisions belong to a maintainer rather than to me: the new seam-verb signature and the slice dsp nb2 bridge surface arriving in a feature PR with no linked issue or RFC; and whether setNb(true) selecting Impulse from a CAT client is the intended rule (finding 3). Concrete next step for the contributor regardless of that ruling: add the hasHostNoiseBlanker guard to the nb2 branch (finding 1) so the write path matches the read path in the same file.


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

Comment thread src/core/AutomationServer.cpp
Comment thread src/gui/VfoWidget.cpp Outdated
Comment thread src/models/SliceModel.cpp Outdated
@tropo1234

Copy link
Copy Markdown
Contributor Author

Finding 1 was right. I reproduced all three from source before
touching anything; one detail of finding 1 needs a correction, and
findings 2 and 3 I have deliberately left alone. Reasons below.

Finding 1 — slice dsp nb2 is now capability-gated (fixed)

if (which == QLatin1String("nb2")
    && !radio->backendCapabilities().hasHostNoiseBlanker)
    return err(QStringLiteral(
        "slice dsp nb2: this radio does not run a host-side noise "
        "blanker (use 'nb' for its own — see get hostnb)"));

To your sanity-check question: yes, that compiles as you wrote it. doSlice
null-checks m_radioModel and takes RadioModel* radio at its top, and the
branch you commented on already calls radio->slices() a few lines below.

Three places I differed from the suggested patch:

1. Placed with the control-name validation, not inside the nb/nb2 branch.
On such a radio nb2 is not a control given bad arguments — it is not a control
at all, so it belongs next to "is this a control I have". That also puts the
refusal ahead of slice resolution, which matters: your placement answers "no
slice available" on a radio with no slice yet, sending a caller looking for a
slice on a radio that can never have the control. There is a test row for
exactly that, and a mutation that moves the guard back down to prove the row
isn't decorative.

2. backendCapabilities().hasHostNoiseBlanker rather than
RadioModel::hasHostNoiseBlanker().
The typed accessor exists and is what the
GUI reads, but it is additionally gated on isConnected(). That gate is there
to keep a button off an unconfirmed radio, and no other branch of slice dsp
refuses for being disconnected — so using it would have added a second,
unrelated reason to refuse. This is the same read get hostnb does, which is
the symmetry the finding is about.

3. One correction. The invariant is stated in IRadioBackend.h:764 and
SliceModel.h:317, not in three headers — NoiseBlankerKind.h never mentions
the capability. Its Advanced comment is about what NOB does, not about where
it may run. Worth knowing if anyone greps for the invariant later.

The GUI was never the hole

Checked while I was in there, because a second unguarded door would have made
the guard cosmetic: the NB2 leg of the button cycle is already gated —
VfoWidget.cpp:2674 takes it only when m_hasHostNoiseBlanker && nbKind() == Impulse, so on a Flex the button toggles Off↔NB and never offers
the third state. The bridge was the only way to Advanced off-capability.

That also makes SliceModel::setNbKind's own comment true rather than
aspirational — it asserts "Advanced is not reachable on a radio whose blanker is
its own" as the reason it still sends the bool unconditionally.

Proof

New target automation_nb2_capability_test: stub backend, injected slice, the
real handleLine dispatcher rather than a shortcut into doSlice. It pins the
refusal, the message, and both directions of narrowness — plain nb still works
on the same radio, nb2 still reaches Advanced where the capability is
published, and a refused nb2 on 77 4 moves neither the level nor the fill.

Four mutations, all killed by the intended check:

mutation killed on
guard deleted nb2 refuses on a radio with no host-side blanker
widened to nb as well plain nb still works on the same radio
polarity inverted nb2 is accepted where the capability is published
moved below slice resolution with no slice, nb2 still refuses for the capability rather than the slice

558/558 ctest (six environment skips), nine ratchets clean.
check_command_plane holds AutomationServer.cpp at its frozen six raw-command
sites, and the colour delta is +0 on all three counts.

Finding 2 — the fill row outliving the NB button in FM: confirmed, not fixed

Confirmed from source, and slightly worse than described. Neither mode-recompute
site calls refreshNbControls(); both call applyRadioSideDspVisibility(),
which hides m_nbBtn (VfoWidget.cpp:3455) and touches no fill row. And on the
syncFromModel path the single refreshNbControls() at :5350 runs before
m_nbModeOk = !isFm at :5393, so the row is evaluated against the previous
mode and never re-evaluated after. refreshNbControls itself computes
showFill = advanced && m_hasHostNoiseBlanker and never consults m_nbModeOk.

Your caveat is the reason I have not fixed it: the shared DSP level row behaves
the same way on the same paths, so the honest fix is one that makes both rows
follow the button, not an m_nbModeOk term bolted into refreshNbControls that
leaves the older row still orphaned. That is a change to shared DSP-panel
behaviour rather than to NB2, and it belongs to whoever owns that panel. Happy
to do it either way — as part of this PR if you want it here, or as its own with
the level row included.

Finding 3 — setNb(true) downgrading NB2: confirmed, and it is your call

All four call sites confirmed: rigctl set_func NB 1, SmartCat NB1;, TCI
rx_nb_enable, and the MIDI binding. SliceModel::setNb(bool) is two lines and
selects Impulse unconditionally, so any client re-asserting its current state
knocks NB2 down to NB.

For what it's worth I would mirror applyChanges' rule in the setter, and I
think the documented reading is the weaker one. None of those four callers can
express "the first blanker specifically" — set_func NB 1, NB1;,
rx_nb_enable and a MIDI button are all state assertions, so reading them as
a choice of blanker attributes an intent the protocol cannot carry. Against
that: "the bool door means the first blanker" is defensible and it is written
down, so if you prefer it I will leave it.

It is one line either way and I will take whichever you rule. I have not
pre-emptively changed it.

The two decisions you referred upward

Both still open and both yours: the new seam-verb signature
setSliceNoiseBlanker(id, kind, level, fill), and the slice dsp nb2 bridge
surface arriving in a feature PR with no linked issue or RFC. I opened this one
the way I did because the button and the seam verb are the same change and
splitting them would have produced a verb with no caller; if you would rather
see an RFC first, say so and I will file one against this branch.

The bench limits in the body still stand unchanged: G2 verified the controls,
but on-air blanking, audible fills, level bite and the TX hold are untested —
no noise source here and no G2 TX.

@tropo1234
tropo1234 force-pushed the anan-nb2 branch 2 times, most recently from 37fff3e to 219b4a9 Compare October 2, 2026 20:57
@tropo1234
tropo1234 requested a review from a team as a code owner October 2, 2026 20:57
ten9876
ten9876 previously requested changes Oct 4, 2026

@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.

Reviewed head 484edefb (merge base 732b99a9). I built it on Arch Linux, ran the PR's tests, ran mutations against WdspChannel.cpp, and drove the build over the automation bridge against the demo simulator.

Issue fit

There is no linked issue, so I reviewed this against its own stated intent: add WDSP's second impulse blanker (NOB, nobII.c) behind the existing NB control on host-DSP radios. Against that intent the engine work does what it says. I reproduced the body's measurements on the head: NB2: off peak=0.6266 | nb peak=0.2704 | nb2/zero peak=0.2716 | nb2/interp peak=0.2573, which match the body's figures to rounding. The open question is the merge, not the feature. #5919 landed on main today, and the routing this PR is built on is gone (Blocker 1).

Whether a seam-verb change plus a new bridge surface needed an RFC first is still the maintainer's call. The 2026-09-26 review raised it, and nobody has ruled on it yet.

Scope

File / group What it changes Claimed? Verdict
src/core/dsp/WdspChannel.{h,cpp}, third_party/wdsp/include/aether_wdsp.h NOB stage created/destroyed with the channel, kind+fill, NOB EXT declarations yes in scope
src/core/backends/NoiseBlankerKind.h, WdspNoiseBlanker.h (new), SliceDelta.h seam vocabulary, the one conversion, nbKind/nbFill deltas yes in scope
IRadioBackend.h + hl2/*, anan/*, icom/* setSliceNoiseBlanker(id, kind, level, fill) yes seam surface: maintainer decision, and now superseded by #5919 (Blocker 1)
src/models/SliceModel.{h,cpp}, RadioModel.cpp nbKind/nbFill, kind-wins applyChanges, routing via noiseBlankerCommandIssued yes in scope, conflicts with #5919
src/core/AutomationServer.cpp, docs/automation-bridge.md slice dsp nb2, nbKind/nbFill in get slice, kind/fill in get hostnb yes new public bridge surface: maintainer decision
src/gui/VfoWidget.{h,cpp} NB button cycles Off→NB→NB2 on host radios; NB2 fill row yes (body "rule differently" item 2) changes an existing control's interaction: maintainer decision, as the body itself asks
.github/workflows/static-checks.yml, tools/check_nb_exclusivity.py, docs/agents/backends.md new CI gate and a new canon entry yes (rebase note) Blocker 2. This gates something that cannot fail, and it misses the failure that can
tests/* (2 new, 10 ported), tests/tests.cmake signature ports + new coverage yes in scope
docs/architecture/aetherd-hl2-backend-design.md seam table row yes in scope

Commit dates: 4d3d5383 (authored 09-29) predates 649a3cfc (10-02) only because the feature commit was rewritten during the rebases. 4d3d5383 is the response to the previous review's finding 1, and 484edefb is a forced signature port of two tests that landed on main. Nothing in the diff is unrelated to the stated change. There is no CHANGELOG entry, which is correct. The step-0 socket preflight is clean: automation_nb2_capability_test constructs an AutomationServer but never calls start()/listen(), and it calls handleLine() directly.

Blockers

1. The PR no longer merges, and #5919 has removed the routing it is built on

GitHub reports CONFLICTING/DIRTY. git merge-tree against current main conflicts in eight files: AnanBackend.{h,cpp}, Hl2Backend.h, WdspChannel.h, RadioModel.cpp, SliceModel.{h,cpp} and anan_noise_blanker_readback_test.cpp. The cause is not churn. #5919 (f4e463c2a, merged 2026-10-04 09:02Z) deleted SliceModel::noiseBlankerCommandIssued: git grep noiseBlankerCommandIssued origin/main -- src tests returns nothing. On main, NB now travels as SliceModel::setNb → notifyReceiveDspIntent(Feature::Nb, Field::Enabled) → RadioModel::dispatchSliceDsp → IRadioBackend::requestSliceDsp. Hl2Backend::requestSliceDsp and AnanBackend::requestSliceDsp then call setSliceNoiseBlanker(id, request.enabled, request.level).

This PR routes through exactly that deleted signal (src/models/RadioModel.cpp:1577, src/models/SliceModel.cpp:378-408). The body says "whichever lands second moves the routing". This PR is now second, so the move is its job. It is not a mechanical rebase, because SliceDspRequest (src/core/backends/ReceiveCommand.h on main) carries only bool enabled; int level;, and valid() admits only those fields. Kind and fill need to get onto that path somehow: a widened request, a Field::Kind/Field::Fill, or a separate typed request. The body explicitly deferred that design to #5919, and #5919 did not make it. That design choice needs a maintainer ruling before the rebase, so the author doesn't redo the seam twice.

The green CI badge doesn't help here. The only runs on this head were created 2026-10-03T14:57Z against 732b99a9. main is now at 2026-10-04T18:26Z and 37 commits ahead, including #5919.

2. check_nb_exclusivity.py guards an inert property, misses the real failure, and adds a false statement to canon

.github/workflows/static-checks.yml:317, tools/check_nb_exclusivity.py, docs/agents/backends.md:159

The ratchet's premise, repeated in the canon entry and in the body, is that both run flags must be written outside the kind branches or "both blank the same window", and that "no runtime test can observe which stages are running". The code refutes both halves. WdspChannel::processIq dispatches to exactly one stage per block (src/core/dsp/WdspChannel.cpp:433-437: xnobEXT if m_nbKind == Advanced, else xanbEXT). A run flag left at 1 on the unchosen stage therefore blanks nothing, because nothing feeds that stage. I checked this with three mutations, then restored the file (sha256 matches git show HEAD:) and rebuilt:

Mutation check_nb_exclusivity.py --strict hl2_noise_blanker_test
M1: SetEXTANBRun(m_channelId, kind != NoiseBlanker::Off ? 1 : 0), so the ANB run flag stays on under NB2 OK passes; NB2 output bit-identical to unmutated (nb2/zero peak=0.2716 rms=0.17834)
M2: processIq calls xanbEXT then xnobEXT under Advanced OK passes; identical output (ANB's run flag is 0, so it copies through)
M1 + M2: both stages genuinely blank the window OK FAILS: nb2/zero peak=0.2704 rms=0.17845 | nb2/interp peak=0.2704 rms=0.17845, so DISCRIMINATION 2 (tests/hl2_noise_blanker_test.cpp:376-380) fires

So the static gate passes on the one change that actually double-blanks. The existing runtime test is what catches that change, and it is the test the body says cannot exist. The body's own note supports this: "with the run flags deliberately broken so BOTH stages ran, the measured peak and RMS moved by less than the printout's four decimals". That happened because processing dispatch, not the run flags, decides what runs.

Fix: drop the workflow step, tools/check_nb_exclusivity.py and the docs/agents/backends.md entry. Point tests/hl2_noise_blanker_test.cpp's closing comment at the discrimination checks as the exclusivity pin, since M1+M2 shows they are one. If you want a cheaper guard, the invariant worth pinning is the single if/else dispatch in processIq, not the run-flag shape. A new CI gate is also a maintainer-ruled change in its own right.

Needs maintainer decision (flagged for @ten9876)

  • Seam shape after #5919 (Blocker 1): how kind and fill ride requestSliceDsp.
  • Bridge surface: slice dsp nb2 on <level> <fill>, nbKind/nbFill in get slice, kind/fill/requested* in get hostnb.
  • setNb(true) selects Impulse (src/models/SliceModel.cpp:378), so rigctl set_func NB 1, SmartCat NB1;, TCI rx_nb_enable and the MIDI binding all knock a running NB2 down to NB. The author offered both readings on 2026-09-30 and is waiting for a ruling. On main the bool setter is now the intent entry point, so the ruling also decides what that path carries.
  • FM leaves the NB2 fill row orphaned (previous review, finding 2). The author confirmed it and is waiting for a ruling on whether to fix both rows here or separately.
  • The cycling NB button on HL2/ANAN is a new interaction on an existing control (body item 2). It is a defensible choice, since deskHPSDR, pihpsdr and Thetis all expose both blankers, but it is a UI ruling.

Nits (non-blocking)

  • src/core/AutomationServer.cpp:7759-7790: a 26-line comment with narrative ("enforced nowhere; this is where it is enforced") and already-stale line cites. IRadioBackend.h:764 is finishTxAudio at this head, and the SliceModel.h invariant is at :300, not :317. AGENTS.md §"Comments" asks for ≤5 lines and no history. Drop the line numbers either way.
  • tests/hl2_noise_blanker_test.cpp:399 says exclusivity is pinned by wdsp_nb_hold_invariant_test, which #6075 retired and which does not exist on this branch.
  • src/core/backends/WdspNoiseBlanker.h:9-14 says the static_asserts stop "a third algorithm added to one enum and not the other". They only catch renumbering of existing values. A value added to one enum alone still compiles.
  • Comment volume generally runs well past the ≤5-line guidance (IRadioBackend.h verb comment, NoiseBlankerKind.h, VfoWidget.cpp refresh block). Worth trimming in the rebase.

Verified vs. read

  • Built the head (RelWithDebInfo). Ran all 12 touched/new targets, 12/12 passing: hl2_noise_blanker_test, noise_blanker_kind_model_test, automation_nb2_capability_test, anan_noise_blanker_readback_test, wdsp_nb_hold_test, reroute_dead_controls_test, the four hl2_rxdsp_* ports, anan_rxdsp_handedness_test, anan_backend_test.
  • Mutations: the three above. M1+M2 confirms the discrimination check is real coverage, not an assertion of the implementation back to itself.
  • Bridge, demo simulator only (DEMO-0001, get radio → "model": "AetherSDR Demo", isolated settings, AETHER_AUTOMATION_NO_TX=1), checking the "no behaviour change elsewhere" claim on a radio without a host blanker:
    • slice dsp nb2 on 60 4 → {"error": "slice dsp nb2: this radio does not run a host-side noise blanker (use 'nb' for its own — see get hostnb)", "ok": false}
    • slice dsp nb on → {'nb': True, 'nbKind': 1, 'nbFill': 0, 'nbLevel': 50}, button text NB, fill combo hidden
    • slice dsp nb on 50 4 → "only 'nb2' takes a fill argument"; slice dsp nb off → nbKind: 0
  • Could not drive: the VFO button cycle and the fill row. The demo has neither hasRadioSideDsp nor hasHostNoiseBlanker, so dspNBBtn is hidden (refused: 'dspNBBtn' is not visible), and NB2 is only reachable on an HL2/ANAN. The TX hold over NB2 is still untested on hardware, as the body says.

What I tried to break that held

  • The enum metatype across the queued invokeMethod (AnanBackend.cpp:852, Hl2Backend::pushNoiseBlanker): anan_noise_blanker_readback_test drives the queued path and passes.
  • The rebuild snapshot: startReceiverDspBuild and applyPanBandwidth both carry kind+fill into buildChannel, and installChannel re-applies a request that changed mid-build.
  • Icom scrubDrive maps the CI-V bool to Impulse/Off with the default fill. The capability guard sits ahead of slice resolution, as the author argued.

🤖 Generated with Claude Code

@ten9876

ten9876 commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

Maintainer rulings on #5966 (following the review above)

  • Kind and fill travel by widening SliceDspRequest, adding kind and fill fields to the existing struct, now that aetherd: consolidate slice receive-control routing #5919 has landed. Please rebase onto main on that basis.
  • The cycling NB button (Off → NB → NB2) on HL2/ANAN is accepted. So is setNb(true) from rigctl/CAT/TCI/MIDI dropping a running NB2 back to NB.
  • The bridge surface (slice dsp nb2, plus kind and fill in get slice / get hostnb) is accepted.
  • Fix the fill row showing on FM in this PR.
  • The exclusivity-checker blocker from the review still stands.

tropo1234 and others added 3 commits October 4, 2026 20:17
…ciple IV.

WDSP ships two impulse blankers. aethersdr#5824 landed the first (ANB, nob.c); this
adds the second (NOB, nobII.c), which every reference client calls NB2. It
reconstructs the blanked window instead of zeroing it, which is better when
the impulse lands on a wanted signal and worse when noise is dense -- so no
client replaces one with the other, and neither does this.

WdspChannel opens both stages for the life of a receive channel and runs at
most one: they share a detector and a window, so a second stage would
reconstruct what the first zeroed. Both run flags are written on every call,
outside the per-kind branches, which is what makes the unchosen stage
explicitly stopped. The transmit hold covers both for the reason it covered
one -- the trigger is relative to a running average, and a transmit period of
zeros would gate the first real receive samples.

The seam verb becomes setSliceNoiseBlanker(id, kind, level, fill): one call
carries the whole state, so no ordering between "which blanker" and "how
hard" can exist. A radio whose blanker is its own firmware treats anything
but Off as on. SliceModel gains nbKind()/nbFill() while nbOn() and
setNb(bool) keep their meaning, so rigctl, SmartCat, TCI, the MIDI mapping,
the keyboard shortcut and `get slice` needed no changes. A radio's bool echo
can no longer downgrade a host NB2 to NB.

The NB button cycles Off -> NB -> NB2 -> Off where this host runs the
blanker, labelled with the running kind, and an NB2 fill combo appears under
the DSP grid only while NB2 runs. The fill row asks the panel to refit when
it appears: the DSP tab's height is pinned to its page's size hint, so a row
that appears inside an already-sized panel is clipped, which is what happened
on the bench before this. `slice dsp nb2 on <level> <fill>` drives it from
the bridge and `get hostnb` reports the applied kind and fill.

Measured against real WDSP in hl2_noise_blanker_test: on a linear-phase
chain, an unblanked impulse peaks at 0.6265, NB at 0.2705, NB2 zero-fill at
0.2717 and NB2 interpolating at 0.2573 -- the lowest peak and the highest
surviving tone RMS of the three. Every new assertion has a mutation run
behind it. Two candidate checks were written and removed instead of kept:
one could not fail, and one failed on correct code while being unable to
distinguish the bug it was written for.

The exclusivity rule is enforced as a Static checks ratchet,
tools/check_nb_exclusivity.py: each run flag called exactly once per call, and
neither one inside a kind branch. A text check because it cannot be anything
else -- WDSP's EXT API cannot read a run flag back, so no runtime test can
observe which stages are running. It self-tests on every scan and all four
mutations fail it, including the kind branch renamed away, which is how a text
check stops measuring while still reporting OK. This branch first carried the
rule as additions to tests/wdsp_nb_hold_invariant_test.cpp; aethersdr#6075 retired that
file as a source-text test, and its behavioural successor wdsp_nb_hold_test is
ported to the new signature here.

Clean-room: WDSP's NOB is in-tree. deskHPSDR was read for which knobs exist
and what its defaults are; no code was copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Principle VII.

The read path already refused. `get hostnb` declines without
RadioCapabilities::hasHostNoiseBlanker, arguing that an empty success is a test
passing against a radio where the question is meaningless. The write path six
thousand lines down had no equivalent check, so `slice dsp nb2 on` put a Flex or
an Icom slice into NoiseBlankerKind::Advanced — a kind no backend on those
families can map onto anything.

Not a harmless no-op, for three reasons that compound:

  * It sticks. SliceModel::applyChanges is deliberately written so a radio's
    bool `nb` echo cannot pull a host kind back down, so nothing the radio says
    afterwards clears it. Only a full NB off does.
  * The button lies. VfoWidget::refreshNbControls keys the label on the kind
    alone — only the fill row is capability-gated — so the NB button reads
    "NB2" on a radio that has no NB2.
  * IcomCivBackend::setSliceNoiseBlanker folds anything non-Off to plain "on",
    which is faithful to the wire and means nothing upstream ever notices the
    disagreement.

The invariant is stated in IRadioBackend.h:764 and SliceModel.h:317, and until
now was enforced nowhere. The GUI was never the hole: the NB2 leg of the button
cycle is already gated on m_hasHostNoiseBlanker (VfoWidget.cpp:2674). The bridge
was the only unguarded door to Advanced.

Placed with the control-name validation rather than inside the nb/nb2 branch,
because on such a radio `nb2` is not a control given bad arguments — it is not a
control at all. That also puts the refusal ahead of slice resolution, so a radio
with no slice yet is told about the capability instead of "no slice available",
which would send a caller looking for a slice on a radio that can never have the
control.

Reads the capability rather than RadioModel::hasHostNoiseBlanker(), which is
additionally gated on isConnected(): that gate exists to keep a BUTTON off an
unconfirmed radio, and no other branch of `slice dsp` refuses for being
disconnected. Same read as `get hostnb`.

New target automation_nb2_capability_test drives the real dispatcher with a stub
backend and an injected slice. Four mutations, all killed: the guard deleted,
the guard widened to `nb` as well, the polarity inverted, and the guard moved
below slice resolution — the last proving the placement and not just the
presence.

Rebased onto b714cd3. aethersdr#5982's hl2_rxdsp_squelch_test landed between this
branch's old base and the rebase and builds a channel to prove the squelch
survives a rebuild swap, so its buildChannel() call is adapted to the kind/fill
signature; the blanker is incidental there and stays off. 599/599 ctest with one
deliberate skip, twelve ratchets clean, check_command_plane exactly at its 406
raw-command baseline across 32 files, and the colour delta +0 on all three
counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ures.

Neither file existed at this branch's previous base, so neither conflicted and
neither appeared in the rebase: hl2_rxdsp_adaptive_taps_test arrived with aethersdr#5981
and reroute_dead_controls_test with aethersdr#6049, and both call signatures this branch
widens.

hl2_rxdsp_adaptive_taps_test: four buildChannel(cfg, false, 0) calls become
(cfg, NoiseBlanker::Off, 0, NoiseBlankerFill::Zero). These cases assert filter
tap lengths across a rebuild and never engage a blanker, so off with a zero
fill is what they already had.

reroute_dead_controls_test: the fake backend's setSliceNoiseBlanker override
takes the kind and fill, and records kind != Off as the bool its rows already
assert — the same reading a radio with one blanker gives the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… Principle VIII.

Answers the 10-04 review of aethersdr#5966 and the maintainer's rulings on it.

The exclusivity ratchet guarded the wrong thing. WdspChannel::processIq feeds
exactly one blanker stage per block, so a run flag left on the unchosen stage
blanks nothing. The review's mutation that does double-blank (both stages fed,
the ANB run flag left on under NB2) passed check_nb_exclusivity.py and failed
DISCRIMINATION 2 in hl2_noise_blanker_test. The checker, its Static checks
step and its docs/agents/backends.md entry are removed. The test's closing
comment and the run-flag comment in WdspChannel::setNoiseBlanker now name the
dispatch as the guarantee and the discrimination checks as its pin.

The NB2 fill row now follows m_nbModeOk, so it leaves with the NB button in FM
(ruling 4). refreshNbControls() runs at both mode-recompute sites: the
modeChanged handler, and syncFromSlice(), whose earlier refresh ran before
m_nbModeOk was computed. A new fm_filter_controls_test case covers a live
switch to FM and attaching a slice already in FM. The shared DSP level row is
unchanged.

Review nits, comments only: the slice dsp nb2 guard comment loses its stale
line cites and fits five lines; WdspNoiseBlanker.h no longer claims its
static_asserts catch a value added to one enum; the long comments in
IRadioBackend.h, NoiseBlankerKind.h and refreshNbControls() are trimmed.

On 85f2a12: build clean, ctest 655 of 656 (pgxl_panel_test fails; none of its
sources differ from main), and every Static checks step passes with --strict.
Five mutations, all killed: the fill row ignoring the FM gate, each of the two
mode sites not refreshing, the Flex fill refusal removed, and a kind change
sent as a Level request.

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

Copy link
Copy Markdown
Contributor Author

Thanks for the rulings. All of them are addressed at 3ef4cd36, and the body is rewritten to match.

  • Blocker 1 and ruling 1. Rebased onto 85f2a124 and moved onto aetherd: consolidate slice receive-control routing #5919's typed route in 340d00b8. SliceDspRequest gains kind and fill members and one new field, Field::Fill. A change of blanker travels as Field::Enabled with the kind attached, not as a new Field::Kind: the NB button calls setNbKind() on every radio, and this way Flex and Sim read enabled exactly as before. Flex refuses Field::Fill as Unsupported; the fill is only reachable where hasHostNoiseBlanker is set.
  • Rulings 2 and 3. Unchanged, as accepted.
  • Ruling 4. The fill row now follows m_nbModeOk and is refreshed at both mode-recompute sites (the modeChanged handler and syncFromSlice()). New case in fm_filter_controls_test, with three mutations behind it, all killed. The shared level row is untouched.
  • Blocker 2. Your M1+M2 table settled it. The checker, its workflow step and the canon entry are gone. The test's closing comment now points at the discrimination checks, and the run-flag comment in WdspChannel::setNoiseBlanker names processIq's single-stage dispatch as the guarantee.
  • Nits. All four, comment-only.

Locally, ctest passes 655 of 656. The one failure is pgxl_panel_test, whose sources are identical to main's. Main has since moved to 12df6943; the merge is clean, and CI runs on that merge. Not repeated on this head: the G2 bench check.

jensenpat
jensenpat previously approved these changes Oct 7, 2026

@jensenpat jensenpat 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.

Reviewed 3ef4cd360f62047abd7534b2d5d1f36eb2e9d1d2.

Issue fit: no linked issue. The change matches the PR body and the maintainer rulings on 2026-10-04: kind and fill ride a widened SliceDspRequest, the NB button cycles Off → NB → NB2 on a host blanker, setNb(true) selects the first blanker, the bridge surface is the accepted slice dsp nb2 plus kind/fill readback, the FM fill row is hidden with the NB button, and the run-flag ratchet is gone.

Scope. Host-side WDSP only. HL2 and ANAN run at most one of ANB (nob.c) or NOB (nobII.c). Flex still sends nb=0|1 and returns Unsupported for Field::Fill with no wire text. Icom maps anything but Off to its one blanker and ignores fill. Sim still echoes enabled and still rejects a non-Enabled field, which is how a demo fill request stays off the wire. The capability hasHostNoiseBlanker is what shows the cycle and what admits slice dsp nb2.

Group What changed
Engine WdspChannel opens both stages, processIq feeds exactly one, TX hold still skips that call
Seam SliceDspRequest carries kind and fill; Field::Fill is Nb-only; requestedBlanker() maps a kind-less enabled request to Impulse
HL2 / ANAN one verb applies kind, level, and fill together, including across a rebuild
Flex / Icom bool blanker unchanged; fill is refused or ignored
Model / VFO nbOn() derived from kind; radio bool echo cannot downgrade NB2; fill row follows NB2 and m_nbModeOk
Bridge slice dsp nb2 gated like get hostnb; get slice adds nbKind / nbFill beside nb
Tests model, capability gate, ANAN readback, Flex contract, FM row, measured NB vs NB2

Findings: none that block. The three open threads are closed by this head or by the ruling: the nb2 capability gate is in AutomationServer::doSlice ahead of slice resolution; the FM fill row follows m_nbModeOk on both the mode change and slice attach; setNb(true) dropping NB2 to NB is the accepted bool door.

Nit (non-blocking): processIq comments that storing the kind before m_nbActive makes a relaxed load ordered. Those stores are memory_order_relaxed, so that ordering is not a C++ guarantee. A torn block during a switch would run the other stage once; the run flags and the single if/else still decide the steady state, and hl2_noise_blanker_test discriminates the two stages.

Verified on this SHA: Qt 6.12.0, RelWithDebInfo, headless. backend_receive_contract_test, anan_noise_blanker_readback_test, noise_blanker_kind_model_test, automation_nb2_capability_test, hl2_noise_blanker_test, fm_filter_controls_test — 6/6 passed. CI on this SHA is green for build, check-macos, check-windows, Static checks, and Sanitizer option configures (compile and static gates; the required CI jobs do not execute this suite). git merge-tree onto current main (25de97c3) is clean; the 15 commits since the PR base do not touch the blanker path.

create_nobEXT matches third_party/wdsp/upstream/wdsp.h, and nobII.c's switch (a->mode) is 0 zero, 1 sample-hold, 2 mean-hold, 3 hold-sample, 4 interpolate.

Non-blocking follow-ups (not merge prerequisites):

  • wdsp_nb_hold_test still measures the TX-edge hold on Impulse only. The hold is the same skip ahead of the kind branch, so NB2 is covered structurally and not by that measurement.
  • The Off → NB → NB2 click cycle is not driven by a widget test. fm_filter_controls_test covers the fill row leaving in FM, not the re-check that keeps the button down on the NB2 leg.

@jensenpat
jensenpat dismissed ten9876’s stale review October 7, 2026 13:57

Addressed on 3ef4cd3: SliceDspRequest carries kind and fill, the run-flag ratchet is removed, and the FM fill row hides with the NB button. Approving review 5443410128 is on that SHA.

Keep both the ANAN link-telemetry registration and the NB2 tests in tests.cmake.

@jensenpat jensenpat 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.

Reviewed 118255f7f51a4c7c1dc39d5876b607fd3be99e2e, the merge of origin/main into 3ef4cd36.

The only conflict was tests/tests.cmake: main added anan_link_telemetry_test and this branch added noise_blanker_kind_model_test and automation_nb2_capability_test at the same spot. Both registrations are kept. No blanker source hunk changed in that resolution.

Auto-merged ANAN/HL2/Icom/Flex/RadioModel files still carry kind, level, and fill on setSliceNoiseBlanker, and the new link-telemetry path on AnanBackend is intact. Rebuilt and reran, headless, on this merge: anan_backend_test, anan_noise_blanker_readback_test, automation_nb2_capability_test, noise_blanker_kind_model_test, backend_receive_contract_test, hl2_noise_blanker_test — 6/6 passed.

The approval on 3ef4cd36 still stands for the feature itself.

@jensenpat
jensenpat merged commit d5f34f0 into aethersdr:main Oct 7, 2026
5 checks passed
@tropo1234
tropo1234 deleted the anan-nb2 branch October 7, 2026 14:47
ten9876 added a commit that referenced this pull request Oct 12, 2026
… Principle VIII.

An audit of every page against the 106 PRs in v26.10.2, each fact checked
against the source at the release commit:

- Menu paths: AetherMap, AetherModem, Callsign Lookup and FreeDV Reporter
  are on View, and Waveforms is on Tools.
- New coverage: Windows Firewall rules and the Network Diagnostics page
  (#6289), live CW contacts (#6268), NB2 on the HL2 and ANAN-G2 (#5966),
  ANAN-G2 AGC-T and per-radio memory (#6282, #6129), HL2 state restore
  and its level changes (#6259, #6254, #5975), DVK import (#6247, #6262,
  #6251), Copy Assist from unprocessed audio (#6216), keyboard-operable
  status-bar indicators (#6267), the KPA1500 (#5905), CTR2 extensions
  and the AetherKnob (#6338), live PPM (#6082), and Icom COMP and the
  squelch scale (#6174, #6184).
- Known issues whose GitHub issues are closed are removed.
- The style guide now asks for US spelling, and every page follows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 12, 2026
… and re-take the Stable snapshot. Principle VIII. (#6396)

## Summary

This brings the user guide (docs.aethersdr.com) up to date with
everything in v26.10.2 and re-takes the Stable snapshot so the default
site shows it. Docs only, apart from the TCI page prose in
`tools/docs/gen_reference.py`; no application source, test or CI
changes.

Every page was audited against the 106 PRs in the release. Every fact
that was added or kept was checked against the source at `f753dbfc4`,
the v26.10.2 release commit, not taken from the PR bodies alone.

## What changed

- **Menu paths.**
- AetherModem, Callsign Lookup, AetherMap and FreeDV Reporter are on
**Tools**. They are created on View and then moved to Tools
(`MainWindow_Menus.cpp:1519-1520`, `:1569-1575`); the style guide's
example says **Tools → AetherMap...**.
  - **Show live CW contacts** is on **View**.
  - Waveforms is on **Tools**, not File.
  - The VUDU applet button was called TXDSP.
  - Labels now match the source's `...` vs `…`.
- **Gaps from this cycle.**
- **Windows and Network Diagnostics:** Windows Firewall rules and the
Network Diagnostics Windows Firewall page (#6289); the `aether.network`
and `aether.nvafx` log categories (#6289, #6308).
  - **Remote access:** Tailscale shim revocation semantics (#6325).
  - **Sleep, App Nap and power throttling (#6195):** per-OS notes.
- **Hermes-Lite 2:** state restore, RX audio at unity, CW at TUNE level,
multi-interface discovery and a discovery troubleshooting entry (#6259,
#6254, #5975, #6009, #6293).
- **ANAN-G2:** AGC-T range, a "What is remembered" section, and supply
voltage and link status (#6282, #6129, #6137, #6211).
  - **NB2** on the HL2 and ANAN-G2 (#5966).
- **DVK:** WAV import requirements, the slot menu without Delete, status
text and licensing (#6247, #6262, #6251).
  - **Copy Assist** from unprocessed audio (#6216).
  - **Live CW contacts** replace the decoder's contact card (#6268).
  - **Status-bar indicators** work from the keyboard (#6267).
- **Volume:** controllers, hotkeys and TCI `volume` follow the active
output (#6129, #6260).
- **Peripherals:** the KPA1500 (#5905), CTR2 link extensions and the
AetherKnob (#6338), TMate 2 udev (#6250), MIDI RADE Modem and FDV modes
(#6031, #6038).
  - **RTL-SDR:** live PPM (#6082).
  - **Icom:** COMP and the MK2 squelch scale (#6174, #6184).
- **Known issues.** Entries whose GitHub issues are closed are removed
(each checked with `gh issue view`). As decided, this includes three
closed today without a demonstrated fix: #5107, #4824 and #5665.
- **US spelling.** `docs-style-guide.md` asked for British spelling; it
now asks for US, by the maintainer's decision, and every page is
converted. The generated TCI pages are converted at their source, the
prose strings in `tools/docs/gen_reference.py`. Code spans, links and
URLs were left alone. UI labels were checked against the source strings,
and none of them are British. Two in-page anchors and two Log Analyzer
rule anchors (`#bnr-is-grayed-out-…`) follow the renamed headings.
- **Stable snapshot.** `snapshot_stable.py 26.10.2` was re-run on the
release tree with these corrections, before `main` was merged in, so
Stable carries no post-release docs. `docs/` also has `main`'s
post-release pages, in US spelling. Stable alone keeps the IC-9700
waterfall-rate entry (#5996): #6402 fixed it after the 26.10.2 tag, so
26.10.2 still has it. The PDF manual already attached to the v26.10.2
release keeps the tag's docs.

## Validation

- `npm ci && npm run build` in `docs/user`: success. This is the
broken-link and broken-anchor check.
- `node tools/docs/test_log_rules.mjs`: 707 checks passed for 45 rules.
- `python3 tools/docs/gen_reference.py --check`: up to date (8 pages).
- `git diff --check`: clean.
- Menu paths on a build of this branch (offscreen, isolated settings, no
radio), `menu list`: `Tools → AetherModem...`, `Tools → Callsign
Lookup...`, `Tools → AetherMap...`, `Tools → FreeDV Reporter...`, `Tools
→ Waveforms...`, `View → Show live CW contacts`.

## Screenshots now out of date (not recaptured)

These need a recapture pass on the radio:
- **Old panadapter title strip (#6386):** `panadapter`, `main-window`,
`first-session-*`, `multi-slice-two-slices`, `split-pair`,
`tnf-markers`, `offscreen-vfo-indicator`, `pan-3d-stacked-trace`,
`default-light-theme`.
- **Pre-canon windows (#6334, #6339, #6341, #6372):** `aetherrx*`,
`aethertx*`, `aethermodem-aprs`, `bnr-panel`, `connect-dialog`,
`connect-by-ip-card`, `connect-icom-rows`, `connect-smartlink-card`,
`rx-agc-calibration`.
- **Other:**
  - `title-bar` (lowercase tab states, #6281);
  - `menu-help` (#6294);
  - `display-panel` (#6371);
  - `support-diagnostics-dialog`;
  - `waveforms-dialog` and `tailnet-remote-access` (show shim 0.4.0);
  - `about-window` (v26.10.1);
  - `radio-setup-qrz`.

## Found during the audit, not fixed here

- **App string bug:** `TailnetShimDialog.cpp:628` tells the user to
"Connect remotely with Connect → Manual"; the real path is **File →
Connect to Radio... → Connect by IP**.
- **ANAN AGC-T tooltips:** the RX applet and VFO tooltips show the
slider position as dB, which is wrong on ANAN. #6282 lists this as a
follow-up.
- **MIDI Master Volume on non-Flex radios:** it is refused while PC
Audio is off, whereas hotkeys, FlexControl and HID drive the line out.
The docs describe the code as it is.
- **RTL mode names:** `rtl-sdr.md` writes "FM-N" and "CW-R", but the
source strings are `FMN` and `CWR`. Left as is until someone confirms
what the mode menu displays.
- **IC-705 COMP readback:** inferred from the MK2 (#6174) and not
verified on an IC-705.
- **Not yet documented:** the #6230 Radio Health row "Forward responses
skipped", and the Store/MSIX package.

🤖 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.

3 participants