Skip to content

feat(asr): Copy Assist can transcribe before client NR. Principle V. - #6216

Merged
ten9876 merged 4 commits into
aethersdr:mainfrom
K5PTB:feat/copy-assist-pre-dsp-tap
Oct 9, 2026
Merged

ten9876 merged 4 commits into
aethersdr:mainfrom
K5PTB:feat/copy-assist-pre-dsp-tap

Conversation

@K5PTB

@K5PTB K5PTB commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements the design approved in #4861: Copy Assist can transcribe from the receive
audio before AetherSDR's noise reduction and RX effects chain, while the operator
continues to hear the fully-processed audio. One checkbox in Copy Assist settings,
"Transcribe from unprocessed audio (bypasses NR and RX effects)", off by default —
a fresh profile transcribes after NR exactly as every earlier build did.

AudioEngine gains receivePresentationPreDspAudioReady, emitted where each block
enters processMixedRxAudioData() — the one point every RX source (Flex, legacy Kiwi,
external Kiwi antennas, with or without the NR2 packet queue) exists untouched by
client DSP. It carries the same open-sink and Kiwi transmit-gate conditions as the
existing post-DSP emit, so both feeds go quiet together and no new TX surface is
introduced. AsrAudioTap subscribes to whichever point AsrTapPoint selects and can be
switched live; a switch while enabled resets the engine and the receiver lock rather
than splicing one utterance across two chains with different level and latency.

Noise reduction is tuned for human perceptual quality and introduces artifacts — musical
noise, spectral holes, transient smearing, gain pumping — that are out-of-distribution
for a speech model trained largely on unprocessed audio. The operator's ears and the
recogniser want different front-ends, and today a single audio path serves both.


What bypassing NR gains you

Measured, not asserted. One 6.7-minute off-air 40 m SSB ragchew (four stations),
recorded by AetherSDR's own QSO recorder — which taps RadioModel::rxDemodAudioReady,
already upstream of all client DSP, so the same signal is replayed through each NR
algorithm offline. Human reference transcript, 943 words, with unintelligible and
non-speech spans marked. Scored on the 53 windows whose content is fully known
(833 words). whisper large-v3-turbo, fixed 5 s windows identical in every arm,
context carry off.

1. Word error rate: 35.2% → 22.7%

A 35% relative reduction in errors against the NR setting in use at the time (NNR).

audio fed to ASR WER penalty vs unprocessed
unprocessed (pre-DSP tap) 22.7% —
MNR 27.1% +4.4
NR4 28.3% +5.6
NNR 35.2% +12.5
NR2 48.9% +26.2
RN2 59.9% +37.2
DFNR 72.0% +49.3

No NR algorithm beats unprocessed audio. The NNR gap is +12.5 points, 95% CI
[+5.9, +19.8] from a 10,000-sample bootstrap over rows; unprocessed wins in 100% of
resamples. Per-row, unprocessed is better in 27 of 53 rows and worse in 8.

2. The errors it removes are the damaging kind

substitutions deletions insertions
unprocessed 44 107 38
NNR 118 133 42

Substitutions nearly triple under NR while deletions barely move. NR is not losing
words, it is turning them into different words — which is worse for an operator,
because a wrong callsign reads as fact while a missing one is visibly missing. This is
the artifact mechanism #4861 predicted, confirmed.

3. The gain is largest on the signals that need it most

Split by speaker (SpeakerEmbedder embeddings clustered offline; two stations fully
copyable, two marginal at 38% unintelligible):

speaker group rows words unprocessed NNR gain
copyable 45 754 21.0% 31.8% 10.8 pts
marginal 8 79 39.2% 67.1% 27.4 pts

Both intervals exclude zero. The magnitude on the marginal group is loosely bounded
(8 rows, CI [+4.5, +48.8]) so treat it as direction rather than a figure — but the
direction is the opposite of NR's rationale for an ASR feed.

4. Fewer provable hallucinations

In the three reference rows marked as non-speech, where any emitted word is invented by
construction: unprocessed produced 0 words, NNR produced 5. Small sample, and
the only metric here needing no interpretation.

5. It is not a whisper quirk, and not a level artifact

  • sherpa-onnx (zipformer, a different architecture) reproduces the same ordering.
    Its top four modes sit within 0.9 points — NR neither helps nor hurts it much — and
    the destructive modes are destructive for both.
  • The effect is spectral, not gain. RMS-matching every arm before transcription
    changes WER by at most 0.2 points (NNR: 35.2% → 35.2%), so none of the WER gap is
    explained by one feed being louder than another.

What this measurement does not cover

Every arm was transcribed on fixed 5 s windows, identical across arms, so that a
WER difference could only come from the audio. That is deliberate, and it means
AsrSegmenter — the product's VAD/energy gate — was never in the loop. These numbers
describe what the recogniser does with each kind of audio, not what the shipped
segmenter will hand it. The level behaviour that does affect the segmenter is
addressed below, from the engine rather than from this data.


Keeping Sensitivity calibrated across the toggle

Sensitivity sets an absolute RMS threshold in AsrSegmenter
(sensitivityToRms(): 1 → 0.050, 100 → 0.001). The post-DSP feed is the output of the
full RX chain, and several of its stages add gain the pre-DSP feed never sees:

stage effect on the post-DSP feed
RX boost (m_rxBoost, tanh(x·2)) +6.02 dB on small signals
RX output trim (m_rxOutputTrimDb) −12 … +12 dB
EQ master gain (ClientEq::masterGain) −∞ … +12 dB
Compressor makeup (ClientComp::makeupDb) operator-set
Tube output gain (ClientTube::outputGainDb) −24 … +24 dB

So an operator who tunes Sensitivity with boost on and trim up is tuning against a feed
that can be +18 dB hotter than the raw one before the chain stages are counted at
all. Measured on the real engine with the chain at defaults: +0.00 dB, +6.01 dB
with boost on, +18.01 dB with boost on and trim at +12 dB. Flipping the checkbox
without compensating would leave a stored Sensitivity meaning two different things, and
at the top of that range speech would fall under the gate — e.g. at Sensitivity 41 the
threshold is 0.0302, which a 0.026-RMS raw speech block misses while the same block
boosted and trimmed clears it easily.

This PR scales the threshold instead. AudioEngine::rxStaticChainMakeupDb() sums the
chain gains that are one number regardless of what the audio is doing, counting a
stage only when it is both present in the operator's stored chain order and enabled —
the same walk runRxChain() does, so a stage removed from the chain contributes
nothing. It reads the RX singletons, which RxClientEffects::syncParametersFrom()
mirrors into each external source's own instances, so one number holds for every source.
asrSpeechRmsForTapPoint() (pure, in AsrTapPolicy.h) then divides the operator's
tuned RMS by boost × trim × that chain gain.

AsrAudioTap owns the application, because AudioEngine publishes no change signal for
any of these inputs — so the tap re-evaluates on tap-point change, on a Sensitivity
change, and per block, and pushes a new threshold only when the total gain actually
moves (≥0.01 dB). The post-DSP path returns the tuned value untouched, so the default
configuration is bit-identical to before. A corrupt trim, or an EQ master gain of zero
(−∞ dB, which silences the post-DSP feed outright), leaves the threshold at the tuned
value rather than producing a zero or infinite gate.

What this does not correct for, because no scalar describes it: the
signal-dependent part of the chain — EQ band shaping, the gate, compressor gain
reduction, tube drive and pudu. Each NR mode's own speech attenuation is in the same
category, and already leaves Sensitivity unanchored across NR modes (see below).

All Sensitivity writes now funnel through one method,
CopyAssistController::applySensitivity(), so the slider and the startup seed cannot
diverge: both set the base RMS on the tap and the tap decides what the engine gets.

Two notes for the record, neither changed here:

  1. On a noisy band the energy gate is the weak link, not the level. The raw feed has
    very little RMS separation between speech and the noise floor (0.2 dB measured), so an
    energy gate admits nearly everything regardless of where the threshold sits, and
    closing it down removes speech rather than noise. The tooltip says so and tells the
    operator to leave Sensitivity high. It explicitly does not recommend Silero VAD for
    this case: measured on the same recording, the raw feed scores 28.3% WER with the
    energy gate wide open against 87.6% with Silero at its default threshold, which admits
    44 s of speech out of 402. Silero handles clean speech correctly (27.5 s of 34 s,
    segmenting on sentence boundaries), so this is a learned detector meeting a signal
    outside its training distribution, not a defect — and it admits 2.7x more audio from
    the NR-processed feed than the raw one, wanting the front-end whisper does not.
  2. Sensitivity is already not anchored across NR modes. Each NR algorithm attenuates
    speech differently (−2.6 to −4.1 dB measured), so a value tuned under one mode
    already means something else under another. That predates this PR and is out of
    scope.

