Skip to content

feat(dvk): client-side voice keyer with local recordings (RFC #4214) - #6292

Merged
jensenpat merged 7 commits into
aethersdr:mainfrom
K5PTB:feat/client-voice-keyer-prototype
Oct 17, 2026
Merged

jensenpat merged 7 commits into
aethersdr:mainfrom
K5PTB:feat/client-voice-keyer-prototype

Conversation

@K5PTB

@K5PTB K5PTB commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Implements phase 1 of RFC #4214: twelve voice-message slots stored under the OS user's Documents/AetherSDR/VoiceKeyer folder, PC-microphone recording, and Local/Radio selection through the existing DVK panel. Local recordings need no SmartSDR+ licence.

The existing panel, F1–F12 shortcuts, REC/STOP/PLAY/PREV, rename, import and export dispatch through one VoiceKeyer interface. Radio DVK retains its radio-owned recordings and 10-second limit. Local slots allow 30 seconds; the source choice and labels live in one client-owned settings object. Automatic follows the radio's DVK capability/licence. Source changes wait for the current operation, so Stop stays with its original keyer.

Ownership and safety

  • Generated playback captures a TxCoordinator producer request at the input boundary, carries its media context through queued audio, and releases through one completion path on Stop, timeout, refusal or disconnect. It restores the TX audio state it found.
  • Local REC uses the PC-microphone tap. When VOX must be disabled, recording waits for confirmation of its own ticketed request through the backend seam. Stale replies, an unanswered operator VOX-off, and a pending restore cannot admit recording. Operator VOX changes win; VOX-on ends recording unsaved. STOP cancels a recording still waiting on its hold.
  • WAV decoding is bounded by file size, rate, duration, channels and chunk count; chunk strides use 64-bit arithmetic. Slot replacement is atomic. Dropped-in overlength WAVs are refused at playback and preview.
  • Renames and file dialogs retain their original keyer. The landing pass additionally reproduced and fixed a stale context-menu action: rebinding while Clear's menu was open could clear the incoming keyer. Menu actions now retain the owner captured before the nested event loop and cancel visibly if it changed.
  • Overlapping local preview producers retain independent RX mute ownership. Keyer availability refreshes when a reconnect reclaims an existing TX slice.

Approved scope

The operator explicitly approved all three disclosed decisions during landing: Beta availability on HL2/Icom, the 30-second Local slot cap, and the ticketed backend acknowledgement API (TransmitControlRequest::answerTicket, IRadioBackend::transmitControlAnswered(), answersTransmitControl()). Backends without confirmation support fail closed on the VOX hold. Beta availability is not a claim of physical HL2/Icom validation.

The phase excludes repeat/CQ automation, permanent CQ/Call chrome, and external-control extensions. No new daemon TX permission or wire protocol is added by the internal acknowledgement API.

Validation

  • ARM64 Qt 6.12 macOS app and 16 focused targets built at d047257f5 with RADE enabled. All 16 selected headless CTests passed. ASR was disabled; DFNR/keychain were unavailable in this local build. No RNNoise x86 sources appear in its ARM build graph.
  • The added real-widget menu regression failed before the fix with other=[clear 1], then passed with both keyers untouched. The focused selection also covers VOX ordering, pending STOP, rename rebinding, WAV bounds, generated-audio lifecycle, TX route ownership, disconnect integration and RX mute ownership.
  • At that head, an isolated offscreen bridge process connected only to DEMO-0001, with txAllowed:false, one slice and one pan, and transmitting:false; it disconnected and exited cleanly. Local title/REC description and the source menu were verified. The demo is RX-only.
  • Main integration at 414edcab8 preserves the separately landed SPE 1K-FA support and regenerates the only conflicting file, the touchpoint manifest. Final-head build/test/bridge evidence and hosted-check outcomes are recorded in the approving review.
  • Strict engine-boundary, capability and command-plane checks pass: findings 102 → 101, capability booleans 70, raw command count 296. Generated touchpoint/seam/reference pages, strict test registration, accessibility and 707 LogAnalyzer checks pass.
  • The author reports FLEX-6500 on-air and flex-sim v0.3.3 evidence on earlier feature heads. Those results were not reproduced during landing. Full-suite execution, sanitizers, physical VOX/TX and HL2/Icom convergence were not run in the landing pass. The dvk-panel documentation screenshot was not refreshed.

Attribution

K5PTB implemented the feature. The WAV boundary retains dk4dj's contribution from #957; the shared Local/Radio design comes from milenjb's RFC #4214. Original co-author trailers are preserved in the squash commit. The landing pass adds the demonstrated context-menu ownership fix.

Generated with OpenAI Codex (GPT-6 Astra)

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this — the on-air contest-weekend evidence and the wire captures are a great test plan, and the "one panel, two keyers" parity decision reads well. The CI failure is a small bookkeeping step, not a problem with the code.

What failed

Static checks → aetherd touchpoint manifest (run 37837059597). That step is .github/workflows/static-checks.yml:197 → python tools/gen_touchpoint_manifest.py --check. The error:

::error::aetherd touchpoints require a valid semantic tag: core/GeneratedAudioTransmitter.h,
core/LocalWavPlayer.h, core/VoiceKeyerSettings.h, core/VoiceKeyerSource.h,
models/LocalVoiceKeyer.h, models/VoiceKeyer.h

I reproduced it exactly on 502b0d3b3, so it's deterministic, not a flake. The other three lanes (build, check-macos, check-windows) were still in progress when I looked — Static checks is the only completed failure so far.

Why

Every src/core/ or src/models/ header that a src/gui/ file #includes is a touchpoint in the aetherd burndown (RFC docs/aetherd-headless-engine-design.md §10), and each one must carry a semantic tag in docs/architecture/aetherd-touchpoint-tags.json. This PR adds six new engine headers that GUI files include, so the generator finds six untagged rows. docs/architecture/ is untouched in the diff — easy to miss, since nothing in the build or the test suite notices.

Fix — two steps, and the first alone isn't enough

  1. Add the six entries to docs/architecture/aetherd-touchpoint-tags.json, same shape as the neighbours:
 "models/VoiceKeyer.h": {
  "tag": "universal",
  "note": "…",
  "split": "",
  "confidence": "high"
 },

Two notes on that file: it's 1-space indented, so hand-add rather than round-tripping it through json.dump (default indent rewrites all 254 entries and buries the real change). Key order doesn't matter; the generator reads it as a dict.

  1. Regenerate the manifest: python tools/gen_touchpoint_manifest.py (no --check), then commit both aetherd-touchpoint-tags.json and aetherd-touchpoints.md. If you stop after step 1 the check still fails, with a different message — aetherd-touchpoints.md is stale. I hit that doing the repro.

With both steps done, --check prints manifest up to date. aetherd-touchpoint-status.json needs nothing.

On the tags themselves

The vocabulary the regex accepts is universal, ui-support, vendor(<family>), mixed(<family>), peripheral(<family>) — and which one each header gets is a maintainer classification call, so I'll point at precedents rather than put words in anyone's mouth:

  • core/DigitalVoiceWaveformSettings.h → ui-support ("AppSettings-backed … client-side persistence plumbing, not radio state") — probably the closest neighbour for VoiceKeyerSettings.h.
  • models/CwxModel.h → universal ("CW keyer intent … generic despite Flex 'CWX' name") — a near-exact analogue for VoiceKeyer.h.
  • core/DaxTxPolicy.h → vendor(flex), by contrast, because its whole surface is DAX/VITA-49.

GeneratedAudioTransmitter.h is the one I'd expect discussion on: the Route interface itself is abstract and the header carries no Flex types, but its comments describe the dax_tx create reply and it's documented as proven on Flex, HL2 and Icom. universal looks defensible on that shape; worth a sentence in the note either way.

One thing worth calling out in the regen

models/DvkModel.h drops off the manifest entirely — no GUI file includes it any more, now that DvkPanel reaches it through VoiceKeyer.h. That's a genuine burndown win and the split the audit already named for that header, so it's worth a line in the PR body. Alongside it: core/DvkWavTransfer.h includers 2→1 and core/ThemeManager.h 169→170, and the totals line moves up by five net. Don't hand-edit any of that — the generator writes it; the exact numbers will be whatever it emits once you're current with main.

No Copilot or reviewer comments on the PR yet, so nothing there to cross-check against this failure.

Nothing else to change — the C++ is fine as far as these lanes got. 73!


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

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

Review withdrawn by submitter, blockers addressed in new squashed commit.

@nigelfenton

Copy link
Copy Markdown
Contributor

Claude here, posting this for Nigel.

Thanks @K5PTB — this is a really nice piece of work, and putting the whole thing behind the existing DVK panel makes it feel native straight away. I built 1275479 on Windows 11 (Qt 6.12.0, MSVC 14.50) and drove it against flex-sim through the automation bridge, with real F1/Esc key presses for playback. No RF anywhere; every keying step was guarded to the simulator.

What held up

  • Unlicensed radio → Local by itself. With license feature name=digital_voice_keyer enabled=0, the panel came up as "Digital Voice Keyer (Local)" with Radio DVK greyed out. With the licence on, Automatic picked Radio.
  • Playback: F1 keyed in ~0.25 s, created the dax_tx stream, sent ~560 audio packets for a 3 s slot, and unkeyed on its own at the end.
  • State as found (TCI DVK leaves DAX enabled after TX #5524): after playback, Esc and STOP, transmit dax went back to 0 and the mic source was left as it was found.
  • Esc / STOP mid-playback unkeyed in 0.02 s / 0.13 s.
  • Deferred source change: switching to Radio during a Local playback neither cut nor retargeted it. The status line read "On air · slot 1 · 2.6s of 3.0s · Radio keyer when this ends", and the switch landed when the playback finished. Esc inside that window still stopped the Local playback.
  • Record gate: with the mic source on MIC, REC refused with the clear "set the mic source to PC" message and left VOX alone.
  • VOX: with VOX on, REC sent vox_enable=0 within ~0.15 s, showed "VOX held off" the whole time, keyed nothing, and gave VOX back after STOP. It did that even though the save itself failed (see below).
  • Empty slot (F12) never keys, and nothing resumes after a reconnect.

Two things I think need a look

1. The DVK indicator, and the F1–F12 set with it, comes up disabled after connecting to an unlicensed radio, even though Local is selected.
Every connect leaves dvkIndicator disabled, with an arrow cursor and no right-click menu. Any later TX-slice mode change (LSB → USB) brings it back. For an operator without SmartSDR+, which is exactly who this is for, the DVK button is dead until something else happens to refresh it.
My guess at the cause: licenseFeaturesChanged runs updateKeyerAvailability() while the source still resolves to Radio, so the unlicensed blocker applies. The Auto → Local rebinding then arrives through boundKeyerChanged, whose handler only updates the panel and doesn't re-run updateKeyerAvailability(). Calling it from that handler may be all it needs.

2. Disconnecting mid-playback leaves the radio keyed, with DAX already dropped.
On a disconnect during playback, the radio sees:

xmit 1                       (playing)
transmit set dax=0           (releaseAudioPath)
stream remove 0x48000010
— connection closed —        (no xmit 0)

finish() calls keyOff() before releaseAudioPath(), so I'd expect xmit 0 first. On this path the unkey seems to get lost while the DAX restore still goes out. On a real radio that means, for the moment before the radio notices the client has gone, it is keyed with its audio source switched back to the radio's own mic. I couldn't check what real firmware does when a keyed GUI client drops, so treat the consequence as unverified. The ordering itself is reproducible.

Smaller items

  • REC's accessible description still says "up to 10 seconds", which is the radio DVK's cap. Local allows 60.
  • A WAV dropped straight into Documents/AetherSDR/VoiceKeyer isn't shown until the keyer source is chosen again. There's no folder watch or rescan.
  • Testability: the automation bridge can't press DVK play (the buttons are markTxKeying but have no scoped TX action), and the F-keys are panel QShortcuts, not ShortcutManager actions, so shortcut can't reach them either. VOX is the same, apart from shortcut vox_toggle. I had to send real key presses to the window. A scoped action for slot play would make this scriptable.
  • On Windows the recordings folder resolves to OneDrive/Documents/AetherSDR/VoiceKeyer when Documents is OneDrive-redirected, so recordings sync to the cloud. That's probably fine, but worth a line in the docs.

Not verified

  • Recording itself. Every attempt ended "Stop recording failed: No microphone audio arrived". Windows showed AetherSDR holding the mic open, but AudioEngine::onTxAudioReady() never ran (no audio log during REC). That looks like my laptop or setup rather than this PR, so I filled the slot with a dropped WAV to test playback. Recording still needs a real-radio check.
  • Everything here is against a simulator. Your on-air evidence covers the real radio side.

For anyone else reviewing: flex-sim v0.3.3 now models what this PR needs, including mic input/mic_selection, the DVK licence (--no-dvk-license), remote_audio_tx and unkeying on client drop. You can exercise the unlicensed path without hardware.

73,
Nigel G0JKN & Claude (AI dev partner)

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

Issue fit: Partially implements RFC #4214 phase 1: local 12-slot recordings, PC-mic capture, one panel, and TxCoordinator-backed playback. The recording and disconnect safety claims fail under the orderings below. This draft openly states non-Flex hardware validation is outstanding.

Scope

Changed group Claimed? Verdict
Local keyer, WAV store/decoder, TX route, controller, panel/model/settings wiring, focused tests Yes Fits phase 1.
RX playback mute ownership and radio refusal handling Yes, disclosed Related ancillary repairs.
HL2/Icom availability through capability-driven routing Disclosed, beyond RFC's Flex-first approval Needs maintainer ruling and hardware evidence.
User guide No change Required update missing.

Changes requested

  1. Correlate VOX-off confirmation and fence restoration. The new listener accepts a successful acknowledgement for either VOX-on or VOX-off as permission to record. A concurrent operator VOX-on reply can release the recording hold. After one recording, the asynchronous VOX-on restoration can arrive during an immediate second REC: the second start sees the old false model value and skips the hold. Identify the requested value/operation in the acknowledgement and guard pending restoration/operator changes. This is a source-derived race, not a radio reproduction.
  2. Make STOP cancel pending REC. While VOX-off confirmation is pending, LocalVoiceKeyer remains Idle and does not report pending Recording. The panel's STOP does nothing; direct recStop() also returns. A temporary socket-free scratch probe of recStart(1); recStop(1); onRecordHoldReady() failed because recording began after STOP. The test file was restored afterward.
  3. Unkey before disconnect resets the request. disconnectFromRadio() calls resetTxOperations() before the controller's disconnect-signal abort. The later keyOff finds no bound Mox intent and returns, while DAX restoration can proceed. A tester's flex-sim capture on this same head reports xmit 1, then DAX/stream teardown and close, without xmit 0. Source ordering corroborates that report; real-firmware behavior was not tested here. Abort/unkey before resetting the producer request, or otherwise guarantee off before restoration and close.
  4. Update docs/user/docs/dvk-panel.md in this PR. It still says DVK requires SmartSDR+, stores recordings on the radio, and is unavailable on HL2/Icom. The Local source changes those statements. AGENTS.md requires matching user docs in the same PR.
  5. Reconcile current main. This head is unmergeable. A scratch merge of current upstream/main (37fbdbc) and this head conflicted in docs/architecture/aetherd-touchpoint-tags.json and docs/architecture/aetherd-touchpoints.md. Picking the PR side alone drops new main-side tags; regenerate/reconcile the manifest.

Non-blocking / maintainer decision

  • The accepted RFC limited phase 1 to Flex until an equivalent route existed. This PR discloses the HL2/Icom expansion, but route-shape unit tests do not prove device behavior. Obtain the maintainer's scope ruling and planned device evidence before marking the draft ready.
  • The REC accessible description still says 10 seconds while Local permits 60 seconds (src/gui/DvkPanel.cpp).

Verification and limits

Reviewed head 12754793b094b1447fe6145adf63d93778998640 against current main 37fbdbc309c8f5397c6694a4d2aa1574f936d824. On a disposable scratch merge, the ARM64 app and six focused targets built; all six original focused CTests passed. ARM64 processor/executable and absence of RNNoise x86 sources were verified. The temporary STOP probe failed; the original test was restored and passed. git diff --check and strict test registration passed. CI and Static Checks are green on this head but ran against the earlier merge base. New/modified tests are socket-free. An isolated, TX-disabled app connected only to DEMO-0001; slice 0 RX was active and TX was off. The RX-only simulator cannot validate keyer TX or VOX. No live-radio TX was attempted. I also checked WAV decoder bounds and atomic slot replacement. Full-suite and non-Flex hardware behavior remain unverified.

Comment thread src/models/VoiceKeyerController.cpp Outdated
Comment thread src/models/LocalVoiceKeyer.cpp
Comment thread src/models/VoiceKeyerController.cpp
Comment thread src/models/VoiceKeyerController.cpp
nigelfenton added a commit to nigelfenton/flex-sim that referenced this pull request Oct 13, 2026
…live changes (#17)

The sim now answers 'sub license all' with the three FlexLib licence
objects (license, license subscription, license feature) and can be set
up to report any licence, at startup or live from the control port.
Asked for by K5PTB to test AetherSDR against different licences.

- --subscription none|smartsdr+|early-access, --subscription-expires,
  --license-feature NAME=0|1[:REASON] (repeatable), --dvk-refuses;
  --no-dvk-license kept
- /license?feature=&enabled=&reason= / subscription= / expires= /
  dvk_refuses= changes it live and pushes the status to the connected
  client; validated before anything changes; rack mode takes radio=N
- dvk commands refused with 50004001 when dvk_refuses is on
- tests/test_licensing.py (20 checks), PROTOCOL.md section 11, README

Checked end to end against the aethersdr/AetherSDR#6292 build: with the
keyer source on Automatic, AE switches Radio -> Local and back on live
licence changes without reconnecting.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@K5PTB
K5PTB force-pushed the feat/client-voice-keyer-prototype branch from 1275479 to 7e6b991 Compare October 13, 2026 15:07
K5PTB added a commit to K5PTB/AetherSDR that referenced this pull request Oct 13, 2026
aethersdr#6199 seam, STOP cancels a pending REC, DVK alive after reconnect, 30 s local slots, Beta on non-Flex radios. Principle VI.

Robbie's review (CHANGES_REQUESTED) and Nigel's flex-sim review of
1275479, after rebasing onto upstream/main 34363a3.

VOX hold, ported onto the typed control seam (aethersdr#6199)

aethersdr#6199 moved every TransmitModel control behind IRadioBackend, which
removed the reply path the hold used: the hold now has to hear the
radio's answer through the seam. TransmitControlRequest gains an
answerTicket; FlexBackend sends a ticketed request through a reply-aware
sink (the same sendCmdPublic() funnel as its plain sink, so the
command-plane ratchet is unchanged) and emits transmitControlAnswered().
A backend that cannot hear the reply is answered by RadioModel as not
confirmed, never as applied.

- The hold is released only by the answer to its own ticket. An
  operator's VOX click, or the late answer to a hold STOP abandoned,
  can no longer start a recording.
- A restore still in flight counts as VOX on, so a REC started before
  it is answered holds VOX again instead of reading the model's stale
  "off".
- VOX changed while the hold waits fails the hold and is not undone;
  VOX turned on during a recording stops it unsaved; VOX changed during
  a recording is not overwritten by the restore.
- requestVoxEnable() makes no optimistic write and applies an answer
  only while it is still the latest VOX intent.

STOP cancels a REC waiting on its hold

LocalVoiceKeyer reports the waiting recording through
pendingOperation(), so the panel's STOP reaches it, and recStop()
abandons the wait and runs the release.

DVK dead after a reconnect (Nigel, finding 1)

Not the binding: a reconnect reclaims the previous session's slice
models, and the reclaimed TX slice carries tx=1 over, so no edge
re-ran updateKeyerAvailability() after the licence pass had found no TX
slice. It now also runs on slotOccupancyChanged, as aethersdr#5750 does for the
span control. Reproduced 3/3 against flex-sim before, licensed and
unlicensed alike.

Local slot cap: 30 s (was 60 s)

A deliberate choice, not the RFC's: RFC aethersdr#4214 asks only for "the
reporter's requested short-duration cap", and aethersdr#3915 asked for 10 s, the
radio DVK's own limit. 10 s is too short for a contest exchange or a
longer CQ; 60 s turned a slot into a recording rather than a message.
LocalVoiceKeyerStore::kMaxDurationMs is the one constant for recording,
import, play and preview; its comment, the user docs and the PR body
state 30 s, and local_voice_keyer_test static_asserts the documented
value so they cannot drift apart.

Smaller

- REC's accessible description names the bound keyer's own limit
  (10 s radio, 30 s local).
- The panel title reads "(Local · Beta)" on a radio with no voice keyer
  of its own: verified on FlexRadio, Beta elsewhere (Jeremy's direction).
- docs/user/docs/dvk-panel.md covers the Local keyer, the source menu,
  the VOX hold, the recordings folder (including OneDrive) and the
  Beta status. Log Analyzer citations and the seam probe table
  regenerated.

Disconnect mid-playback (Robbie 3, Nigel 2) is not changed: the
coordinator's reset already unkeys the active operation, the keyer's
included, exactly as it does the operator's own MOX; on LAN that queued
xmit 0 is then cancelled by the same reset's fence for every source.
tx_operation_integration_test now pins that the keyer's playback is
stopped like MOX and leaves the same wire.

Tests: voice_keyer_radio_test (new, 6 cases), plus cases in
local_voice_keyer_test, dvk_panel_test, model_control_routing_test,
voice_keyer_record_gate_test and tx_operation_integration_test. Each new
case fails with its fix reverted.

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

K5PTB commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@rfoust @nigelfenton — thank you both. Your reviews found real problems, and the branch now answers them. It is rebased onto main and carries two signed commits: the original (716285ca, conflicts with #6199 resolved) and one commit for the review changes (7e6b991c). The PR description has the full table under Changes since the reviews; the short version:

@rfoust

  1. VOX-off confirmation, and fencing the restore. Fixed. The hold now waits for the answer to its own request, matched by ticket. Since refactor(aetherd): route remaining slice and transmit model controls #6199 removed the reply path the hold used, the answer comes back through the seam: TransmitControlRequest::answerTicket, IRadioBackend::transmitControlAnswered(), and a reply-aware sink in FlexBackend that uses the same sendCmdPublic() funnel as its plain sink, so the command-plane ratchet is unchanged. A backend that cannot hear the reply is answered by RadioModel as not confirmed. The two orderings you described are now tests in voice_keyer_radio_test: a REC started while the previous restore is unanswered holds VOX again; and an unrelated or stale answer — including the late answer to a hold that STOP abandoned — cannot start a recording. VOX changed by the operator while the hold waits fails the hold and is left alone, and VOX turned on during a recording stops it unsaved.
  2. STOP during a pending REC. Fixed. LocalVoiceKeyer::pendingOperation() reports the waiting recording, so the panel's STOP reaches it, and recStop() abandons the wait and puts VOX back. Your scratch probe is now stop_cancels_a_recording_waiting_on_its_hold.
  3. Disconnect mid-playback. I traced this through the real fenced Flex writer, and it is not a keyer defect, so the keyer is unchanged. You are right that the keyer's own unkey runs after resetTxOperations() and finds nothing to release, but TxCoordinator::reset() stops the active operation itself, the keyer's included, exactly as it does the operator's own MOX. On LAN that queued xmit 0 is then cancelled by the same reset's fence, for every source: MOX under the same disconnect leaves an identical wire. Issuing the keyer's abort before the reset was tried and measured; the fence drops it all the same. tx_operation_integration_test now pins "stopped like MOX, same wire", and fails if the reset's stop is removed. Two observations for you, outside this PR: no source gets xmit 0 onto the wire when a keyed LAN session disconnects; and since refactor(aetherd): route remaining slice and transmit model controls #6199 a producer's DAX restore on disconnect is refused by m_txSessionClosing. The second I have from reading the code, not from measuring it.
  4. User docs. Done. dvk-panel.md covers both keyers, the source menu, the VOX hold, the local recordings folder, the 30 s cap and the Beta status.
  5. Reconcile with main. Done, and GitHub now reports it mergeable.

On scope: @ten9876's direction is to verify on FlexRadio and ship as Beta on the other radios, with a public call for testing after release. The panel title reads "Digital Voice Keyer (Local · Beta)" on a radio with no voice keyer of its own, and the docs say the same.

@nigelfenton

  1. DVK indicator dead after connecting. Your repro was the key. With flex-sim 0.3.3 it is dead after every reconnect, licensed and unlicensed alike, so it was never the binding. A reconnect reclaims the previous session's slice models, and the reclaimed TX slice carries tx=1 over, so nothing re-ran updateKeyerAvailability() after the licence pass had found no TX slice. It now also runs on slotOccupancyChanged, as Panadapter zoom is offered per-pane but is radio-wide on the HL2, and panSpanIsRadioWide reaches no UI code #5750 already does for the span control: 3/3 dead before, 3/3 alive after, both ways.
  2. Disconnect mid-playback. As in item 3 above: the reset unkeys, then the LAN fence drops the queued xmit 0, for every source. The transmit set dax=0 you saw going out on the old base should no longer go out at all: reading the code, since refactor(aetherd): route remaining slice and transmit model controls #6199 the closing gate refuses it. I have not measured that.
  3. REC's "up to 10 seconds". Fixed: it names the bound keyer's own limit. The local cap is now 30 s, deliberately — Voice Recorder for calling CQ #3915 asked for 10 s, the radio's own limit, which is too short for a contest exchange, and 60 s was more than a message needs.
  4. Dropped-in WAVs and OneDrive. Both are documented. A dropped-in WAV appears when Local is chosen again; a folder watch is not in this PR.

The --no-dvk-license switch and the licensed simulator covered paths my radio cannot. Thanks for building them.

Every new or changed test was checked to fail with its fix reverted. Full ctest passes (795/795), as do all the static checks, and I did a final functionality and visual pass on a FLEX-6500 on 7e6b991c. Ready for another look when you have time.

@K5PTB
K5PTB marked this pull request as ready for review October 13, 2026 15:10
@K5PTB
K5PTB requested review from a team as code owners October 13, 2026 15:11

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

Reviewed against RFC #4214, its approval ruling, and the two review threads. This is unusually careful work — the ticketed VOX confirmation, the single finish() hand-back funnel, and the canStartOperation()-rather-than-status() deferral all answer the exact failure modes the approval named. I spent most of my time trying to break the parts the body is most confident about. Two things need a decision before this leaves draft, and one is a confirmed parsing defect.

Everything below is reasoned from the code at 7e6b991c plus the head checkout; I have no build and ran nothing, so no runtime claim is mine. The one exception is the arithmetic in Blocker 2, which I compiled standalone.

1. Issue fit

Partially — scope is the open question, not the implementation. RFC #4214's phase 1 asks for local slots, PC-mic recording, and one panel with a Local/Radio selector; all three are here, driven through one VoiceKeyer interface rather than a second surface, which is what the ruling's answer to Q1 required. Mapping the approval's conditions to the diff: TxCoordinator request at the input boundary (VoiceKeyerTxRoute::admitOperation(), called before the settle hop in GeneratedAudioTransmitter::start()) ✓; TX audio state restored (claimAudioPath/releaseAudioPath) ✓; one F1–F12 set, dispatched to the bound keyer ✓; routing changes never retarget a running operation (refreshBinding()'s deferral) ✓; OS-account storage, atomic replacement via QSaveFile, bounded decoding ✓ (with Blocker 2 as the gap); repeat and CQ/Call chrome correctly deferred ✓; no TCI/CAT surface added ✓; socket-free tests registered ✓. The deviation is the last condition — see Blocker 1.

2. Scope

Files What it changes Claimed in body Verdict
src/core/{VoiceKeyer*,LocalVoiceKeyerStore,LocalWavPlayer,GeneratedAudioTransmitter}.{h,cpp}, src/models/{VoiceKeyer*,LocalVoiceKeyer,ModeVocabulary} the feature itself yes in scope
src/core/DaxTxPolicy.h ClientVoiceKeyerTx reason yes in scope — the RFC names this clone
src/core/backends/IRadioBackend.h, ModelControlRequest.h, flex/FlexBackend.h, flex/FlexModelControls.cpp answerTicket, transmitControlAnswered(), answersTransmitControl(), reply-aware sink yes new seam surface → maintainer decision (see below)
src/models/TransmitModel.{h,cpp} requestVoxEnable(), ticket bookkeeping, voxIntentMark() yes in scope
src/models/RadioModel.{h,cpp} answered sink, answer routing, test friend yes in scope
src/gui/{DvkPanel,MainWindow*} panel drives the interface; controller wiring; source menu yes in scope
src/gui/VoiceModeGate.h → src/models/ModeVocabulary.h mode vocabulary moved below the UI yes in scope — engine-boundary motivated
src/gui/MainWindow_Session.cpp slotOccupancyChanged → updateKeyerAvailability() yes (@nigelfenton 1) in scope — pre-existing reconnect bug, disclosed, mirrors #5750
src/gui/RxPlaybackMuteOwners.h + its test pre-existing two-producer mute bool yes in scope — a third owner makes it this PR's problem; defensible, though it would also stand alone
src/gui/DvkAvailabilityGate.h radioVoiceKeyerUnavailableReason(radioRefused) yes, with an offer to split in scope — keep it; without it the menu offers a source that yields silence
tools/check_engine_boundary.py removes the DvkPanel.cpp → DvkWavTransfer baseline entry no in scope and tightening, not weakening — DvkWavTransfer now appears in DvkPanel.cpp only in a comment. Worth a body line so it doesn't read as a gate edit
docs/architecture/aetherd-touchpoint* generated manifest, 255 → 258 yes in scope
docs/user/docs/dvk-panel.md both keyers, source menu, VOX hold, folder, 30 s, Beta yes (@rfoust 4) in scope
docs/user/src/components/LogAnalyzer/rules.json RadioModel.cpp line renumbering only no mechanical consequence of the RadioModel.cpp insertions; in scope
tests/* (11 new targets, 5 updated) coverage yes in scope

Nothing in the diff is unexplained by the issue or the body. No CHANGELOG.md edit — correct.

Socket tests: none added, none removed. Every one of the eleven new targets is socket-free — I grepped all of them plus tests/FakeTxAudioRoute.h for QTcpServer/QTcpSocket/QUdpSocket/QLocalServer/QWebSocketServer/connectToHost/listen/bind and got no hits. All eleven are registered in tests/tests.cmake with add_test. Recording this per the reporting rule; it is not a finding.

Blockers

1. The non-Flex scope expansion rests on a maintainer direction I cannot find, and the written ruling says the opposite. — Needs maintainer decision, not a code defect.

The body states: "@ten9876's direction on scope: confirm it works properly on FlexRadio, and ship it as Beta on the other radios, with a public call for testing after it is released." The approval ruling on #4214 (ten9876, 2026-10-09) lists among the conditions:

Scope is Flex radios (dax_tx). Say so in the UI and docs until a backend provides an equivalent route.

I read every comment on #6292 and on #4214 and searched the tracker; the only ten9876 statements are the 2026-09-25 review recommendation and that approval. I could not locate the Beta direction anywhere. @rfoust's fourth review comment asks for exactly this: "Please obtain the requested maintainer scope ruling … before marking this draft ready."

To be fair to the PR: it makes a real argument that the ruling's own "until a backend provides an equivalent route" clause is now satisfied — VoiceKeyerTxRoute::needsStream() (src/core/VoiceKeyerTxRoute.cpp:37-41) answers from caps.hostModulates || caps.takesTxAudioOverSeam rather than a family name, and the two gate sites read canTransmit instead of the Flex entitlement. That is the right shape, and generated_audio_transmitter_test does drive the HL2 and Icom route shapes. So this may well be a decision a maintainer would make. But it is a decision, and right now the PR is self-certifying it against a citation that cannot be checked. Either link the direction, or ask for the ruling on this PR. If the Beta ruling stands, non-Flex hardware evidence should not be required — that is what Beta means.

2. src/core/VoiceKeyerWavDecoder.cpp:105 — the RIFF chunk stride is computed in 32-bit arithmetic and wraps, so a declared chunk size near UINT32_MAX walks the file 9 bytes at a time instead of ending the walk.

        // Chunks are word-aligned; a zero-size chunk would loop forever.
        qint64 advance = 8 + chunkSize + (chunkSize & 1);
        if (advance <= 8)
            advance = 9;

chunkSize is const quint32 (line 71). In 8 + chunkSize, the literal converts to unsigned int, so the sum is taken mod 2^32 before widening to qint64. I compiled the expression standalone: chunkSize = 0xFFFFFFF9 gives advance = 2, which the guard below then rewrites to 9.

Failing input: a file in Documents/AetherSDR/VoiceKeyer (or one chosen in Import WAV…) padded to near the 100 MB cap, carrying a chunk header whose declared size is in 0xFFFFFFF8..0xFFFFFFFF. The walk then runs ~11M seek + read(8) pairs instead of jumping past EOF. There is no iteration cap and no early exit once both fmt and data are found. This runs on the GUI thread: probeDurationMs via LocalVoiceKeyerStore::durationMs() for all twelve slots on every LocalVoiceKeyer::reload(), and decodeToMonoFloat in playbackStart(). It terminates, so this is a main-thread stall rather than a hang or any memory-safety problem — but a multi-second main-thread stall is a transmit gap on this codebase, and the drop-in folder is a documented, operator-writable input boundary. The header's own comment ("Never trusts chunk sizes: every read is bounds-checked") is true of the reads and not of the stride; and "Imported files are decoded with size, length and rate caps (Principle VII)" is a condition of the RFC approval. Credit: surfaced by security-audit (CodeGuard), confirmed against the source and by compiling the arithmetic.

Two-part fix — widen before adding, and stop walking once both chunks are in hand:

        // Chunks are word-aligned; a zero-size chunk would loop forever.
        // Widen before adding: 8 + a quint32 near UINT32_MAX wraps in 32-bit
        // arithmetic, and the guard below would then step 9 bytes through the
        // whole file instead of ending the walk.
        const qint64 size64 = chunkSize;
        qint64 advance = 8 + size64 + (size64 & 1);
        if (advance <= 8)
            advance = 9;
        pos += advance;
        if (haveFmt && dataOffset >= 0)
            break;   // nothing after this can change the answer
    }

(and drop the now-duplicated pos += advance; above it).

Needs maintainer decision

3. New IRadioBackend surface arrives inside a feature PR — src/core/backends/IRadioBackend.h:147 (answersTransmitControl()), :945-948 (transmitControlAnswered(quint64, bool)), src/core/backends/ModelControlRequest.h:57-63 (answerTicket).

The implementation is good: RadioModel answers for a backend that cannot hear the reply and reports Dispatched-without-answer as not confirmed (RadioModel.cpp:10064-10073), the Flex path reuses sendCmdPublic() so the command-plane ratchet is untouched, the answer is queued and generation-guarded, and a superseded request cannot write the model. The protocol finding behind it — a FLEX-6500 answers vox_enable with R<seq>|0| and never echoes it in a status — is exactly the kind of evidence Principle II wants, and genuinely forces a reply-shaped seam. But this is new cross-cutting seam surface on the interface every backend implements, arriving as a side effect of a voice-keyer feature. That is a maintainer call per the project's own scope rules, not something to wave through on quality. Worth noting the same mechanism would serve any other "must confirm before acting" control, so the generality is real rather than speculative.

Nits

All non-blocking.

  • src/core/VoiceKeyerWavDecoder.cpp:199 / src/core/LocalVoiceKeyerStore.cpp:117-130 — the caps are individually sound but they multiply. An 8-bit mono WAV declaring sampleRate = 192000 passes the parser's 600 s check at ~520 s, so a 100 MB data chunk expands to a ~400 MB monoFloat32 held alongside the 100 MB raw, then the resampled stereo and the int16 PCM — roughly 600 MB peak, allocated synchronously on the GUI thread, before importSlot applies the 30 s cap. playbackStart() has it right: playableRefusal() (header-only probe) runs first. importSlot could do the same by calling probeDurationMs before decodeToMonoFloat, or by rejecting on decoded sample count. (security-audit (CodeGuard))
  • src/models/LocalVoiceKeyer.h:62-64 claims a parity invariant that setName does not enforce. The comment says the 61 is "Matches the radio's limit on purpose: a slot name typed under one keyer then fits the other, so switching source never truncates a name." But LocalVoiceKeyer::setName (src/models/LocalVoiceKeyer.cpp:343-349) only calls .trimmed(), while DvkModel::sanitizeName strips quotes and | and trims to 61 bytes. DvkPanel.cpp's setMaxLength(m_model->maxNameBytes()) bounds QChars, not bytes, so 61 CJK characters is ~183 bytes stored locally and truncated on a switch to Radio. Either cap bytes in setName or soften the comment. (security-audit (CodeGuard))
  • The body's engine/UI-split line is wrong. It says "MainWindow_DigitalModes.cpp loses 232 lines and no new gui/ → core/ touchpoint appears." git diff --numstat 34363a381..HEAD gives 130 0 src/gui/MainWindow_DigitalModes.cpp — the file gains 130 lines and deletes none; 123 of them are the new wireLocalVoiceKeyer() and source menu. The touchpoint half of the sentence does check out (models/DvkModel.h leaves the manifest, core/DvkWavTransfer.h 2→1). Only the line count is wrong, and the split it describes is real elsewhere — but it reads as evidence, so it should be corrected or dropped.
  • An explicit Radio choice strands the keyer on a radio with no DVK. resolveVoiceKeyerSource (src/models/VoiceKeyerSource.h:43) honours an explicit Radio setting unconditionally, so on an HL2 voiceKeyerUsable(Radio, /*radioHasKeyer=*/false, …) is false and the indicator is hidden — on a radio where Local would have worked. The docs say the indicator "is hidden only on a radio that can neither transmit nor host a voice keyer," which is not true in this case. Settings ▸ Voice Keyer Source… is unconditional so it is recoverable, but the operator has to know that. Consider falling back to Local when the explicit Radio choice is unusable, or at least matching the docs to the behaviour.
  • importWav/exportWav gate on m_status != Idle rather than canStartOperation() (src/models/LocalVoiceKeyer.cpp:355, :367). That is the precise distinction busyRefusal()'s own comment at :134-136 insists on: a recording waiting on its VOX hold reads Idle. So an Import during that window overwrites the slot the pending recording is about to write. Harmless in practice, and exportWav has no guard at all; worth making the three consistent.
  • src/models/VoiceKeyerController.cpp:195-196 describes a detection the protocol does not support. The comment says VOX coming back on — "the operator's click, or another client's" — ends the recording. checkVoxStillHeld() fires on phoneStateChanged, but the PR's own protocol finding is that the radio never mentions vox_enable in a transmit status, so a second client's VOX change has no path to this model. RFC #4214 flags MultiFlex as a known risk; either the claim is too strong or there is a path I missed.
  • RadioModel::dispatchTransmitControl's !request.valid() early return (src/models/RadioModel.cpp:10036-10039) precedes the answerTicket fallback, as do the !isConnected() || m_txSessionClosing guards at :10029. A ticketed request refused there is answered only by the controller's 3 s backstop. Fail-closed and harmless for VoxSetting (always valid), but it means a disconnect race costs a 3 s stall before REC refuses rather than refusing at once.
  • **src/core/VoiceKeyerWavDecoder.cpp:152** — frames / fmt.sampleRate > kMaxDurationSec` is integer division, so 600.99 s passes. Cosmetic.
  • The touchpoint manifest totals (255 → 258) will go stale the moment another PR touching the manifest merges first. Merge-order note, not a finding.

What I tried to break

  • The VOX hold's ticket correlation, which is @rfoust's first finding. Drove the orderings by hand: two tickets in flight at once (hold + a previous restore); the restore answered with current=false; an operator VOX-on answered while the hold waits; a late answer to a hold that STOP abandoned. onVoxAnswered (VoiceKeyerController.cpp:124-141) checks ticket == m_restoreTicket before ticket != m_holdTicket and then requires applied && current && !on — so an answer to VOX on cannot reach onRecordHoldReady(), which was the defect. restoreVox() additionally fences on voxIntentMark(), captured after its own request, so an operator change since the hold wins. TransmitModel::requestVoxEnable makes no optimistic write, which is what lets shouldHoldVoxOff(..., restorePending) hold again rather than read a stale "off". I could not construct an ordering that starts a recording with VOX enabled. Held.
  • STOP during the pending hold (@rfoust's second finding). recStop() (LocalVoiceKeyer.cpp:188-197) clears m_awaitingHoldSlot before the m_status != Recording return, emits admissionChanged, and runs the release; onRecordHoldReady() then returns on m_awaitingHoldSlot < 0. canStartOperation() is overridden to include the wait, so the panel's STOP reaches it and a second REC cannot. The scratch probe they describe is stop_cancels_a_recording_waiting_on_its_hold at tests/local_voice_keyer_test.cpp:561. Fixed.
  • The TxCoordinator admission leaking on the pre-keyOn() exits — a stop or a stream timeout during WaitingForStream/Settling, where VoiceKeyerTxRoute::keyOff() skips requestProducerPttOff because m_pttRequested is false. I expected a dropped handle and a second PLAY refused. It is not: TxCoordinator::Request is a shared_ptr<RequestState> and RequestState::~RequestState() decrements identity->requests, so m_request = {} releases the admission by refcount. Not a finding.
  • Re-entrancy through our own unkey. finish() sets m_phase = Idle before calling keyOff(), and onKeyReleased() is gated on m_phase != Phase::Idle, so the moxChanged(false) our own release provokes cannot recurse into a second finish(). The keyOn() → permitsDispatch failure path, which unkeys synchronously, is caught by the m_phase != Phase::WaitingForPtt check after keyOn() returns.
  • The 30 s cap bypass the body says it closed — a long WAV dropped into the folder. I checked whether probeDurationMs and decodeToMonoFloat could disagree, which would let a lying header pass the cap and then transmit the real length. They cannot: both go through parseWav, and dataBytes = std::min<qint64>(chunkSize, fileSize - body) clamps the declared size to what is actually there, so the probe can never understate. playableRefusal() is applied at both PLAY and PREV. Held — and covered at tests/local_voice_keyer_test.cpp:407.
  • The WAV parser's read bounds, separately from the stride defect: the fmt body is read into a 40-byte buffer with want = min(chunkSize, sizeof(fbuf)), the 0xFFFE unwrap is gated on chunkSize < 40 before touching fbuf + 24, frameBytes cannot be zero because bitsPerSample is allow-listed first, and NaN/Inf is zeroed before the clamp so it never reaches the TX chain. No out-of-bounds read for any bit depth. Held.
  • The keyer swap leaving stale connections. DvkPanel::setKeyer does disconnect(m_model, nullptr, this, nullptr) before rebinding, and every connectKeyer() lambda takes this as context, so none survive — which matters because main requires exactly this discipline (the same duplicate-connection hazard RxPlaybackMuteOwners exists to fix).
  • Thread boundaries. txFinalMonitorPcmReady is emitted on the audio thread and onMicPcm is reached by a cross-thread (queued) connection, gated on source == TxAudioSource::Microphone so another producer's audio cannot land in a slot. sendAudio/finishAudio marshal onto the engine with QPointer guards. The backend answer is explicitly Qt::QueuedConnection so it cannot land inside the request that asked for it.
  • CI: all seven checks green on 7e6b991c (build, check-windows, check-macos, Static checks, docs, sanitizer-configures). Worth remembering ctest in ci.yml is -R-filtered, so green does not mean the 795/795 the body reports — I cannot verify that number, and I cannot verify any of the hardware, flex-sim or wire-capture evidence either. The byte-identical-capture claim (157365045…) is the strongest thing in the test plan and also the thing I am least able to check.
  • CodeGuard's 325 findings: refuted and dropped the CS-VLN-CMDI-BACKTICK hits on IRadioBackend.h, FlexBackend.h, DvkAvailabilityGate.h and the tags JSON (lexical matches on backticked prose in comments), the CMDI-PIPE/SEMI/AMP hits in DvkPanel.cpp (the |/;/& of the rename sanitizer and status strings), the PATH-UNC/PATH-PROC hits (slotPath formats an int as slot-%02d.wav, so no separator or .. can appear for any id), and the PII/SSRF/password hits in rules.json (which contains no voice-keyer entry at all — consistent with a whole-file scan of main's tip, not this diff). Two leads survived and are above.

Recommendation

Needs maintainer decision. The code is strong and the two substantive review findings are genuinely fixed, not papered over — I tried hard to re-break both and could not. What blocks it is not quality:

  1. Blocker 1 — the scope expansion to HL2/Icom needs the ruling on the record. The approval as written says Flex-only; the PR argues the "until a backend provides an equivalent route" clause is met, and the capability-shaped route is good evidence for that, but it is @ten9876's call and the cited direction is not in either thread.
  2. Blocker 3 — the new IRadioBackend answer surface is a seam addition that deserves its own yes.
  3. Blocker 2 is the author's to fix and is a one-line widening plus an early break.

Concrete next step: fix Blocker 2 and the body's line-count claim, then ask @ten9876 to confirm the Beta scope and rule on the answerTicket seam. Once those land this is ready for a merge decision on the code alone. Thank you for the unusually honest test plan — naming the two paths you could not test, and that the m_txSessionClosing DAX-restore observation comes from reading rather than measuring, is what made the rest of it worth trusting.


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

@aethersdr-agent

Copy link
Copy Markdown
Contributor

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

  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in docs/architecture/aetherd-touchpoint-tags.json /tmp/aetherclaude/pr-6292/docs/architecture/aetherd-touchpoint-tags.json:10
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in docs/architecture/aetherd-touchpoint-tags.json /tmp/aetherclaude/pr-6292/docs/architecture/aetherd-touchpoint-tags.json:1157
  • [MEDIUM] CS-VLN-SSRF-HEX — Vulnerability: CS-VLN-SSRF-HEX in docs/user/src/components/LogAnalyzer/rules.json /tmp/aetherclaude/pr-6292/docs/user/src/components/LogAnalyzer/rules.json:89
  • [HIGH] CS-VLN-PATH-UNC — Vulnerability: CS-VLN-PATH-UNC in docs/user/src/components/LogAnalyzer/rules.json /tmp/aetherclaude/pr-6292/docs/user/src/components/LogAnalyzer/rules.json:744
  • [HIGH] CS-VLN-PATH-UNC — Vulnerability: CS-VLN-PATH-UNC in docs/user/src/components/LogAnalyzer/rules.json /tmp/aetherclaude/pr-6292/docs/user/src/components/LogAnalyzer/rules.json:1003
  • [HIGH] CS-PII-DL-1 — PII detected: drivers_license in docs/user/src/components/LogAnalyzer/rules.json /tmp/aetherclaude/pr-6292/docs/user/src/components/LogAnalyzer/rules.json:88
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/core/LocalVoiceKeyerStore.cpp /tmp/aetherclaude/pr-6292/src/core/LocalVoiceKeyerStore.cpp:24
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:48
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:95
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:184
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:339
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:347
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:363
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:424
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:463
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:480
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:513
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:638
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:685
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:698
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:711
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:736
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:817
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:830
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:843
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:873
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:905
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:1009
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:1014
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:1098
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:1148
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/IRadioBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/IRadioBackend.h:1154
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/flex/FlexBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/flex/FlexBackend.h:175
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/core/backends/flex/FlexBackend.h /tmp/aetherclaude/pr-6292/src/core/backends/flex/FlexBackend.h:183
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/DvkAvailabilityGate.h /tmp/aetherclaude/pr-6292/src/gui/DvkAvailabilityGate.h:20
  • [HIGH] CS-VLN-PATH-UNC — Vulnerability: CS-VLN-PATH-UNC in src/gui/DvkPanel.cpp /tmp/aetherclaude/pr-6292/src/gui/DvkPanel.cpp:815
  • [CRITICAL] CS-VLN-CMDI-SEMI — Vulnerability: CS-VLN-CMDI-SEMI in src/gui/DvkPanel.cpp /tmp/aetherclaude/pr-6292/src/gui/DvkPanel.cpp:770
  • [CRITICAL] CS-VLN-CMDI-PIPE — Vulnerability: CS-VLN-CMDI-PIPE in src/gui/DvkPanel.cpp /tmp/aetherclaude/pr-6292/src/gui/DvkPanel.cpp:597
  • [CRITICAL] CS-VLN-CMDI-PIPE — Vulnerability: CS-VLN-CMDI-PIPE in src/gui/DvkPanel.cpp /tmp/aetherclaude/pr-6292/src/gui/DvkPanel.cpp:675
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/DvkPanel.cpp /tmp/aetherclaude/pr-6292/src/gui/DvkPanel.cpp:548

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


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

rfoust
rfoust previously requested changes Oct 13, 2026

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

Re-review of 7e6b991 against #4214

Request changes: Local REC can still begin while the radio may have VOX enabled. The previous ticket-correlation and pending-STOP defects are fixed; this is a different ordering.

Area Result
Local slots, WAV handling, shared panel, TX route Fits the phase-one feature
Ticketed Flex VOX hold and restore Previous findings addressed
RX mute ownership, reconnect availability, generated ratchet/docs Related or explained scope
HL2/Icom Beta availability and new backend answer seam Maintainer decision needed; please link the ruling for the former and confirm the latter

Blocking finding

TransmitModel::setVoxEnable(false) updates the model optimistically and emits an unticketed control request. If the operator clicks VOX off and immediately starts Local REC before the radio processes that request, the guard here sees voxEnable() == false and starts recording without a confirmed VOX-off hold. I reproduced this exact ordering in a temporary voice_keyer_radio_test case: after a backend VOX-on status, setVoxEnable(false), then recStart(1), the only wire request was vox_enable=0 with answerTicket == 0; REC entered Recording and no confirming request was issued. A delayed or rejected operator-off command can therefore leave VOX active during mic recording. Please track the unconfirmed operator-off state or require a ticketed VOX-off answer before admitting REC. The temporary case was removed after the repro; the existing test passed again.

Nonblocking

  • The user guide gives 8–384 kHz as a common import range, but the Local decoder caps input at 192 kHz. Please state the two limits separately.
  • Please have the maintainer confirm the new 30-second Local cap (the earlier issue asked for 10 seconds) and the HL2/Icom Beta scope. The code's new IRadioBackend answer surface also deserves an explicit seam decision. These are product/architecture decisions, not an assertion that the implementation is defective.

Verification

I reconstructed this head on current upstream/main and completed the required ARM64 build (RADE enabled, ARM64 final executable, no RNNoise x86 sources), nine focused CTest targets, strict test-registration and touchpoint checks, and an isolated DEMO-0001 RX-only bridge smoke test with TX disabled. Seven current PR checks are green. The demo cannot establish live RF/VOX behavior; the blocker is a state-machine repro, not an on-air observation. I did not run the full test suite.

Comment thread src/models/VoiceKeyerController.cpp Outdated
Comment thread docs/user/docs/dvk-panel.md Outdated
@K5PTB

K5PTB commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@rfoust — thank you, that ordering is real, and your repro was exact.

The blocker. setVoxEnable(false) writes the model before the radio has answered, so REC was trusting a request as if the radio had confirmed it. TransmitModel now tracks whether VOX is confirmed (voxStateConfirmed()). An operator's VOX click marks it unconfirmed; a status that reports VOX, or the answer to a ticketed request, confirms it. Before recording, the hold now takes one of three courses (voxHoldForRecording()):

  • VOX on, or a restore still in flight: hold it off and restore it afterwards, as before.
  • The model says off, but unconfirmed: send a ticketed vox_enable=0 and record only once the radio answers. VOX is not restored afterwards, because off is what the operator asked for. A refused answer refuses the recording.
  • Off and confirmed by the radio, or a radio with no VOX that AetherSDR controls (voxControl unset, as on the HL2): record at once.

Your sequence is now rec_after_an_unanswered_operator_vox_off_confirms_it_first in voice_keyer_radio_test, with a_refused_vox_off_refuses_the_recording beside it. With the guard reverted to trust the model, the first one fails exactly as you saw: one unticketed vox_enable=0, then Recording.

User guide. The import limits are now a Radio/Local table: 8–384 kHz versus 8–192 kHz, 32-bit float versus 32 and 64-bit float, 10 s versus 30 s, and Local's 100 MB file cap. The VOX section covers the confirm-first case.

Also from the automated review. The WAV decoder's chunk walk (VoiceKeyerWavDecoder.cpp, the file carried over from #957) took its stride as 8 + chunkSize in 32-bit arithmetic. A declared size near UINT32_MAX wrapped to a short step, and the walk crept through the file on the GUI thread, reading audio as chunk headers. The stride is now 64-bit, and the walk stops once it has fmt and data and gives up past 256 chunks. A zero-size chunk is legal, but the old guard stepped it 9 bytes; it no longer misaligns the walk. Each new test in voice_keyer_wav_decoder_test is decided by what the decoder accepts, so the old stride fails all three. The PR body's line count for MainWindow_DigitalModes.cpp was wrong too, and is corrected: the file gains 130 lines.

Both fixes are pushed (f57afc0a, c162cf33). Full ctest passes (795/795), as do all the static checks, and the docs site builds. The PR description is updated, with a table of these changes under Re-review of 7e6b991c.

@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 c162cf33f957f06235fda36d8709e71d8e6f12bf. Request changes: phase 1 of RFC #4214 is substantially implemented, but two reproducible operation-lifecycle defects remain. Local per-user slots, PC-mic recording, existing-panel/source dispatch, bounded imports and the core TX route map to the approved requirements. Repeat/CQ automation and TCI/CAT extensions remain outside this phase. The latest push fixes the earlier unconfirmed VOX-off admission and WAV stride findings; those are not outstanding blockers in this review.

Scope audit

File/group Change Claimed? Verdict
LocalVoiceKeyer, VoiceKeyer, DvkModel Common keyer interface and local slot/operation state Yes RFC phase 1
LocalVoiceKeyerStore, VoiceKeyerSettings, VoiceKeyerSource Per-user local files, atomic writes, source preference Yes In scope
VoiceKeyerWavDecoder, LocalWavPlayer Bounded conversion and local preview Yes In scope
GeneratedAudioTransmitter, VoiceKeyerTxRoute, DaxTxPolicy Generated audio through coordinator and backend-shaped routing Yes In scope
VoiceKeyerController, VoiceKeyerRecordGate, TransmitModel Source binding, ticketed VOX hold/confirmation/restore Yes In scope; blocker 1
IRadioBackend, ModelControlRequest, FlexBackend, FlexModelControls, RadioModel New transmit-control answer surface and Flex reply routing Yes Jeremy: maintainer decision on new public seam surface
DvkPanel Rebind existing panel, local actions and source qualifier Yes In scope; blocker 2
MainWindow*, DvkAvailabilityGate, VoiceModeGate, ModeVocabulary Single dispatcher, source menu, voice/capability gating, reconnect wiring Yes In scope
RxPlaybackMuteOwners Multiple preview producers retain their own RX mute ownership Yes Necessary integration
CMakeLists.txt, tests/tests.cmake, changed/new tests, FakeTxAudioRoute, seam probe table Registration and socket-free state/transport injection coverage Yes In scope
Touchpoint tags/manifest, engine-boundary tool Classify new headers and remove obsolete panel vendor include baseline Yes In scope; ratchets do not grow
User DVK guide and LogAnalyzer rules Document current behavior and refresh source locations Yes In scope

No unrelated commits or files found. The body now correctly states that MainWindow_DigitalModes.cpp gains 130 lines, and the guide separates Radio/Local import limits. The 30-second local cap deliberately differs from the RFC's 10 seconds; Beta availability beyond Flex and the added public control-answer API are disclosed, but remain decisions for Jeremy under GOVERNANCE.md. The body attributes the Beta ruling to @ten9876; I could not independently locate that ruling in the fetched RFC/PR discussion. Please retain a link to that decision and explicitly confirm the 30-second choice.

Blockers

  1. [P2] VOX-on does not stop a recording that began with VOX already off. src/models/VoiceKeyerController.cpp:210 checks !m_voxHeldOff before considering the active recording. A radio-reported VOX-off admits REC without a hold, leaving that flag false; a subsequent operator VOX-on therefore returns immediately. A socket-free probe at the reviewed head reports:

    PROBE initially-off then on: recording=1 vox=1 failures=0
    [FAIL] review_vox_on_stops_all_local_recordings
    

    The existing mid-recording VOX test starts with VOX on and exercises only the held path. The user guide (docs/user/docs/dvk-panel.md:64) promises that turning VOX on ends recording unsaved. Monitor every active local recording, including recordings admitted from confirmed-off state, and abandon it when VOX becomes enabled while keeping the operator's setting. Add the initially-off → REC → operator-on case to the socket-free radio test. This is a controller/model reproduction, not an on-air test.

  2. [P2] Rebinding the panel retargets an in-progress rename. src/gui/DvkPanel.cpp:740 swaps m_model without ending or pinning the inline editor; src/gui/DvkPanel.cpp:875 commits through the new pointer. In the real Qt widget, I double-clicked slot 1, entered a name on the first keyer, called the production setKeyer() on the second keyer and submitted the editor:

    PROBE rename: first= other=setName 1 Started on first
    [FAIL] review_rename_does_not_retarget_after_rebind
    

    Automatic licence resolution or a source change during editing can thus write a local rename onto the radio's shared slot instead. Cancel the edit before changing keyers, or retain and validate the original operation owner. The minimal rename fix belongs immediately before the outgoing disconnect:

        cancelRename();
        disconnect(m_model, nullptr, this, nullptr);
        m_model = keyer;

    Also audit the modal import/export continuations (DvkPanel.cpp:806, :823): they read the current m_model after the file chooser's nested event loop. The rename is empirically reproduced; the analogous chooser ownership hazard is source-inspected. Add a socket-free widget regression that edits, rebinds and submits, asserting that the incoming keyer receives no rename.

Non-blocking nits and evidence limits

  • src/core/VoiceKeyerWavDecoder.cpp:109 and src/core/VoiceKeyerWavDecoder.h:22 narrate the review history in code. Keep the current invariant (64-bit, word-aligned, bounded stride) there; move the incident narrative to the commit/PR body, per AGENTS.md's Comments rule.
  • Maintainer decisions above are scope calls, not additional correctness findings. Positive Flex hardware playback, byte-identical on-air audio, full 795-test execution, and HL2/Icom convergence were not independently reproduced. Author evidence is distinct from the local results below. No model-invocable code-review skill was available; the manual/parallel audit results were independently checked.

Empirical verification

  • Linux Qt 6.12 lean shared-core build of AetherSDR and 14 focused targets. ASR/RADE/D-Star optional integrations were disabled. All 14 focused CTests pass, including generated-audio lifecycle, route/intent, controller, local storage, panel and RX mute ownership. A sandbox multimedia stall was avoided by running the focused suite outside the sandbox; it is not classified as a PR failure.
  • Breaking pending-REC cancellation in a scratch source copy makes the PR's stop_cancels_a_recording_waiting_on_its_hold test fail (pending=1 status=3); the unmodified test passes. Tests are socket-free state/transport injections, not synthetic firmware peers.
  • At the new head, unanswered operator VOX-off now waits (recording=0 requests=2 ticket=1), rejected holds refuse REC, and the updated decoder accepts the empty JUNK fixture. Near-maximum chunk stride and excessive chunk-count regressions pass. The two blockers above still reproduce.
  • Engine-boundary, capability and command-plane strict checks pass without ratchet growth (engine findings 102 → 101; capabilities 70; commands 297). Touchpoint manifest, seam table, test registration, generated reference pages and LogAnalyzer's 707 checks pass.
  • Drove the isolated offscreen instance aethersdr-pr6292-review against DEMO-0001 only, with txAllowed:false. connect wait/get radio returned model:"AetherSDR Demo", serial:"DEMO-0001", sliceCount:1, panCount:1, transmitting:false; inspected the widget/menu tree, disconnected and closed the exact owned instance. Demo cannot reach the transmit-capable local REC/VOX path: those findings use the production controller/widget with state injection. No operator hardware was driven. Repeated the demo connection, identity/TX-gate checks and disconnect on the final reviewed head after rebuilding; both owned instances were closed.
  • CI on the final head was still running at inspection; docs and Static checks passed. Earliest run creation was 2026-10-13T19:24:17Z, newer than current main's 2026-10-13T08:19:09Z; no stale-main timing gap was found. Green CI does not cover the two added hostile probes.

Recommendation: request changes. Fix the two remaining lifecycle cases, add the focused socket-free regressions, and retain explicit maintainer rulings for the disclosed scope decisions.

@K5PTB

K5PTB commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@ten9876 — thank you. Both lifecycle defects are real, and both are fixed with the socket-free regressions you asked for.

1. VOX-on now stops every active local recording. The check is no longer gated on the hold: any local recording, including one admitted from a confirmed VOX-off with no hold taken, is abandoned unsaved when VOX turns on, and the operator's VOX setting is kept (nothing is restored). Radios with no VOX AetherSDR controls (voxControl unset, the HL2) are not watched, because their VOX flag cannot key them. New in voice_keyer_radio_test: vox_on_stops_a_recording_that_needed_no_hold (initially off → REC → operator on), and the HL2 case now also checks that a VOX click there does not end the recording. With the old m_voxHeldOff gate restored, the new case fails.

2. Rebinding no longer retargets an in-progress rename. DvkPanel::setKeyer() calls cancelRename() before the outgoing disconnect, as you suggested. The import and export continuations now capture the keyer before the file dialog's nested event loop. If the bound keyer changed while the dialog was open, nothing is transferred, and the status line says so. New in dvk_panel_test: rename_does_not_follow_a_rebind_to_the_other_keyer, which double-clicks the real name label, types, rebinds, then presses Return, and asserts that neither keyer receives a rename. Without the cancelRename() it fails. The dialog guard is from reading the code, without a widget test, since the test would need a modal QFileDialog.

Comments. The decoder, record-gate and reconnect comments now state the invariant; the incident history is in the commit messages and the PR description.

The disclosed decisions — please confirm here, so the ruling is on record in this PR:

  1. Scope: verified on FlexRadio, and available as Beta on the other transmit-capable radios (Hermes-Lite 2, Networked Icom), with a public call for testing after release. This is what you and I agreed outside this thread; the PR currently cites it without a link.
  2. The 30-second local slot cap — deliberately longer than Voice Recorder for calling CQ #3915's 10 s (too short for a contest exchange) and shorter than the 60 s an earlier revision used.
  3. The new answer seam: TransmitControlRequest::answerTicket, IRadioBackend::transmitControlAnswered() and answersTransmitControl(), added because a FLEX-6500 never echoes vox_enable in a status, so the hold can only be confirmed by the command reply.

Pushed as 94cd91d3. Full ctest passes (795/795), as do all the static checks, and the accessibility scan finds nothing on the changed panel. The PR description has these changes under Review of c162cf33, and the three decisions under What this is waiting on.

K5PTB and others added 5 commits October 17, 2026 09:12
…dr#4214) — Principle VI.

The radio's DVK keeps its recordings on the radio, behind the SmartSDR+
`digital_voice_keyer` licence, shared with every operator connected to it.
This adds a client-side keyer: twelve slots recorded and stored on the
operator's own computer, needing no licence. It implements phase 1 of
RFC aethersdr#4214 — local slots, PC-mic recording, and one panel with a Local/Radio
selector.

The UI is the existing DVK panel, unchanged. Right-click the DVK indicator, or
use Settings > Voice Keyer Source, to choose Local or Radio; the twelve slots,
F1-F12, click-to-send, rename, REC/STOP/PLAY/PREV and import/export all behave
as they always did. Parity is the point: an operator who knows the radio DVK
already knows this one. The choice is persisted, defaults from the radio's
licence, and Radio is greyed out with a reason when the radio cannot store
recordings.

DvkPanel drove DvkModel directly; it now drives a VoiceKeyer interface with two
implementations — DvkModel for the radio-hosted keyer, unchanged on the wire,
and LocalVoiceKeyer for this one. The interface also carries the per-keyer
facts the panel needs (canStartOperation, pendingOperation, maxRecordingMs,
maxNameBytes, defaultSlotName), which is what lets one progress bar serve a
10 s radio cap and a 60 s local cap without the panel knowing which keyer it
has.

The feature lives in the engine. core/VoiceKeyerTxRoute is the transmit route:
stream, DAX TX mode, the TxCoordinator request, PTT and the media context.
models/VoiceKeyerController owns the keyer, the route, the transmitter and the
preview player, and resolves which keyer is bound. models/ModeVocabulary.h
carries the voice/CW mode lists, split out of gui/VoiceModeGate.h so the engine
can ask whether a mode is voice without including anything from gui/. All three
compile into aethercore; MainWindow_DigitalModes.cpp loses 232 lines and no new
gui/ -> core/ touchpoint appears.

Not Flex-only. Every per-family difference is an answer read from
RadioCapabilities, never a family name: a radio that modulates on the host or
takes TX audio over the seam needs no stream, one with its own modulator input
gets a dax_tx stream first. The two gates that read hasVoiceKeyer — a Flex
licence fact — now read canTransmit, so HL2, Icom and the sim reach the local
keyer too. Verified on hardware on a FLEX-6500 only; the other families are
covered by unit tests against their route shapes.

Transmit authority is taken at the input boundary. Each transmission takes one
TxCoordinator producer request synchronously in start(), before any queued hop,
which is where the coordinator requires it; the order is refuse-or-proceed,
admit, wait, so a route that refuses its stream never takes a request it would
hand straight back. The media context is captured only once the key is
admitted, every audio block carries it, and finish() releases on the admission
rather than on whether the radio echoed keyed — so a stop, a timeout waiting
for PTT, a refused key and a disconnect each release exactly once. A key that
admits nothing unkeys rather than holding a silent carrier.

claimAudioPath() records the engine's DAX TX mode and the radio's transmit dax
before changing either, and releaseAudioPath() restores what it saw from that
same funnel, so the operator's TX audio state survives the failure paths.
means a dead mic on the next PTT.

Recording taps the audio AetherSDR already captures from the PC microphone
(AudioEngine::txFinalMonitorPcmReady, after the TX voice chain), so it never
keys the transmitter. VOX was the exception: VOX keys on the microphone and
recording is the operator speaking into it, so REC with VOX enabled put the
radio on the air. Refusing the recording fixes nothing — VOX keys on the voice
whether or not anything is being recorded — so REC holds VOX off for the
duration and restores it on the way out, and does not start until the radio
confirms. The confirmation is the command's reply: a FLEX-6500 on 4.2.20
answers `transmit set vox_enable=0` with R<seq>|0| in ~30 ms and then never
repeats vox_enable in a transmit status, though it echoes dax changes in the
same session. The keyer stays Idle and closed to a second start until the
answer arrives; an unconfirmed hold refuses and puts VOX back. The panel's
recording status reads "VOX held off", because a radio setting changed that the
operator did not change.

On a radio with its own selectable mic inputs the PC must be the chosen source
or the recording would be silence; on a radio whose only mic source is this
computer, the question is not asked.

Storage. Recordings live in Documents/AetherSDR/VoiceKeyer as 24 kHz stereo
int16, with labels in one nested JSON blob under the AppSettings key
"VoiceKeyer" (Principle V). Nothing is keyed to a radio, so the same slots
follow the operator from a local 6500 to a remote radio on the account.
Per-client therefore means per operating-system user account — a separate
username and password, with its own home directory; two people sharing one OS
account share one set of slots, and distinguishing operators inside one account
would need an operator-profile concept AetherSDR does not have.

Writes are atomic (QSaveFile) and imports are decoded with caps on size,
length and rate (100 MB, 600 s, 192 kHz, 8 channels), so a failed recording or
import cannot destroy a working slot. The 60 s cap is applied again at play and
preview, because a WAV the operator drops straight into the folder passed
neither the recording cap nor the import cap — without that, a ten-minute file
would transmit.

A source change made mid-operation never retargets it. refreshBinding() defers
the swap while the bound keyer cannot start an operation, so Stop acts on what
is in flight; the test is canStartOperation(), not status(), because a radio
start already sent and still waiting for its echo reads as Idle. The panel's
status line gains "<Local|Radio> keyer when this ends" as a suffix — the
elapsed timer rewrites that line every 100 ms, so a one-shot message would
flash and vanish — and the swap lands by itself when the operation ends.

One pre-existing bug fixed, because this change would have made it worse. Live
RX leaves the shared sink while audio plays back through it, and the flag that
did it was a single bool written by two producers. A keyer preview is a third,
so whichever finished first unmuted under the others. RxPlaybackMuteOwners
holds the mute while any owner wants it and reports only the edges, because the
caller drops a Qt connection on mute and Qt permits duplicates.

Repeat, quick-access CQ/Call chrome and TCI/CAT extensions are deliberately NOT
here: this change matches the on-radio DVK's feature set, and each of those is
a later phase of the RFC with its own conditions.

Tests, all registered in tests/tests.cmake and socket-free:
local_voice_keyer_test, voice_keyer_tx_route_test, voice_keyer_controller_test,
voice_keyer_record_gate_test, rx_playback_mute_owners_test,
generated_audio_transmitter_test, dvk_panel_test, voice_keyer_dvk_model_test,
voice_keyer_source_test, voice_keyer_wav_decoder_test and
dvk_availability_gate_test. Suite 683/684 — unified_title_bar_test fails on
main independently of this branch. All 18 static checks pass.

Verified on a FLEX-6500 into a dummy load: a keyer transmission measured on the
dax_tx wire (no missing packets, nothing after unkey, no clipping, key held
~273 ms past the audio), the VOX hold and restore with the radio's replies in
the log, a source change mid-playback that let the playback finish first, and a
65-second dropped-in WAV refused at both play and preview with no key-up. The
capture of a slot after all of this work is byte-identical by SHA-256 to the
capture of the same slot taken before any of it, so none of the restructuring
changed a sample on the wire.

The WAV boundary (VoiceKeyerWavDecoder and its test) is dk4dj's patch from
is milenjb's design from RFC aethersdr#4214. Neither his implementation nor any other
code of his is used here.

Co-Authored-By: dk4dj <101949905+dk4dj@users.noreply.github.com>
Co-Authored-By: milenjb <204617769+milenjb@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aethersdr#6199 seam, STOP cancels a pending REC, DVK alive after reconnect, 30 s local slots, Beta on non-Flex radios. Principle VI.

Robbie's review (CHANGES_REQUESTED) and Nigel's flex-sim review of
1275479, after rebasing onto upstream/main 34363a3.

VOX hold, ported onto the typed control seam (aethersdr#6199)

removed the reply path the hold used: the hold now has to hear the
radio's answer through the seam. TransmitControlRequest gains an
answerTicket; FlexBackend sends a ticketed request through a reply-aware
sink (the same sendCmdPublic() funnel as its plain sink, so the
command-plane ratchet is unchanged) and emits transmitControlAnswered().
A backend that cannot hear the reply is answered by RadioModel as not
confirmed, never as applied.

- The hold is released only by the answer to its own ticket. An
  operator's VOX click, or the late answer to a hold STOP abandoned,
  can no longer start a recording.
- A restore still in flight counts as VOX on, so a REC started before
  it is answered holds VOX again instead of reading the model's stale
  "off".
- VOX changed while the hold waits fails the hold and is not undone;
  VOX turned on during a recording stops it unsaved; VOX changed during
  a recording is not overwritten by the restore.
- requestVoxEnable() makes no optimistic write and applies an answer
  only while it is still the latest VOX intent.

STOP cancels a REC waiting on its hold

LocalVoiceKeyer reports the waiting recording through
pendingOperation(), so the panel's STOP reaches it, and recStop()
abandons the wait and runs the release.

DVK dead after a reconnect (Nigel, finding 1)

Not the binding: a reconnect reclaims the previous session's slice
models, and the reclaimed TX slice carries tx=1 over, so no edge
re-ran updateKeyerAvailability() after the licence pass had found no TX
slice. It now also runs on slotOccupancyChanged, as aethersdr#5750 does for the
span control. Reproduced 3/3 against flex-sim before, licensed and
unlicensed alike.

Local slot cap: 30 s (was 60 s)

A deliberate choice, not the RFC's: RFC aethersdr#4214 asks only for "the
reporter's requested short-duration cap", and aethersdr#3915 asked for 10 s, the
radio DVK's own limit. 10 s is too short for a contest exchange or a
longer CQ; 60 s turned a slot into a recording rather than a message.
LocalVoiceKeyerStore::kMaxDurationMs is the one constant for recording,
import, play and preview; its comment, the user docs and the PR body
state 30 s, and local_voice_keyer_test static_asserts the documented
value so they cannot drift apart.

Smaller

- REC's accessible description names the bound keyer's own limit
  (10 s radio, 30 s local).
- The panel title reads "(Local · Beta)" on a radio with no voice keyer
  of its own: verified on FlexRadio, Beta elsewhere (Jeremy's direction).
- docs/user/docs/dvk-panel.md covers the Local keyer, the source menu,
  the VOX hold, the recordings folder (including OneDrive) and the
  Beta status. Log Analyzer citations and the seam probe table
  regenerated.

Disconnect mid-playback (Robbie 3, Nigel 2) is not changed: the
coordinator's reset already unkeys the active operation, the keyer's
included, exactly as it does the operator's own MOX; on LAN that queued
xmit 0 is then cancelled by the same reset's fence for every source.
tx_operation_integration_test now pins that the keyer's playback is
stopped like MOX and leaves the same wire.

Tests: voice_keyer_radio_test (new, 6 cases), plus cases in
local_voice_keyer_test, dvk_panel_test, model_control_routing_test,
voice_keyer_record_gate_test and tx_operation_integration_test. Each new
case fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e radio before it starts; the guide states Radio and Local import limits apart. Principle VI.

@rfoust's re-review of 7e6b991: TransmitModel::setVoxEnable(false) writes
the model optimistically and sends an unticketed request, so a REC
pressed before the radio answered read VOX as off and recorded without a
confirmed hold — and a late or refused VOX-off left VOX live during the
recording.

TransmitModel now tracks whether VOX is confirmed (voxStateConfirmed()):
an operator's VOX click leaves it unconfirmed; a status that reports VOX,
or the answer to a ticketed request, confirms it. Recording chooses one of
three courses (voxHoldForRecording()):

- VOX on, or a restore in flight: hold it off and give it back, as before.
- Model off but unconfirmed: send a ticketed vox_enable=0 and record only
  on the radio's answer. Nothing is given back afterwards — off was asked
  for. A refused answer refuses the recording.
- Off and confirmed, or a radio with no VOX AetherSDR controls
  (voxControl unset, the Hermes-Lite 2): record at once.

docs/user/docs/dvk-panel.md: the import limits are a Radio/Local table
(8-384 kHz vs 8-192 kHz, 32-bit float vs 32/64-bit float, 10 s vs 30 s,
Local's 100 MB file cap); the VOX section covers the confirm-first case;
"indicator grayed out" covers Radio DVK selected on a radio that has none.

Tests: voice_keyer_radio_test gains the reviewer's sequence, a refused
VOX-off, a radio-reported VOX-off (no round trip) and a radio without VOX;
voice_keyer_record_gate_test covers the three-way decision. Each fails with
its guard reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed, so a crafted chunk size cannot stall the GUI thread. Principle VII.

Found by the automated review of 7e6b991. VoiceKeyerWavDecoder (carried
from the aethersdr#957 patch) computed the chunk stride as `8 + chunkSize` with a
quint32 size, so the sum wrapped mod 2^32 before widening: a declared
size near UINT32_MAX became a 2-byte step, which the guard turned into 9,
and the walk crept through the file reading audio as chunk headers. On a
file dropped into the recordings folder that runs on the GUI thread, in
every reload() and playbackStart().

- The stride is taken in 64 bits, so such a chunk ends the walk.
- The guard is gone: its comment said a zero-size chunk would loop
  forever, but the stride is at least 8 — and it stepped a legal
  zero-size chunk 9 bytes, misaligning every chunk after it.
- The walk stops once fmt and data are both found, and gives up past
  kMaxChunks (256): a real WAV has a handful.

voice_keyer_wav_decoder_test adds three cases, each decided by what the
decoder accepts: a wrapping size with a valid-looking fmt/data pair
exactly where the old 9-byte stride landed (accepted before, refused
now), a zero-size chunk before fmt (refused before, decoded now), and a
file of 306 chunks (refused). The old stride fails all three; removing
only the cap fails the third.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot carry a rename or a file transfer to the other keyer. Principle VI.

@ten9876's review of c162cf3 found two operation-lifecycle defects.

1. VOX-on did not stop a recording that began with VOX already off. The
   watch was gated on m_voxHeldOff, so a recording admitted from a
   confirmed VOX-off — no hold taken — kept running when the operator
   turned VOX on, although the user guide promises it ends. The watch now
   covers every active local recording: VOX turning on abandons it,
   unsaved, and leaves VOX as the operator set it. A radio with no VOX
   AetherSDR controls (voxControl unset, the Hermes-Lite 2) is not
   watched, since its flag cannot key it. Renamed to
   stopRecordingIfVoxOn().

2. DvkPanel::setKeyer() swapped m_model under an open inline rename, and
   commitRename() then wrote through the new keyer — a local rename could
   land on the radio's shared slot. setKeyer() now cancels the rename
   before the swap. The import/export continuations, which run after the
   file dialog's nested event loop, keep the keyer they were opened on and
   drop the transfer, with a status-line notice, if it changed.

Comments in VoiceKeyerWavDecoder, VoiceKeyerRecordGate and the reconnect
wiring now state the invariant rather than the review history (AGENTS.md
Comments rule).

Tests: voice_keyer_radio_test adds the initially-off -> REC -> VOX-on case
and checks a VOX click does not end an HL2 recording; dvk_panel_test edits
a rename on the real widget, rebinds and submits, and asserts neither
keyer is renamed. Each fails with its fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jensenpat
jensenpat force-pushed the feat/client-voice-keyer-prototype branch from 94cd91d to 263acb7 Compare October 17, 2026 16:14
Capture the keyer before entering the slot context menu event loop and refuse stale rename, clear, import and export actions. Keep the existing file-dialog guard for changes during the chooser. Document cancellation when the keyer changes.

The socket-free widget regression fails before the guard with other=[clear 1] after rebinding, and passes with neither keyer receiving the stale action.

@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 d047257f5c9eed2aefa1c88c5e9d4a74588f7ea6, rebased onto main 94e3cd47de1d4a94a0eacb7b803c064afe188675.

The phase-one implementation fits RFC #4214. I found and repaired one additional operation-owner defect. No further correctness blocker survived the source audit or focused checks. Merge remains held for the outstanding 30-second cap and public acknowledgement-API decisions recorded below.

File/group Change Claimed? Scope verdict
VoiceKeyer, LocalVoiceKeyer, DvkModel, store/settings/source Twelve per-user local slots, atomic replacement, shared keyer interface and persisted source Yes RFC phase 1
WAV decoder and local player Bounded imports/conversion, local preview Yes In scope
Generated transmitter, TX route and DAX policy Producer-owned playback, stream/key/audio/drain/release lifecycle Yes In scope
Controller, record gate and TransmitModel Deferred source changes and ticketed VOX hold/restore Yes In scope
Backend control requests, Flex reply sink and RadioModel Ticketed setting acknowledgement through the backend boundary Yes Disclosed public API decision remains pending
DVK panel and MainWindow wiring One existing panel and F-key dispatcher, source menu, reconnect availability, Beta qualifier Yes RFC phase 1; Beta support accepted by the operator during this landing pass
RX mute owners Preserve overlapping local preview producers' mute ownership Yes Necessary integration
Build/test registration, touchpoint/seam metadata, checker baseline Compile and classify the feature, register socket-free tests, remove obsolete vendor include allowance Yes In scope; no ratchet growth
User guide and LogAnalyzer references Document both sources and refresh source locations Yes In scope

Finding repaired in this pass

src/gui/DvkPanel.cpp:788 (showContextMenu()) captured the file-transfer owner only after an action was chosen, while Rename/Clear used the current m_model. The menu itself runs a nested event loop. A source rebind while that menu was open could therefore clear the incoming keyer's recording. The socket-free real-widget probe in tests/dvk_panel_test.cpp:339 failed on the pre-fix source:

[FAIL] slot_menu_does_not_follow_a_rebind_to_the_other_keyer triggered=1 first=[] other=[clear 1]

d047257f5 captures the owner before opening the menu and rejects stale Rename/Clear/Import/Export actions, preserving the existing second guard for changes while a file chooser is open. The same probe now passes with first=[] other=[]. The user guide describes cancellation.

Earlier review findings

  • Correlated VOX hold/restore, stale/unrelated replies, operator changes, and immediate REC after restore: covered by voice_keyer_radio_test and model_control_routing_test.
  • An unanswered optimistic operator VOX-off cannot admit REC: rec_after_an_unanswered_operator_vox_off_confirms_it_first passes, as does refusal of a rejected hold.
  • STOP cancels pending REC: stop_cancels_a_recording_waiting_on_its_hold passes.
  • VOX-on stops a recording admitted from confirmed-off without a hold: vox_on_stops_a_recording_that_needed_no_hold passes.
  • Rename cannot follow a panel rebind: rename_does_not_follow_a_rebind_to_the_other_keyer passes.
  • WAV walking handles near-maximum strides, zero-sized chunks and the chunk-count bound: voice_keyer_wav_decoder_test passes.
  • Disconnect is checked through the real fenced Flex writer: keyer and MOX both reach backend stop and leave the same terminal wire. This refutes a keyer-specific regression; it does not prove a physical radio unkeyed after transport loss.
  • Current main is integrated, including its Controllers submenu, radio-nickname API and peripheral tags. User documentation distinguishes Radio and Local import limits. REC's accessible description follows the bound keyer's cap.

Verification

  • ARM64 Qt 6.12 macOS app and 16 focused targets built using -j22; RADE enabled, ASR disabled, DFNR/keychain absent. No RNNoise x86 sources in the ARM graph. The sandbox iconutil failure cleared when the intended build ran outside the sandbox.
  • QT_QPA_PLATFORM=offscreen ctest --test-dir build -j22 --no-tests=error ...: 16/16 passed on the final changes. Includes store, decoder, panel, controller, VOX/backend routing, generated-audio lifecycle, real TX-route ownership, disconnect integration and RX mute ownership. The added stale-menu regression failed before the guard and passed afterward.
  • Strict engine-boundary checks: main 102 findings -> head 101, no added per-file findings. Capability booleans remain 70; command-plane usage remains 296. Touchpoint manifest, generated seam probe table, test registration, generated user references and 707 LogAnalyzer checks pass. Accessibility scan of the changed panel reports zero findings. git diff --check passes.
  • Isolated offscreen bridge instance at build SHA d047257f5, clean tree, explicit /tmp/aethersdr-pr6292-final.sock, isolated settings, txAllowed:false. Connected only to DEMO-0001, model AetherSDR Demo, one slice, one pan, transmitting:false; disconnected and closed the exact owned instance with exit 0. Local panel title, 30-second REC description, Voice Keyer Source action and preserved Controllers submenu were read from the running app. Source preference was also read after process restart.
  • The demo is RX-only and cannot establish positive keyer TX, physical VOX behavior, or HL2/Icom convergence. Author/reviewer hardware and flex-sim results remain their evidence; I did not reproduce them. Full suite, sanitizer execution and native visual inspection were not run. The dvk-panel documentation screenshot was not refreshed. The automated code-review skill was unavailable; this was a manual audit.
  • Current-head hosted Docs, Static checks and sanitizer-configuration checks pass. Linux, macOS and Windows build jobs are still running; their outcomes are not claimed here.

Decisions still required

The operator accepted HL2/Icom Beta availability in this session. The author's final comment and latest maintainer review still ask for explicit confirmation of the 30-second Local slot cap (versus the RFC's referenced 10 seconds) and answerTicket / transmitControlAnswered() / answersTransmitControl() public backend API. Those decisions remain pending in this session; they are not additional correctness findings. I have not approved or armed merge over them.

Recommendation: Needs maintainer decision. The code and focused validation are ready at this exact head. Confirm the two disclosed choices, complete current-head CI, then retire the addressed change requests and perform a guarded squash merge.

@jensenpat jensenpat assigned jensenpat and unassigned jensenpat Oct 17, 2026
@jensenpat
jensenpat dismissed rfoust’s stale review October 17, 2026 16:46

Fixed in the reviewed VOX confirmation repair, now integrated at 414edca: TransmitModel tracks whether its VOX value is confirmed, and the controller requires its own ticketed VOX-off answer after an unanswered operator-off request. voice_keyer_radio_test::rec_after_an_unanswered_operator_vox_off_confirms_it_first and a_refused_vox_off_refuses_the_recording pass. The user guide distinguishes Local's 8–192 kHz decoder limit from Radio's 8–384 kHz limit.

The operator explicitly approved the disclosed Beta scope, 30-second Local cap and ticketed acknowledgement API. The final approving review records the 16 focused test results and isolated DEMO-0001 bridge evidence, without claiming physical VOX/TX proof.

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

Approved exact head 414edcab84547dc7ca19bc29cd86e8b9124f42f0. The branch was rebased, then integrated main c257486a5e89a8f16067df44c4dbb8ef3bceefab after its generated-manifest conflict. Main's separate SPE 1K-FA change is preserved; Local voice-keyer source is unchanged from the repaired d047257f5 tree.

The phase-one implementation fits RFC #4214. I found and repaired one additional operation-owner defect. No further correctness blocker survived the source audit or focused checks. The operator has explicitly approved Beta availability on HL2/Icom, the 30-second cap and the ticketed backend acknowledgement API. There are no remaining correctness or scope blockers; current-head CI is now green.

File/group Change Claimed? Scope verdict
VoiceKeyer, LocalVoiceKeyer, DvkModel, store/settings/source Twelve per-user local slots, atomic replacement, shared keyer interface and persisted source Yes RFC phase 1
WAV decoder and local player Bounded imports/conversion, local preview Yes In scope
Generated transmitter, TX route and DAX policy Producer-owned playback, stream/key/audio/drain/release lifecycle Yes In scope
Controller, record gate and TransmitModel Deferred source changes and ticketed VOX hold/restore Yes In scope
Backend control requests, Flex reply sink and RadioModel Ticketed setting acknowledgement through the backend boundary Yes Disclosed public API approved by the operator
DVK panel and MainWindow wiring One existing panel and F-key dispatcher, source menu, reconnect availability, Beta qualifier Yes RFC phase 1; Beta support accepted by the operator during this landing pass
RX mute owners Preserve overlapping local preview producers' mute ownership Yes Necessary integration
Build/test registration, touchpoint/seam metadata, checker baseline Compile and classify the feature, register socket-free tests, remove obsolete vendor include allowance Yes In scope; no ratchet growth
User guide and LogAnalyzer references Document both sources and refresh source locations Yes In scope

Finding repaired in this pass

src/gui/DvkPanel.cpp:788 (showContextMenu()) captured the file-transfer owner only after an action was chosen, while Rename/Clear used the current m_model. The menu itself runs a nested event loop. A source rebind while that menu was open could therefore clear the incoming keyer's recording. The socket-free real-widget probe in tests/dvk_panel_test.cpp:339 failed on the pre-fix source:

[FAIL] slot_menu_does_not_follow_a_rebind_to_the_other_keyer triggered=1 first=[] other=[clear 1]

d047257f5 captures the owner before opening the menu and rejects stale Rename/Clear/Import/Export actions, preserving the existing second guard for changes while a file chooser is open. The same probe now passes with first=[] other=[]. The user guide describes cancellation.

Earlier review findings

  • Correlated VOX hold/restore, stale/unrelated replies, operator changes, and immediate REC after restore: covered by voice_keyer_radio_test and model_control_routing_test.
  • An unanswered optimistic operator VOX-off cannot admit REC: rec_after_an_unanswered_operator_vox_off_confirms_it_first passes, as does refusal of a rejected hold.
  • STOP cancels pending REC: stop_cancels_a_recording_waiting_on_its_hold passes.
  • VOX-on stops a recording admitted from confirmed-off without a hold: vox_on_stops_a_recording_that_needed_no_hold passes.
  • Rename cannot follow a panel rebind: rename_does_not_follow_a_rebind_to_the_other_keyer passes.
  • WAV walking handles near-maximum strides, zero-sized chunks and the chunk-count bound: voice_keyer_wav_decoder_test passes.
  • Disconnect is checked through the real fenced Flex writer: keyer and MOX both reach backend stop and leave the same terminal wire. This refutes a keyer-specific regression; it does not prove a physical radio unkeyed after transport loss.
  • Current main is integrated, including its Controllers submenu, radio-nickname API and peripheral tags. User documentation distinguishes Radio and Local import limits. REC's accessible description follows the bound keyer's cap.

Verification

  • ARM64 Qt 6.12 macOS app and 16 focused targets built using -j22; RADE enabled, ASR disabled, DFNR/keychain absent. No RNNoise x86 sources in the ARM graph. The sandbox iconutil failure cleared when the intended build ran outside the sandbox.
  • QT_QPA_PLATFORM=offscreen ctest --test-dir build -j22 --no-tests=error ...: 16/16 passed on the final changes. Includes store, decoder, panel, controller, VOX/backend routing, generated-audio lifecycle, real TX-route ownership, disconnect integration and RX mute ownership. The added stale-menu regression failed before the guard and passed afterward.
  • Strict engine-boundary checks: main 102 findings -> head 101, no added per-file findings. Capability booleans remain 70; command-plane usage remains 296. Touchpoint manifest, generated seam probe table, test registration, generated user references and 707 LogAnalyzer checks pass. Accessibility scan of the changed panel reports zero findings. git diff --check passes.
  • Isolated offscreen bridge instance at build SHA 414edcab8, clean tree, explicit /tmp/aethersdr-pr6292-414edcab8.sock, isolated settings, txAllowed:false. Connected only to DEMO-0001, model AetherSDR Demo, one slice, one pan, transmitting:false; disconnected and closed the exact owned instance with exit 0. Local panel title, 30-second REC description, Voice Keyer Source action and preserved Controllers submenu were read from the running app. Source preference was also read after process restart.
  • The demo is RX-only and cannot establish positive keyer TX, physical VOX behavior, or HL2/Icom convergence. Author/reviewer hardware and flex-sim results remain their evidence; I did not reproduce them. Full suite, sanitizer execution and native visual inspection were not run. The dvk-panel documentation screenshot was not refreshed. The automated code-review skill was unavailable; this was a manual audit.
  • Current-head hosted Linux build, Windows, macOS, Docs, Static checks and sanitizer-configuration checks all passed. CI run: https://github.com/aethersdr/AetherSDR/actions/runs/39050687813 .

Operator decisions and recommendation

The operator explicitly approved HL2/Icom Beta availability, the 30-second Local slot cap, and the ticketed acknowledgement API in this session. These decisions are recorded in the PR body; physical HL2/Icom and RF/VOX convergence are not claimed.

All three earlier change-request reviews have been dismissed with per-review evidence. No unresolved review threads remain. Final-head local build, all 16 focused tests and the isolated demo checks pass. No new findings arose from integrating main; the only conflict was the regenerated manifest.

Recommendation: Approve. All hosted checks at this exact head have passed; perform the normal guarded squash merge with contributor attribution. No admin bypass is authorized or needed.

@jensenpat
jensenpat merged commit a655e42 into aethersdr:main Oct 17, 2026
7 checks passed
@K5PTB
K5PTB deleted the feat/client-voice-keyer-prototype branch October 20, 2026 19:04
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.

5 participants