Constitution principle honored

  • Principle V — the tap point persists as AsrTapPoint inside the nested
    CopyAssist object via CopyAssistSettings, not a flat Asr* key. Stored as a name
    ("PostDsp" / "PreDsp") rather than a bool so a third tap point is an added value
    instead of a migration; anything unrecognised reads as PostDsp, which is what every
    build before this setting did. An existing operator's stored choice survives the
    setting unchanged.
  • Principle VIII — the tables above replace the operator impression that motivated
    RFC: raw (pre-noise-reduction) audio for Copy Assist ASR #4861. The RFC committed to measuring before proposing; this is that measurement, and
    it states plainly where it stops and where the numbers come from the engine instead.
  • Principle VI — receive path only. TX and the operator's monitored audio are
    untouched, and the new emit reuses the existing gate conditions.

Test plan

  • Local build passes (cmake --build build -j4) — clean, zero errors, whole tree
  • Full ctest green on this branch rebased to main
  • asr_pre_dsp_emit_test (new) — pins the new emit on a headless AudioEngine
    driving the real processMixedRxAudioData() into a QBuffer sink: payload is the
    input block byte-for-byte, it carries the producer rate and a stereo tag, Flex and
    legacy Kiwi sources are labelled correctly, and a closed sink silences both feeds
    together. The load-bearing check is the placement: with RN2 enabled but not ready
    the NR cascade returns early, the post-DSP feed goes silent, and the pre-DSP feed
    must keep flowing. Moving the emit below the NR branch turns exactly that check
    red, and nothing else in the suite.
  • asr_tap_policy_test (extended) — the tap-point name round-trips and an unknown
    value reads as PostDsp; and the threshold scaling: post-DSP is unscaled, boost
    halves the threshold, +12 dB trim divides it by 3.98107, the two compound to the
    +18 dB worst case, a negative trim raises it, a non-finite trim leaves it alone,
    chain makeup divides on top of both, negative chain makeup raises the threshold,
    and a silenced chain falls back to the tuned value.
  • asr_pre_dsp_emit_test (extended) — rxStaticChainMakeupDb() on the real engine:
    a fully disabled chain is 0 dB, compressor makeup and tube output gain add, the EQ
    master gain converts from linear, a disabled stage stops contributing, a stage
    absent from the chain order contributes nothing even while enabled, and a zero
    master gain reports −∞ rather than a finite number.
  • asr_audio_tap_test (new) — real AudioEngine → real AsrAudioTap → real
    AsrEngine, counting chunks: each tap point delivers one chunk per block and not
    one per feed, a tap never enabled delivers nothing, and — the load-bearing one — a
    block emitted from the old tap point and still in the queue when the operator
    switches is dropped rather than opening the new session with audio from the other
    chain. Closes the controller-wiring gap this PR previously disclosed.
  • asr_engine_test (extended) — restartSession() emits the reset before the
    resume that re-enables worker processing, and a plain reset() emits no resume, so
    the ASR-disable path keeps its cancel flag latched as before.
  • copy_assist_settings_dialog_test (extended) — the checkbox exists and is off by
    default, its label names the RX effects and not just NR, its tooltip covers the
    gate and does not recommend Silero for the noisy-band case, the row sits above
    Silero so Silero keeps its own VAD-model row, seeding from the store does not echo
    as an operator
    edit, a click emits the new state, and the stored value survives an
    AppSettings::load() re-read in both directions.
  • Each new guard mutation-checked — every assertion above was confirmed to fail
    against a deliberately broken implementation before being kept.
  • Verified against a real radio, with the limits stated — the engine and tap path
    ran live on a FLEX-6500 through a contest weekend, via the header toggle this PR
    replaces with the checkbox. The settings-dialog checkbox itself has been verified
    on a connected FLEX-6500 for wiring and persistence only: its effect on
    transcription was not re-evaluated on air, because the radio is currently on a
    dummy load. The audio path it selects is unchanged from the on-air testing; what
    has not been re-confirmed off-air is the new control driving it.
  • Offline A/B against a human reference transcript (above)

Scope not covered

  • The measurement isolates noise reduction. Each arm differs from unprocessed only
    by the NR filter; the feature bypasses NR plus EQ, gate, compressor, de-esser, tube,
    boost and trim. The other six stages are not individually measured.
  • One recording, conversational audio. Contest exchanges — formulaically different, and
    the other half of the use case — are not scored.
  • WER was measured on fixed windows, so AsrSegmenter's behaviour on a raw feed is not
    part of these numbers (see above).
  • restartSession()'s discarding of the queued backlog is verified by the ordering
    contract above, not by a behavioural test: observing it requires racing the worker
    thread, and a timing-dependent test is not worth the flake.
  • No change to the default, and none proposed.

Checklist

  • Commits are signed
  • No new flat-key AppSettings calls — nested under CopyAssist (Principle V)
  • Code is clean-room
  • All meter UI uses MeterSmoother — n/a, no meter UI
  • Documentation updated — docs/architecture/radio-capabilities-map.md
  • Security-sensitive changes reference a GHSA — n/a

Refs #4861.

🤖 Generated with Claude Code

@K5PTB K5PTB left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Retracted by the author. Superseded by the current PR description.

Copy Assist has always transcribed the RX audio after the whole client
chain: the NR stage (NR2/RN2/NR4/DFNR/BNR/MNR), the RX effects, output
resampling, boost, trim and pan. In practice NR's artifacts — musical noise,
smeared onsets, gated word tails — confuse the speech model more than the
background noise NR removes. This adds a way to hand the recogniser the audio
before any of that, without changing what the operator hears.

Engine: AudioEngine gains receivePresentationPreDspAudioReady, the same
unthrottled, source-tagged stream as the post-DSP signal, emitted where each
block enters processMixedRxAudioData() — the one place every source (Flex,
legacy Kiwi, external Kiwi antennas, with or without the NR2 packet queue)
exists untouched by client DSP. It carries the same open-sink and Kiwi
transmit-gate conditions as the post-DSP emit, so both feeds go quiet
together. Its sampleRate is the producer rate, not the output rate; the ASR
worker already resamples whatever it is given. Processing done by the radio
or the Kiwi itself is still in the audio — this is "before AetherSDR's DSP",
not "off the antenna".

Tap: AsrAudioTap connects to whichever point AsrTapPoint selects, switchable
live. A switch while enabled resets the engine and the receiver lock rather
than splicing one utterance across two chains with different level and
latency. Each signal lands on its own slot that names its point, because
disconnect() does not withdraw queued calls already posted: without that, the
first blocks after a switch would open the new session with audio from the
old chain.

UI: a checkbox in Copy Assist settings, "Transcribe from unprocessed audio
(bypasses NR and RX effects)" — the name agreed in RFC aethersdr#4861, which is
deliberately longer than "bypass NR" because the feed also skips EQ, gate,
compressor, de-esser, tube, boost and trim. Unchecked is the default and the
historical behaviour. Its tooltip covers the one real caveat: on a noisy band
the unprocessed feed has almost no level difference between speech and the
noise floor, so the energy-based Sensitivity gate admits nearly everything,
and Silero VAD is the better pairing because it decides by content.

Sensitivity calibration: AsrSegmenter's default gate is an ABSOLUTE RMS
threshold driven by the Sensitivity slider, and the post-DSP feed carries the
operator's RX boost and output trim while the pre-DSP feed does not — measured
on the real engine at +0.00 dB with defaults, +6.01 dB with boost on and
+18.01 dB with boost plus a +12 dB trim. Left alone, an operator who tuned
Sensitivity against their post-DSP level would have speech drop under the gate
the moment they ticked the box. So the THRESHOLD moves, not the audio:
asrSpeechRmsForTapPoint() (pure, in AsrTapPolicy.h) divides the operator's rms
by that gain while the tap is PreDsp. The feed stays bit-exact, which is the
point of the feature and what the WER evidence was gathered on, and nothing
can clip. AsrAudioTap owns the scaling because only it knows which point is
live, and it re-reads the engine's boost and trim per block — AudioEngine
publishes no change signal for either, so a controller-side computation would
go stale as soon as the operator moved one. setSpeechRms is pushed only when
the gain actually changes. This does NOT fix discrimination: on a noisy band
the unprocessed feed has little level difference between speech and the noise
floor, so no threshold separates them, and the checkbox's tooltip points at
Silero VAD for that.

Settings: stored as AsrTapPoint inside the nested CopyAssist object
(Principle V), as a name rather than a bool so a third tap point is an added
value instead of a migration. Anything unrecognised reads as PostDsp, which
is what every build before this setting did.

Tests:
- asr_pre_dsp_emit_test: drives the real processMixedRxAudioData() against a
  QBuffer sink on the same socket-free friend seam audio_engine_rates_test
  uses. Checks the payload is the input byte-for-byte, the producer rate and
  stereo tagging, flex vs kiwi source names, that a closed sink silences both
  feeds together, and — the load-bearing one — that an NR processor which is
  enabled but not ready returns early from the post-DSP path while the pre-DSP
  feed keeps emitting. Moving the emit below the NR cascade turns exactly that
  check red and nothing else.
- asr_tap_policy_test: the tap-point name round-trips, an unknown value reads
  as PostDsp, and the threshold tracks the tap-point gain — unscaled for
  PostDsp, halved for PreDsp with boost on, divided by the trim's linear gain,
  compounding for both, raised for a negative trim, and left alone for a
  non-finite one.
- copy_assist_settings_dialog_test: the checkbox exists and is off by
  default, its label names the RX effects and not just NR, its tooltip covers
  the gate and points at Silero, seeding from the store does not echo as an
  operator edit, a click emits the new state, and the stored value survives
  an AppSettings::load() re-read of the database (a simulated restart) in
  both directions.
Each guard was mutation-checked — default, label, tooltip, silent setter,
emitted state, stored name, fallback, dropping the save to disk, moving the
pre-DSP emit below the NR cascade (exactly one check reddens), removing the
threshold scaling, inverting it to multiply, and dropping the non-finite trim
guard — and each mutation failed its test before the source was restored.

Verified: builds clean on macOS. vkamp_connection_test fails only under
parallel ctest load and passes alone; it shares no source with this change.
Run live on a FLEX-6500 through a contest weekend.
Measured offline against a human reference transcript of an off-air 40 m SSB
ragchew: 22.7% WER from the unprocessed tap against 35.2% after NNR, +12.5
points, 95% CI [+5.9, +19.8], unprocessed better in 100% of 10,000 bootstrap
resamples. No NR mode tested beats unprocessed audio, and RMS-matching the
arms moves WER by at most 0.2 points, so the effect is spectral rather than
level. The same ordering reproduces on sherpa-onnx.

Not verified: the stale-block drop after a live switch has no automated
coverage — that needs an AsrEngine with a fake backend, which is heavier than
the engine seam. The controller wiring from the checkbox to the tap is
likewise untested. Contest exchanges are not scored, and the measurement isolates NR —
the other six effects stages the feed bypasses are not individually measured.

Refs aethersdr#4861.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@K5PTB
K5PTB force-pushed the feat/copy-assist-pre-dsp-tap branch from 02df42c to ed79018 Compare October 6, 2026 07:48
@K5PTB
K5PTB marked this pull request as ready for review October 6, 2026 08:15
@K5PTB
K5PTB requested a review from a team as a code owner October 6, 2026 08:15

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

Issue fit

Yes. This implements RFC #4861 and, more to the point, it clears every decision ten9876 carried forward in the approval comment — I checked them one at a time:

Ruling (#4861) Where
Emit above the NR if/else, "load-bearing for correctness" src/core/AudioEngine.cpp:5510-5527
Level-matching in scope, Sensitivity stays calibrated across the toggle src/gui/AsrTapPolicy.h:70-99 + src/gui/AsrAudioTap.cpp:84-103
Whole-chain bypass, not per-stage single emit on the raw pcm
Default stays off asrTapPointFromSetting() fallback + dialog default
Name: "Transcribe from unprocessed audio (bypasses NR and RX effects)" src/gui/CopyAssistSettingsDialog.cpp:131-132, verbatim
Nested CopyAssist setting, not a flat Asr* key CopyAssistSettings::value/setValue("AsrTapPoint")
Segmenter flush on toggle and reset AsrTapPolicy's source lock, not just the connection src/gui/AsrAudioTap.cpp:65-69

The RFC gated merge on measured WER evidence; §"What bypassing NR gains you" is that evidence, with the segmenter-out-of-loop limitation stated rather than hidden. Principle VIII is satisfied on its own terms.

Scope

File(s) Change Claimed? Verdict
src/core/AudioEngine.{h,cpp} new receivePresentationPreDspAudioReady + emit yes In scope — new public signal, but it is RFC design-sketch item 1
src/gui/AsrTapPolicy.h AsrTapPoint, setting codec, asrSpeechRmsForTapPoint() yes In scope
src/gui/AsrAudioTap.{h,cpp} tap selection, stale-block drop, threshold scaling yes In scope
src/gui/CopyAssistSettingsDialog.{h,cpp} the checkbox yes In scope
src/gui/CopyAssistController.{h,cpp} wiring + applySensitivity() funnel yes In scope
src/gui/MainWindow.cpp:10302-10310 comment-only not in the body In scope, trivially — flagging only so it isn't mistaken for behavior
docs/architecture/radio-capabilities-map.md ASR row names both signals yes In scope
tests/ + tests.cmake 1 new test, 2 extended yes In scope

Clean: one commit, no CHANGELOG.md entry (correct — don't add one), no vendored/CI/build churn, no new flat settings key. I read the - lines too: the only deletions are the old inline connect() in setEnabled() (moved verbatim into connectSelectedTap(), AsrAudioTap.cpp:105-126) and rewritten header comments. No guard, early return, or fixed-issue comment removed.

Socket tests: none added, modified, or removed. asr_pre_dsp_emit_test is socket-free — it drives the real processMixedRxAudioData() into a QBuffer through the AudioEngineRatesTestAccess friend seam (AudioEngine.h:1266), exactly as audio_engine_rates_test does.

Preference check: this is a new opt-in control, default off, with the name a collaborator specified. No existing UI element or default was changed. Nothing smuggled.

Blockers

None.

Nits

All non-blocking.

  1. src/gui/AsrTapPolicy.h:88 — "exactly the make-up gain" is overstated.

    // So scale the THRESHOLD rather than the audio: the unprocessed feed stays
    

    and above it, "divides the operator's tuned RMS by exactly the make-up gain the raw feed is missing" (PR body). Boost and trim are not the only level-changing stages between the two taps: ClientComp::makeupDb() (src/core/ClientComp.h:44) and ClientEq's per-band gainDb (src/core/ClientEq.h:149) also sit in runRxChain(), upstream of the post-DSP emit and downstream of the new one. An operator running the compressor with makeup gain gets a threshold off by that amount after the toggle. The measurement quoted (+0.00/+6.01/+18.01 dB) was evidently taken with the chain at defaults, which is the common case — so this is a wording fix, not a design change. Suggest "boost and trim" instead of "exactly", with the other chain stages listed next to the NR-mode note you already carved out as out of scope.

  2. src/gui/AsrAudioTap.cpp:69 — the reset doesn't cancel the worker backlog, so "starts over" has a tail.

            m_asr->reset();

    AsrEngine::setEnabled(false) deliberately pairs setCancelPending(true) with reset(), and its comment says why: "reset() alone (a queued signal) would just sit behind the same backlog it's meant to clear." A bare reset() here keeps worker ordering correct (old blocks → reset → new blocks, so nothing splices), but with a 20 s decode buffer the operator can keep receiving old-chain text well after flipping the checkbox — softer than the header's "starts transcription over". Either mirror the disable path or say so in the comment and the tooltip.

  3. Stale "NR button" references in the new test comments. tests/asr_tap_policy_test.cpp:242 ("every operator who turned Copy Assist's NR button off"), tests/copy_assist_settings_dialog_test.cpp:90 ("the panel's NR button") and :612 ("Replaces the header NR button"), plus assertion strings reading "(NR on)" / "NR off (PreDsp)". Two problems: that control exists on neither this branch nor main (I grepped CopyAssistPanel.* and the whole of src/ on the PR head), so a future reader will hunt for it; and "NR on/off" is precisely the framing ten9876 rejected as undersell — the toggle moves where ASR listens and leaves NR running for the operator. Worth rewording to "tap point" while the context is fresh.

  4. src/gui/CopyAssistController.cpp:573-576 — the fallback arm looks unreachable.

        if (m_asr) {
            m_asr->setSpeechRms(rms);
        }

    Both call sites run with m_tap already non-null: the slider handler is wired in the ctor which calls buildEngine() at :535, and applyTuning() is invoked at :1132, inside buildEngine(), after m_tap = new AsrAudioTap(...) at :1060. Harmless as defense, but the comment ("No tap yet (ASR disabled)") describes a state I could not construct — worth either deleting or correcting.

  5. src/core/AudioEngine.cpp:5520 — the sibling re-checks the pointer, this doesn't.

        if (m_audioDevice->isOpen() && !txPresentationGated) {

    m_audioDevice is a QPointer<QIODevice> whose own declaration says "may vanish on hot-unplug" (AudioEngine.h:1062), and writeAudio re-tests !m_audioDevice || !m_audioDevice->isOpen() at :5549 despite the identical function-top check at :5481. Pure consistency — the window is tiny and a QPointer is not thread-safe either way — but the defensive form is the house pattern here.

  6. Deviation from the ruling's literal wording, twice, both defensible — flagging for maintainer acknowledgement rather than as problems. (a) ten9876 said "Put it immediately inside the m_dspMutex guard, before if (bypassRxChainForTx)"; the emit is ~200 lines earlier, outside the lock. I checked: there is no return on the straight-line path between :5527 and the mutex block at :5719 (the only return in that span is inside the writeAudio lambda at :5549), so it is behaviorally equivalent and strictly cheaper — no work under the DSP lock. (b) the ruling said "apply boost/trim gain to the raw fan-out or normalise in the tap"; scaling the threshold is a third option, and the body's reasoning for it (raw feed stays bit-exact, which is what the WER evidence was gathered on, and nothing can clip) is sound. Both read as improvements; they just aren't what the text said.

  7. Two factual notes on the body's test-plan claims, neither a defect. asr_pre_dsp_emit_test appears in no ctest -R filter in .github/workflows/ci.yml, so it does not gate PR merges — it runs in the full-suite lane only. And the head's parent is 03dcff47c, so CI ran against a merge base five commits behind main; "Full ctest green on this branch rebased to main" was true at an older main. I specifically checked the one intervening commit that touches a file you edit — #6208 (035a85835, sanitizer TIMEOUT scaling) — and your bare set_tests_properties(... TIMEOUT 120) is the correct post-#6208 idiom: aether_retrofit_tests() multiplies existing explicit TIMEOUTs by AETHER_SANITIZER_TIMEOUT_SCALE, and it matches audio_engine_rates_test:211 exactly. Nothing to change; a rebase is just hygiene.

  8. Disclosed coverage gap, now cheaper than you think. The body honestly says the controller wiring and the stale-block drop have no automated coverage. The from != m_tapPoint guard (AsrAudioTap.cpp:147) is the subtlest new logic in the PR, and this PR itself demolishes the reason asr_tap_policy_test's old header gave for not testing the tap ("AudioEngine cannot be constructed headless") — you replaced that comment with the opposite claim. A tap-level test on the same friend seam now looks reachable. Not merge-gating under the test-layer boundary, but it's the next test I'd write.

What I tried to break

  • Moved the emit, mentally, below the NR if/else. The test's load-bearing claim holds: m_rn2Enabled true with m_rn2 null makes rn2ForSource() return nullptr and processMixedRxAudioData() returns at AudioEngine.cpp:5724 — post-DSP silent, pre-DSP still flowing. nr4, dfnr, nnr, nvAfx and mnr all have the same shape. The test would go red and nothing else would. It is not an implementation-echo test.
  • Source-tag and sample-rate divergence between the two feeds, which would break AsrTapPolicy's first-block-wins source lock. Both emits use the identical source == KiwiSdr ? "kiwi" : "flex" and externalSource ? externalSource->id : QString() expressions. Rates legitimately differ (producer vs m_rxOutputRate) and the header calls that out; pushAudio() takes the rate per call, and the drain math at :2378 confirms external-Kiwi native really is DEFAULT_SAMPLE_RATE, so the 24000 tag is right.
  • Frame misalignment reaching toMono(), which would drop every block for the session behind a once-per-enable warning. I walked all five processMixedRxAudioData() call sites (:2230, 2242, 2259, 2363, 2386, 2426): every one slices on a frameBytes multiple, and appendRxAudio() truncates to alignedBytes before queueing. No unaligned path.
  • Threshold left stale across enable/disable/toggle sequences. Walked PreDsp+boost → disable → uncheck → re-enable: setTapPoint() early-returns while disabled without rescaling, and setEnabled(true) doesn't rescale either — but the per-block applySpeechRmsForTapPoint() at AsrAudioTap.cpp:154 catches it on the first block, before pushAudio(), because m_appliedGainDb still holds the old point's 6.02 dB. I also checked that reset() can't clobber the threshold: AsrWorker::reset() → AsrSegmenter::reset() touches no m_config field, and in setTapPoint() the reset() is queued before the setSpeechRms(), so ordering is right. And sensitivityToRms() clamps to 1–100 → always ≥ 0.001, so applySpeechRmsForTapPoint()'s m_baseSpeechRms <= 0.0f bail can't swallow a real value.
  • Compile-fragility on the headers I'd expect to bite — std::pow/std::isfinite in AsrTapPolicy.h and std::numeric_limits in the test. <cmath> and <limits> are both already present; no transitive-include luck involved. check-windows and check-macos are green.
  • Could not check: anything at runtime. Every finding above is reasoned from the code in /tmp/aetherclaude/pr-6216, not reproduced — I have no build, no test run, and no way to drive the dialog. Specifically unverifiable by me: the WER tables (one recording, one operator, no artifacts attached), the "mutation-checked every new guard" claim, and the FLEX-6500 bench note — whose own limitation (dummy load, checkbox verified for wiring and persistence only) you stated yourself, which is the right way to report it.

Recommendation

Approve with nits. This is the strongest implementation-of-an-RFC I've reviewed on this project: every carried-forward ruling is met, the emit placement the maintainer called correctness-critical is pinned by a test that genuinely fails if it moves, the level confound that dominated the RFC thread is handled from the engine rather than hand-waved, and the body is candid about where the measurement stops. The nits are wording and polish, not correctness — #1 (drop "exactly", list the other chain stages) and #3 (the phantom "NR button") are the two I'd actually fix before merge, both one-line edits. Rebasing onto current main is worth doing so CI reports against the tree it will merge into.

Nice work, and thank you for going and getting the WER numbers instead of shipping the impression — the substitution-vs-deletion breakdown in particular is the kind of evidence that makes a design decision durable.


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

@K5PTB

K5PTB commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

To anyone contemplating a review: I am evaluating the "nits" listed in this review, to decide if any of them are serious enough to resolve.

Boost and trim were not the only gain between the two tap points. EQ
master gain, compressor makeup and tube output gain all sit in
runRxChain(), upstream of the post-DSP emit and downstream of the
pre-DSP one, so an operator running any of them still got a threshold
wrong by that amount after the toggle.

AudioEngine::rxStaticChainMakeupDb() sums the chain gains that are one
number regardless of what the audio is doing, counting a stage only when
it is both enabled and present in the operator's stored chain order —
the same walk runRxChain() does, over the same atomic. It reads the RX
singletons, which RxClientEffects::syncParametersFrom() mirrors into
each external source's instances, so one number holds for every source.
asrSpeechRmsForTapPoint() divides by boost x trim x that gain, and falls
back to the tuned value when the product is non-finite — which now
includes an EQ master gain of zero, -inf dB, that silences the post-DSP
feed outright. What no scalar can describe is named in both the header
and the PR body: EQ band shaping, the gate, compressor gain reduction,
tube drive and pudu.

A tap-point switch now calls AsrEngine::restartSession() instead of
reset(). reset() alone is a queued signal, so it arrives behind the very
backlog it is meant to clear, and the old chain's audio kept being
transcribed into the new session — with a long decode buffer the
operator read old-chain text well after switching. restartSession()
raises the worker's cancel flag directly, then resets, then clears the
flag with a third queued call, so the no-op window ends exactly at the
backlog boundary. The ASR-disable and shutdown paths keep their own
semantics untouched.

Tests: asr_audio_tap_test (new) drives a real AudioEngine through a real
AsrAudioTap into a real AsrEngine and counts chunks, pinning that each
point delivers one chunk per block and that a block queued from the old
point is dropped after a switch — the onRxAudio() guard, which nothing
covered. asr_pre_dsp_emit_test gains the chain-makeup cases,
asr_tap_policy_test the chain divisor, asr_engine_test the
reset-before-resume ordering that the backlog drop depends on. Also drops
stale "NR button" wording from the test comments and assertion strings:
that control exists on neither this branch nor main, and "NR on/off"
misstates what the toggle does.

Five mutations verified, each failing its intended check and no others.

Refs aethersdr#4861.

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

K5PTB commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks — four of these were worth more than a wording pass, so c75a5dc6 addresses them in code rather than in prose. Suite is green at 649/650 (the one failure is vkamp_connection_test, an unrelated amplifier target that is flaky with or without -j4; one skip is hl2_probe_spectrum_panadapter).

1. "exactly the make-up gain" — fixed by extending the compensation, not by softening the claim

You were right, and the honest reading is that this was the original Sensitivity concern surviving in a narrower form: an operator running the compressor with makeup gain still got a threshold wrong by that amount. Rewording would have left the defect.

What made a real fix tractable is that ClientEq has a master output gain applied after all bands (ClientEq::setMasterGain, linear, clamped to +12 dB), so EQ does have a signal-independent scalar even though its band shaping does not.

New AudioEngine::rxStaticChainMakeupDb() sums the three gains that are one number regardless of what the audio is doing — EQ master gain, ClientComp::makeupDb(), ClientTube::outputGainDb(). Two details that matter:

  • It counts a stage only when it is both enabled and present in the operator's stored chain order, walking the same packed atomic runRxChain() walks. A stage dragged out of the chain never runs, so its gain must not be counted even while the module is enabled and configured.
  • It reads the RX singletons, which is correct for every source and not just the main one: RxClientEffects::syncParametersFrom() mirrors masterGain, makeupDb and outputGainDb into each external source's own instances, so there is one answer rather than one per source.

asrSpeechRmsForTapPoint() now divides by boost × trim × chain gain. An EQ master gain of zero is −∞ dB — it silences the post-DSP feed outright — and falls into the existing non-finite fallback, so the threshold stays at the tuned value instead of becoming zero or infinite.

The boundary is now stated in the header and the PR body instead of being implied: EQ band shaping, the gate, compressor gain reduction, tube drive and pudu are signal-dependent, and no scalar describes them.

2. reset() and the worker backlog — mirrored the disable path after all

Documenting it was the cheaper option you offered, but the header's "starts transcription over" was simply untrue, so the claim and the behaviour are now the same thing.

AsrEngine::restartSession() raises the worker's cancel flag directly (so it takes effect ahead of everything already queued), then reset(), then clears the flag through a third queued call, requestResumeAfterCancel(). The worker's queue is FIFO, so the order is: every already-queued processAudio() no-ops → the reset runs → the flag clears. The no-op window therefore ends exactly at the backlog boundary, and new-chain audio is never dropped.

This deliberately does not touch setEnabled(false) or the shutdown path, which was what made "just mirror it" look risky — both keep their cancel flag latched exactly as before. asr_engine_test pins that a plain reset() still emits no resume.

3. Stale "NR button" references — removed

Gone from asr_tap_policy_test, copy_assist_settings_dialog_test:89-90 and :612, including the "(NR on)" / "NR off (PreDsp)" assertion strings. You were right on both counts: nrButton appears nowhere in src/ on this branch or on main, and the NR-on/off framing misstates a control that moves where ASR listens while NR keeps running for the operator.

4. The coverage gap — closed

asr_audio_tap_test (new) wires a real AudioEngine to a real AsrAudioTap to a real AsrEngine and counts chunks via requestProcess. The load-bearing check is the one the from != m_tapPoint guard exists for: emit a block while the tap is on PostDsp, switch to PreDsp before the queue drains, and only then let the invocation run — it must be discarded rather than opening the new session with audio from the other chain.

Writing it turned up something worth recording. The feeds are Qt::QueuedConnection, so a first attempt that never pumped the event loop measured zero audio on both arms and looked like a product bug; it was the test. The guard is invisible until a switch, because only one feed is connected at a time.

Mutation results

mutation checks that went red
from != m_tapPoint removed the stale-block check, plus the count after it (the stale block lands in the next total)
isEnabled() dropped from the comp stage "a disabled stage stops contributing" only
chain-order walk bypassed "a stage absent from the chain order" only
* chain dropped from the divisor the four chain-scaling checks
resume emitted before reset the ordering check only

One of those exposed a weak assertion of mine: "a silenced chain leaves the threshold alone" passed under the dropped-divisor mutant, because the fallback and "no chain gain at all" give the same answer. It now runs with boost on, so the two differ, and I re-ran the mutation to confirm it fails.

On the rest

Nit 5 I left alone deliberately. processMixedRxAudioData() opens with if (!m_audioDevice) return; and nothing between there and the emit yields to an event loop or crosses a thread, so the QPointer cannot be cleared in between — writeAudio's re-test guards a lambda that runs later. Happy to add the null check purely for house consistency if you'd rather not have the asymmetry.

Nit 4 is accurate — the only other m_tap = nullptr is in the destructor, immediately followed by delete m_asr, so no reachable state has one without the other. Left as defensive code for now; say the word and the arm or the comment goes.

Nit 6, for the record, since you asked for acknowledgement rather than a change: both deviations are deliberate and I'd make them again. The emit sits above the m_dspMutex guard rather than inside it because it needs no DSP state and does no work under the lock; your own check that no return intervenes is what makes it equivalent. And scaling the threshold rather than the audio keeps the raw feed bit-exact, which is both the point of the feature and the condition the WER numbers were gathered under.

Nit 7 — not rebasing, and the recommendation to do so is contrary to repo policy. docs/PR-WORKFLOW.md ("Stale-branch policy") expressly does not require PR branches to be current with main: squash-merge runs a fresh three-way merge against main regardless of branch age, post-merge CI on main catches semantic conflicts within ~10 min, and forcing a rebase after every other merge costs ~15–25 min of CI per stale PR per batch day. So the branch staying 6 commits behind (merge base 03dcff47c) is the intended state, not a gap to close.

The factual part of the nit stands and is useful: CI here did report against an older tree, and your check of the one intervening commit that touches a file I edit — #6208's sanitizer TIMEOUT scaling — is the part that actually mattered.

Your other point there I did act on: restartSession()'s actual discarding of the backlog is the one thing I did not cover with a test, because observing it means racing the worker thread and a timing-dependent test is not worth the flake. The ordering contract it depends on is pinned instead, and the PR body now says exactly that rather than implying coverage that is not there.

@NF0T NF0T self-assigned this Oct 7, 2026
NF0T
NF0T previously requested changes Oct 7, 2026

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

Independent review — #6216

This is a careful implementation of RFC #4861. I checked the engine side on my own before reading the existing review, and it holds. The pre-DSP emit sits above every NR early return. It reuses the post-DSP emit's source-tag, sourceId and transmit-gate expressions, so the receiver lock carries over and no new TX surface appears. The direction of the threshold scaling is right: the post-DSP feed is hotter, so the PreDsp threshold is tuned / gain. Every input rxStaticChainMakeupDb() reads from the main thread is a std::atomic: ClientEq::m_enabled / m_masterGain, ClientComp and ClientTube makeupDb / outputGainDb, m_rxBoost and m_rxOutputTrimDb. The restartSession() ordering is sound too. The cancel flag is raised directly, then reset() and resumeAfterCancel() are queued FIFO to one worker thread, so the no-op window closes exactly at the backlog boundary. The disable and shutdown paths still latch the flag as before.

I'm requesting changes for two small, user-visible problems in the settings dialog. Both are cheap to fix. The engine, the tap and the tests are fine as they are.

Blocker 1 — the new checkbox splits Silero VAD from its own model row, and the tooltip says "below"

form->addRow(m_rawAudio) (CopyAssistSettingsDialog.cpp:145) lands between form->addRow(m_useSilero) (:126) and Silero's vadRow (:147). Every other option in this dialog sits directly above its own row: Save transcript → File, Label speakers → Speaker model. After this PR, Use Silero VAD (ONNX) is cut off from its VAD model / Browse… row by an unrelated option. I confirmed this in the running PR build. The widget tree gives Silero at y=524, the new checkbox at y=551 and the VAD model row under it. A screenshot of the dialog shows the same order:

[ ] Use Silero VAD (ONNX)
[ ] Transcribe from unprocessed audio (bypasses NR and RX effects)
    VAD model: (energy VAD)                  [Browse...]
[ ] Label speakers (A/B/C...)

The tooltip (:141) also tells the operator to "Enable Silero VAD below", but Silero is above it.

Fix: create and add the new checkbox before m_useSilero (ahead of :118). That restores the Silero grouping and makes "below" true, with no change to the wording. This is a one-block move, but it isn't a contiguous range, so I can't offer it as a one-click suggestion.

Blocker 2 — the user doc for this dialog wasn't updated

The PR template's checklist item reads "Documentation updated if user-visible behavior changed — docs/ …". It's ticked, citing radio-capabilities-map.md. That file is the architecture doc. The operator-facing page is docs/asr-copy-assist.md, whose Settings (⚙) section has an entry for every checkbox in this dialog. It now has none for this one. On the same page, the architecture diagram still reads AudioEngine (aethercore, 24 kHz post-NR RX). A short bullet in the Settings section would cover it. Your own commit message already has the right content: off by default; what you hear is unchanged; radio-side processing still applies; Sensitivity is rescaled for boost, trim and the static chain gains; pair it with Silero on a noisy band. Also add a one-word "(or pre-NR, opt-in)" on the diagram line.

Scope

File(s) Claimed Verdict
AudioEngine.{h,cpp}: pre-DSP signal + emit, rxStaticChainMakeupDb() yes In scope. The new public signal is RFC design-sketch item 1
AsrEngine.{h,cpp}: restartSession() / resumeAfterCancel() yes (c75a5dc6) In scope. It answers the bot's nit 2
AsrTapPolicy.h, AsrAudioTap.{h,cpp} yes In scope
CopyAssistController.{h,cpp}, CopyAssistSettingsDialog.{h,cpp} yes In scope (see Blocker 1)
MainWindow.cpp comment only In scope
docs/architecture/radio-capabilities-map.md yes In scope. The user doc is missing (Blocker 2)
tests/* + tests.cmake yes In scope. Socket-free: no QTcpServer/QUdpSocket/bind/listen/fake peer in any added line

No CHANGELOG.md entry, which is correct. No guard, early return or fixed-issue comment is removed. The - lines are the inline connect() moving into connectSelectedTap() and rewritten comments. Preference check: this is a new opt-in control, default off, using the name ten9876 set in the RFC. It changes no existing default, so it's not a preference change.

What I tried to break

  • Built the PR merged onto current main (4249b71d5), not the stale 03dcff47c base CI ran against: MinGW, Qt 6.12.0. The merge was clean and the build was clean. The five touched tests pass: asr_pre_dsp_emit_test, asr_tap_policy_test, asr_audio_tap_test, asr_engine_test, copy_assist_settings_dialog_test.
  • Mutated it myself rather than relying on the reported mutation table. First, I silenced the pre-DSP emit whenever RN2 is enabled but not ready, which is what moving it below the NR cascade would do. The result was [FAIL] pre-DSP still emits ahead of the NR early return, and only that. Second, I deleted from != m_tapPoint. The result was [FAIL] a block queued from the old tap point is dropped after a switch plus the count check after it. Both match your table. I reverted both and the suite went green again.
  • Drove the dialog over the automation bridge against the demo radio (DEMO-0001, family sim) with an isolated settings store and TX disabled. On a fresh profile the box is unchecked and the label is verbatim. Clicking it writes AsrTapPoint to the store right away. After a force-kill and relaunch on the same profile, the box came back "checked": true, so the value is committed at toggle time and doesn't depend on a clean shutdown. This run is also where I found Blocker 1.
  • Raced disable against a tap-point switch on paper. With a switch and then a disable within a few ms, the queued resumeAfterCancel() clears the flag the disable just raised, so one new-chain block can be processed after the disable. That's benign: no audio arrives while disabled, and the next setEnabled(true) clears the flag anyway. Not a finding, just recorded.
  • Could not test: transcription quality on either tap. The isolated profile has no model and the demo audio is synthetic. I had no way to reproduce the WER tables, which come from one recording, so I take them as your evidence rather than my own.

Forward guidance (non-blocking)

  1. Output pan is a static gain the compensation doesn't count. For an external Kiwi antenna panned off centre, applyRxPanInPlace() (linear law) runs before the post-DSP emit. toMono() averages L+R, so hard-panned that feed is about −6 dB against pre-DSP. The PreDsp threshold then sits about 6 dB lower than it should, which errs permissive, never toward dropping speech. It only affects off-centre external Kiwi sources. A comment line in asrSpeechRmsForTapPoint()'s boundary list would cover it.
  2. The noisy-band caveat lives only in the tooltip, and screen readers don't announce tooltips. Your sibling checkboxes do the same, so this matches local convention. An accessibleDescription would carry it if you're already in there.
  3. Leaving nits 4 and 5 from the earlier review as they are is fine by me. I agree with your reasoning on 5.

Once the dialog row moves and the doc bullet lands, I expect to approve this. Everything else is ready. Thank you for going and getting measured evidence for the RFC instead of shipping the impression; the substitution/deletion split is what makes the default-off decision easy to defend later.

73, Ryan NF0T


👨🏼‍💻 Co-authored by Claude Opus 5.5

Comment thread src/gui/CopyAssistSettingsDialog.cpp
Comment thread src/gui/CopyAssistSettingsDialog.cpp Outdated
@NF0T NF0T removed their assignment Oct 7, 2026
The new checkbox was added between "Use Silero VAD (ONNX)" and Silero's
own "VAD model / Browse..." row, so it cut a control off from its detail
row. Every other option in this dialog is followed directly by its own
row (Save transcript -> File, Label speakers -> Speaker model). Its
tooltip also told the operator to "Enable Silero VAD below" while Silero
sat above it. Moving the checkbox ahead of Silero restores the pairing
and makes the tooltip true without changing a word of it.

copy_assist_settings_dialog_test now walks the QFormLayout and pins both
halves: the checkbox is above Silero, and Silero's VAD-model row is
immediately beneath it. Restoring the old order fails exactly those two
checks. Nothing in the suite covered row placement before, which is why
this was only visible in a running build.

docs/asr-copy-assist.md gains the operator-facing entry the Settings
section has for every other checkbox: default off, what you hear is
unchanged, radio-side processing still applies, the measured WER
difference, that Sensitivity is rescaled for boost, trim and the static
chain gains, that a noisy band wants Silero alongside it, and that
switching restarts transcription. The architecture diagram on that page
now says the ASR feed is post-NR "or pre-NR when opted in". The earlier
commit cited radio-capabilities-map.md, which is the architecture doc,
not the operator page.

The output pan is named in asrSpeechRmsForTapPoint()'s boundary list
rather than counted. It is a scalar, and a hard-panned source does read
about 6 dB quieter after toMono() averages L+R, so a PreDsp threshold
sits that much low. It is left out because it applies only to external
Kiwi sources, its per-source value is behind an m_dspMutex-guarded
lookup, and that function runs per audio block -- taking the DSP lock at
that rate would put GUI-thread contention on the audio path to chase an
error whose direction is permissive, so no speech is lost to it.

Refs aethersdr#4861.

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

K5PTB commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Ryan — both blockers are fixed in 857a5c07, and the dialog is confirmed by eye in a running build. Full suite green: 649/650, one skip (hl2_probe_spectrum_panadapter), one failure (vkamp_connection_test, the unrelated amplifier flake).

Blocker 1 — row order

m_rawAudio is now created and added before m_useSilero, so Silero is reattached to its VAD model / Browse… row and the dialog goes back to the label→detail-row pattern the other options follow. The tooltip's "Enable Silero VAD below" is now true with no change to the wording — that string was written when the control lived on the panel header, and I never re-checked it after moving it into the dialog. There's a comment at the insertion point naming the pattern so it doesn't drift back.

The more useful outcome is the test. Nothing in 650 tests covered row placement, which is why this survived both a code review and mine: copy_assist_settings_dialog_test asserted the checkbox existed, not where it sat. It now walks the QFormLayout and pins both halves of your finding — the unprocessed-audio row precedes Silero, and Silero's VAD-model row is immediately beneath it. Restoring the old order fails exactly those two checks and nothing else.

That one needed a running build to find, and I'd have kept missing it from the source.

Blocker 2 — the operator doc

You're right, and the checklist tick was simply wrong: radio-capabilities-map.md is the architecture doc, and docs/asr-copy-assist.md is the page with an entry per checkbox. Added there, positioned to match the new dialog order (after Save transcript, before Silero): default off, what you hear is unchanged, radio-side processing still applies, the measured WER difference, a sub-bullet on Sensitivity staying calibrated across the toggle, pairing with Silero on a noisy band, and that switching restarts transcription. The diagram line now reads AudioEngine (aethercore, 24 kHz post-NR RX, or pre-NR when opted in).

Output pan — verified, documented rather than counted

Your reading is exactly right. applyRxPanInPlace() (AudioEngine.cpp:5632) runs before the post-DSP emit at :5652, applyKiwiOutputPan is external-source-only, and since toMono() averages L+R the mono-domain gain is (lGain + rGain) / 2 — 0.5, i.e. −6.02 dB, at either extreme.

I set out to count it and backed off after reading the plumbing. asrSpeechRmsForTapPoint() is re-evaluated per audio block, and the per-source pan is a plain int reachable only through externalKiwiSource(), which takes m_dspMutex — the lock the audio thread holds during DSP. Acquiring that at block rate from the GUI thread would put contention on the audio path to chase an error that is permissive in direction and confined to off-centre external Kiwi sources. That seemed like the wrong trade, so the boundary list in AsrTapPolicy.h and the doc comment on rxStaticChainMakeupDb() now state the magnitude, the direction, and the reason it is excluded rather than just the fact.

accessibleDescription — deferred, deliberately

Not done this round. Every sibling checkbox in the dialog carries its caveat in a tooltip only, so adding it to this one alone makes the dialog inconsistent in a way the next reader can't explain; the right shape is a pass over the whole dialog. Happy to do it here instead if you'd rather not wait for that.

Disable racing a tap-point switch

Thanks for recording it rather than filing it. Agreed it's benign, and for the reason you give: no audio arrives while the tap is disabled, and the next setEnabled(true) clears the flag regardless.

On the review itself

The independent mutations are what make this worth more than a read-through — reproducing the table rather than taking it is the right instinct, and I'd rather have that than agreement. The force-kill persistence check is a good one I hadn't thought to run.

Your merged-onto-main build also answers the stale-base point from the earlier automated review. Per docs/PR-WORKFLOW.md's stale-branch policy we don't rebase an open PR — squash-merge does its own three-way merge and a rebase churns CI — so a reviewer building the merge is exactly the check that policy leans on. Useful to have it done against 4249b71d5.

73, Paul K5PTB


👨‍💻 Co-authored by Claude Opus 5

The tap-point checkbox's tooltip told the operator that on a noisy band
they should enable Silero VAD, "because it decides speech by content
rather than by loudness". The premise is real -- the raw pre-DSP feed has
0.2 dB of RMS separation between speech and the noise floor, so a
level-based gate has nothing to discriminate on -- but the conclusion was
reasoned, not measured, and measurement reverses it.

On the off-air 40 m SSB ragchew the WER tables use, driving the real
AsrSegmenter with the real Silero model on the raw feed:

  energy gate, wide open        445 s admitted    28.3% WER
  energy gate, Sensitivity 63   445 s admitted    30.0%
  Silero @ 0.3 (permissive)      88 s admitted    72.7%
  Silero @ 0.5 (its default)     44 s admitted    87.6%
  Silero @ 0.7                    7 s admitted    97.5%

At its default threshold Silero admits 44 s of 402, against roughly 265 s
the reference marks as speech: it rejects most of the real speech, and an
operator following the old tooltip would have gone from 28.3% to 87.6%.

Falsified before changing anything, because the result is surprising. The
same model, code path and threshold on clean synthesized speech admits
27.5 s of 34.0 s in nine segments that land on sentence boundaries, each
transcribed correctly -- so the detector works and the harness is sound.
Silero is simply out of distribution for an HF signal with an S7 noise
floor. It also admits 2.7x more audio from the NR-processed feed than the
raw one (117 s vs 44 s), wanting the front-end whisper does not: the same
opposition this feature exists for, one layer down.

The tooltip now says to leave Sensitivity high and that Silero is not a
substitute on a noisy HF signal. copy_assist_settings_dialog_test pins
both halves, and the second guard -- that the tooltip does NOT say
"Enable Silero" -- exists because the old advice reads as plausible and
would be easy to reinstate by anyone reasoning from how Silero works, as
I did. Mutation-checked: restoring the old wording fails that check and
only that check.

The operator doc carries the same correction with the numbers.

Scope: one recording, one band, one noise floor. This says Silero is the
wrong tool for THIS signal, not that it is useless; on a quiet band it may
well be the better detector.

Refs aethersdr#4861.

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

K5PTB commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

One correction to this PR's own guidance, found by measuring it rather than by review.

The checkbox tooltip told the operator that on a noisy band they should enable Silero VAD, "because it decides speech by content rather than by loudness". That reasoning is sound in the abstract — the raw pre-DSP feed has only 0.2 dB of RMS separation between speech and the noise floor, so a level-based gate genuinely has nothing to discriminate on. I wrote the advice from that premise and never tested it.

Tested, it is backwards. On the same off-air 40 m SSB ragchew used for the WER tables, running the production AsrSegmenter with the real Silero model:

gate, on the raw feed audio admitted WER
energy gate, wide open 445 s 28.3%
energy gate, Sensitivity 63 445 s 30.0%
Silero @ 0.3 (permissive) 88 s 72.7%
Silero @ 0.5 (its default) 44 s 87.6%
Silero @ 0.7 7 s 97.5%

At its default threshold Silero admits 44 seconds out of 402, against roughly 265 seconds the reference marks as speech. It is rejecting most of the real speech, and following the tooltip's advice would have taken an operator from 28.3% to 87.6%.

I tried to falsify this before changing anything, because the result is surprising. Same model, same code path, same threshold, on clean synthesized speech: 27.5 s admitted of 34.0 s, nine segments landing on sentence boundaries, each transcribed correctly. So the harness wiring is sound and Silero works as designed — it is simply out of distribution for an HF SSB signal with an S7 noise floor. Corroborating that: Silero admits 2.7x more audio from the NNR-processed feed than from the raw one (117 s vs 44 s) at the same threshold. Noise reduction makes the audio look more like Silero's training data and less like what whisper wants. The two models want opposite front-ends, which is the same mechanism this PR is about, showing up one layer down.

So the tooltip now says to leave Sensitivity high and states plainly that Silero is not a substitute on a noisy HF signal. copy_assist_settings_dialog_test pins both halves: the tooltip explains the Sensitivity gate, and it does not recommend enabling Silero for this case. That second check exists because the old advice reads as plausible and would be easy to reinstate by someone reasoning from first principles, exactly as I did.

The operator doc carries the same correction, with the numbers.

Scope of the claim: one recording, one band, one noise floor. It says Silero is the wrong tool for this signal; it does not say Silero is useless, and on a quiet band it may well be the better detector. The tooltip is worded as advice for the noisy case it previously got wrong, not as a general verdict.

Nothing else in the PR changes. The engine, the tap, the calibration and the tests from the previous rounds are untouched.

@ten9876 ten9876 self-assigned this Oct 9, 2026
@ten9876
ten9876 dismissed NF0T’s stale review October 9, 2026 20:30

Both blockers were fixed by K5PTB in 857a5c0 and still hold at 72138f9. (1) Row order: the unprocessed-audio checkbox is now added above 'Use Silero VAD', and Silero's VAD model row sits directly beneath it again. copy_assist_settings_dialog_test walks the QFormLayout and pins both. Restoring the pre-fix dialog (c75a5dc) fails 'unprocessed-audio sits above Use Silero VAD' and 'Silero keeps its VAD model row directly beneath it', and it passes as written (verified on Linux/Qt 6.12). (2) User doc: docs/asr-copy-assist.md now has the operator-facing entry for the setting. Your two inline threads were already resolved.

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

NF0T's blockers, fixed by K5PTB in 857a5c07:

  • Row order: pinned by copy_assist_settings_dialog_test. Restoring the pre-fix dialog fails both layout checks.
  • Operator doc: docs/asr-copy-assist.md is updated.

72138f91 is K5PTB's measured correction to the tooltip: Silero VAD is no longer recommended for noisy HF. His off-air 40 m test gave 87.6% WER with Silero against 28.3% with the energy gate open. It's pinned by the test's tooltip check, which the old text fails.

Verified on Linux/Qt 6.12:

  • copy_assist_settings_dialog_test, asr_tap_policy_test, asr_audio_tap_test, asr_engine_test and asr_pre_dsp_emit_test pass (5/5).
  • AetherSDR builds.
  • Engine-boundary and command-plane findings identical to the merge base; capability records at 70; manifest clean.
  • Required CI green.

Not verified here: the WER and speech-gating figures. They're K5PTB's off-air measurements, not reproduced.

@ten9876
ten9876 merged commit d6f6a4f into aethersdr:main Oct 9, 2026
5 checks passed
ten9876 added a commit that referenced this pull request Oct 11, 2026
…remote operation over CGNAT (#6389)

## Summary

Release prep for **v26.10.2**: the six release files plus the Stable
docs snapshot. Metadata and docs only; no source, test or CI changes.

Cutoff: `origin/main` at `46943eb3c` (#6083, merged 2026-10-11).
Anything that merges after it is folded in before the tag.

**106 merged changes** from **15 human contributors**, AetherClaude (2)
and Dependabot (0). First-time contributor: **@dacacioa**. This was
confirmed by checking each login for a merged PR before the previous
tag, not from `author_association`.

## Changelog

The range `v26.10.1..46943eb` was mapped through the commit-to-PR API
(`repos/…/commits/<sha>/pulls`), not from commit subjects. It holds 106
PRs and no direct commits, and every one is cited in the section.
`v26.10.1` is an ancestor of `main`, so nothing is excluded. #6083
merged before the branch was cut, so it is inside the range and needs no
unmerged-PR banner.

The notes lead with:
- the unified 52 px title bar (#6198);
- docs.aethersdr.com (#6286, #6298, #6294);
- remote operation over CGNAT through the in-radio tailnet shim (#6273,
#6318, #6325, #6319);
- the canon windows (#6227, #6334, #6339, #6340, #6341, #6364, #6372,
#6386).

Then come DVK import, FFT Smooth/De-comb, TCI tune and receiver events,
RTL-SDR multi-receiver delivery, HL2 and ANAN-G2 state memory, the
IC-7300MK2 fixes, the first clean sanitizer runs (stated beside #6350's
TSan red) and Authenticode signing.

Numbers come from the PR bodies. "On upgrading" callouts cover the
behavior changes the bodies flagged:
- HL2 receive audio is 12.04 dB quieter (#6254);
- HL2 host CW is +6.02 dB at a fixed drive (#5975);
- ANAN AGC-T is remapped (#6282);
- sleep prevention is on by default (#6195);
- the Tailscale MTU cap has no opt-out (#6319);
- IC-7300MK2 Auto SQL is withdrawn (#6184).

DeepFist (#5953, #6103) is described as applying only to
`ENABLE_DEEPFIST_EXPERIMENT` builds.

## Docs refresh, not just version strings

- **README**:
- Highlights: FFT Smooth/De-comb on the spectrum line; AetherMap naming;
remote over CGNAT and TCI tune/receiver events on the SmartLink/TCI
line; pre-NR Copy Assist; a new docs.aethersdr.com line.
- Supported Hardware: the KPA1500, marked as not yet validated on
hardware.
- Other radio families: HL2 state restore extended; ANAN-G2 gains AGC-T,
NB2, supply voltage/link and per-radio memory; RTL-SDR now "bundled in
every official package", with WFM stereo, PPM, per-receiver SQL/meters
and DAX/TCI feeds, and multi-receiver explicitly an evaluation.
- Roadmap section: the CTR2 line advanced and a visual-canon line added.
  - Maturity labels are unchanged.
- **ROADMAP**:
- In flight: a v26.10.2 sentence each for aetherd, Icom, ANAN-G2,
RTL-SDR (remaining is now production multi-receiver qualification and
digital FM, RFC #6079), Hermes-Lite 2 and the CTR2 relay (RFC #6332
extensions). A new entry for the visual canon (RFC #6226 / #6239).
  - Queued: RFC #4214 (approved 2026-10-09) added.
- Open RFCs, checked against `label:rfc`: #4214 leaves (approved); #4764
and #4861 leave (shipped by #6198 and #6216); #6346, #6232, #6212 and
#6209 join. #6332 is approved and folds into the CTR2 entry.
- Recently shipped: nine new `(v26.10.2)` entries, still within the
v26.10.x cycle, so no month-rollover trim.
- Backends (six: anan, flex, hl2, icom, rtl, sim) and NR engines (seven)
were checked against the tree; both are unchanged.

## Judgment calls flagged for the maintainer

All of these were decided through AskUserQuestion:
- Version **26.10.2**, date **2026-10-11**, opened ready for review.
- **#6350**, the TSan red in the weekly sanitizer lane: proceed, and
name it below.
- RFCs **#4764** and **#4861** come off Open RFCs as shipped. The issues
themselves are left open for you to close.
- RFC **#4214** goes under Queued (next cycle).
- A **visual canon** In-flight entry, plus a README Roadmap line.
- The **KPA1500** is listed in README Supported Hardware with "not yet
validated on hardware".
- Including **#6083**: you asked for it, and it merged before the
cutoff, so it ships as an ordinary part of the range.

## Known failing on `main`

- **`ctr2_hid_thread_port_test`** fails in the weekly TSan lane (runs of
2026-10-08 and 2026-10-10; #6350). `main`'s CI and Full Suite were green
at `414082197`. The CHANGELOG headline and intro say so: the lanes
achieved their first clean runs this cycle, and the test remains failing
under TSan. This prep does not fix the test, and the release ships with
it red in that lane.

## Scope

| file | change |
|---|---|
| `CMakeLists.txt` | `project(AetherSDR VERSION 26.10.2)` |
| `README.md` | Current version line, plus the prose refresh above |
| `AGENTS.md` | Current version line |
| `CHANGELOG.md` | new `[v26.10.2] — 2026-10-11` section; `[Unreleased]`
stays and stays empty; history below byte-identical |
| `packaging/linux/io.github.aethersdr.aethersdr.metainfo.xml` |
`<release version="26.10.2" date="2026-10-11"/>` first in `<releases>` |
| `ROADMAP.md` | cycle heading → post-v26.10.2, plus the prose refresh
above |
| `docs/user/versioned_docs/version-stable/**`,
`docs/user/versioned_sidebars/version-stable-sidebars.json`,
`docs/user/stable-version.json`,
`docs/user/src/components/LogAnalyzer/rules.json` | docs snapshot
(`tools/docs/snapshot_stable.py 26.10.2`), in its own commit. This is
the first snapshot since #6286. |

## Validation

Output of `check_release_prep.py --mapping mapping.json` at the branch
head: 30 checks, 1 FAIL (explained below), 1 WARN (the snapshot files,
listed in the scope table).

```
PASS v26.10.1 is an ancestor of origin/main
PASS all six version spots say 26.10.2
PASS CHANGELOG heading date equals metainfo release date
PASS appstreamcli validate
PASS metainfo <release> list is date-descending with unique versions
PASS `## [Unreleased]` stays and stays empty
PASS `## [v26.10.2]` sits directly under `[Unreleased]`
PASS the previous section is `[v26.10.1]`
PASS CHANGELOG diff is exactly the inserted section
PASS new section opens with a `### <headline>`
PASS intro sentence has the house shape
PASS new section has a `### Contributors` block
PASS release_contents.py --check (citations, intro and Contributors counts)
     PRs in range: 106  cited: 106; intro 106/15 matches; Contributors (16 entries) matches; first-time ['dacacioa']
     citations outside the range: #4764, #4861, #5468, #6192, #6226, #6271, #6332 (RFCs), #6238, #6350 (issues)
PASS code fences balance / no duplicated table headers or doubled `---` (README, ROADMAP, new section)
PASS every local link in README.md and ROADMAP.md resolves
PASS no heading removed from README/ROADMAP is still linked elsewhere
PASS Recently shipped `(vX)` markers run newest-first and lead with the new version
PASS Recently shipped intro names the current cycle (v26.10.x)
FAIL only the four current-version lines lose the `26.10.1` string outside CHANGELOG
     historical mentions must stay: "label": "26.10.1"
PASS diff touches no src/, tests/, third_party/ or .github/workflows/ path
PASS all six release files are in the diff
WARN files beyond the six (the docs snapshot)
PASS git diff --check
```

- **The one FAIL is the snapshot's version label, not a historical
mention.** `docs/user/stable-version.json`'s `"label"` is the docs
site's current Stable version, and `snapshot_stable.py` rewrites it by
design. The validator predates #6286 and knows only the four
current-version lines. Run on the six-file commit alone, the same check
passes (29 checks, 0 FAIL, 0 WARN).
- The docs build passes: `npm ci && npm run build` in `docs/user`. `node
tools/docs/test_log_rules.mjs` reports 707 checks passed for 45 rules.
The line moves it reports were already on `main`.
- Four merged PRs (#6340, #6341, #6364, #6372) named screenshots to
recapture after merge (`psk-reporter-sidebar.png`, `menu-tools.png`,
`connect-dialog.png`, `rx-agc-calibration.png`), and #6386 named the
panadapter shots (`panadapter.png` and the main-window captures, which
still show the old title strip). They were not recaptured, so the Stable
snapshot carries the older images.
- No configure, build or test was run for this prep; it is metadata
only, and CI builds it.
- `main` CI at the cutoff `46943eb3c`: Docs and CodeQL - Code Quality
are green; CI, Full Suite, CodeQL and the System Libraries Canary were
still running when this was opened. At `414082197`, every one of them
was green.

After merge: tag the resulting **main** commit, not this branch.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ten9876 added a commit that referenced this pull request Oct 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