Repository navigation
feat(dvk): client-side voice keyer with local recordings (RFC #4214) - #6292
Conversation
|
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 failedStatic checks → I reproduced it exactly on WhyEvery Fix — two steps, and the first alone isn't enough
"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
With both steps done, On the tags themselvesThe vocabulary the regex accepts is
One thing worth calling out in the regen
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 |
125cea8 to
1275479
Compare
|
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 What held up
Two things I think need a look1. The DVK indicator, and the F1–F12 set with it, comes up disabled after connecting to an unlicensed radio, even though Local is selected. 2. Disconnecting mid-playback leaves the radio keyed, with DAX already dropped.
Smaller items
Not verified
For anyone else reviewing: flex-sim v0.3.3 now models what this PR needs, including 73, |
rfoust
left a comment
There was a problem hiding this comment.
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
- 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.
- 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. - Unkey before disconnect resets the request.
disconnectFromRadio()callsresetTxOperations()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 reportsxmit 1, then DAX/stream teardown and close, withoutxmit 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. - Update
docs/user/docs/dvk-panel.mdin 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. - Reconcile current main. This head is unmergeable. A scratch merge of current
upstream/main(37fbdbc) and this head conflicted indocs/architecture/aetherd-touchpoint-tags.jsonanddocs/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.
…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>
1275479 to
7e6b991
Compare
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>
|
@rfoust @nigelfenton — thank you both. Your reviews found real problems, and the branch now answers them. It is rebased onto
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.
The Every new or changed test was checked to fail with its fix reverted. Full |
There was a problem hiding this comment.
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 declaringsampleRate = 192000passes the parser's 600 s check at ~520 s, so a 100 MBdatachunk expands to a ~400 MBmonoFloat32held alongside the 100 MBraw, then the resampled stereo and the int16 PCM — roughly 600 MB peak, allocated synchronously on the GUI thread, beforeimportSlotapplies the 30 s cap.playbackStart()has it right:playableRefusal()(header-only probe) runs first.importSlotcould do the same by callingprobeDurationMsbeforedecodeToMonoFloat, or by rejecting on decoded sample count. (security-audit (CodeGuard))src/models/LocalVoiceKeyer.h:62-64claims a parity invariant thatsetNamedoes 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." ButLocalVoiceKeyer::setName(src/models/LocalVoiceKeyer.cpp:343-349) only calls.trimmed(), whileDvkModel::sanitizeNamestrips quotes and|and trims to 61 bytes.DvkPanel.cpp'ssetMaxLength(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 insetNameor soften the comment. (security-audit (CodeGuard))- The body's engine/UI-split line is wrong. It says "
MainWindow_DigitalModes.cpploses 232 lines and no newgui/→core/touchpoint appears."git diff --numstat 34363a381..HEADgives130 0 src/gui/MainWindow_DigitalModes.cpp— the file gains 130 lines and deletes none; 123 of them are the newwireLocalVoiceKeyer()and source menu. The touchpoint half of the sentence does check out (models/DvkModel.hleaves the manifest,core/DvkWavTransfer.h2→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
Radiochoice strands the keyer on a radio with no DVK.resolveVoiceKeyerSource(src/models/VoiceKeyerSource.h:43) honours an explicit Radio setting unconditionally, so on an HL2voiceKeyerUsable(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/exportWavgate onm_status != Idlerather thancanStartOperation()(src/models/LocalVoiceKeyer.cpp:355,:367). That is the precise distinctionbusyRefusal()'s own comment at:134-136insists on: a recording waiting on its VOX hold readsIdle. So an Import during that window overwrites the slot the pending recording is about to write. Harmless in practice, andexportWavhas no guard at all; worth making the three consistent.src/models/VoiceKeyerController.cpp:195-196describes 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 onphoneStateChanged, but the PR's own protocol finding is that the radio never mentionsvox_enablein 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 theanswerTicketfallback, as do the!isConnected() || m_txSessionClosingguards at:10029. A ticketed request refused there is answered only by the controller's 3 s backstop. Fail-closed and harmless forVoxSetting(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) checksticket == m_restoreTicketbeforeticket != m_holdTicketand then requiresapplied && current && !on— so an answer to VOX on cannot reachonRecordHoldReady(), which was the defect.restoreVox()additionally fences onvoxIntentMark(), captured after its own request, so an operator change since the hold wins.TransmitModel::requestVoxEnablemakes no optimistic write, which is what letsshouldHoldVoxOff(..., 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) clearsm_awaitingHoldSlotbefore them_status != Recordingreturn, emitsadmissionChanged, and runs the release;onRecordHoldReady()then returns onm_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 isstop_cancels_a_recording_waiting_on_its_holdattests/local_voice_keyer_test.cpp:561. Fixed. - The
TxCoordinatoradmission leaking on the pre-keyOn()exits — a stop or a stream timeout duringWaitingForStream/Settling, whereVoiceKeyerTxRoute::keyOff()skipsrequestProducerPttOffbecausem_pttRequestedis false. I expected a dropped handle and a second PLAY refused. It is not:TxCoordinator::Requestis ashared_ptr<RequestState>andRequestState::~RequestState()decrementsidentity->requests, som_request = {}releases the admission by refcount. Not a finding. - Re-entrancy through our own unkey.
finish()setsm_phase = Idlebefore callingkeyOff(), andonKeyReleased()is gated onm_phase != Phase::Idle, so themoxChanged(false)our own release provokes cannot recurse into a secondfinish(). ThekeyOn()→permitsDispatchfailure path, which unkeys synchronously, is caught by them_phase != Phase::WaitingForPttcheck afterkeyOn()returns. - The 30 s cap bypass the body says it closed — a long WAV dropped into the folder. I checked whether
probeDurationMsanddecodeToMonoFloatcould disagree, which would let a lying header pass the cap and then transmit the real length. They cannot: both go throughparseWav, anddataBytes = 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 attests/local_voice_keyer_test.cpp:407. - The WAV parser's read bounds, separately from the stride defect: the
fmtbody is read into a 40-byte buffer withwant = min(chunkSize, sizeof(fbuf)), the0xFFFEunwrap is gated onchunkSize < 40before touchingfbuf + 24,frameBytescannot be zero becausebitsPerSampleis 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::setKeyerdoesdisconnect(m_model, nullptr, this, nullptr)before rebinding, and everyconnectKeyer()lambda takesthisas context, so none survive — which matters becausemainrequires exactly this discipline (the same duplicate-connection hazardRxPlaybackMuteOwnersexists to fix). - Thread boundaries.
txFinalMonitorPcmReadyis emitted on the audio thread andonMicPcmis reached by a cross-thread (queued) connection, gated onsource == TxAudioSource::Microphoneso another producer's audio cannot land in a slot.sendAudio/finishAudiomarshal onto the engine withQPointerguards. The backend answer is explicitlyQt::QueuedConnectionso 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 rememberingctestinci.ymlis-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-BACKTICKhits onIRadioBackend.h,FlexBackend.h,DvkAvailabilityGate.hand the tags JSON (lexical matches on backticked prose in comments), theCMDI-PIPE/SEMI/AMPhits inDvkPanel.cpp(the|/;/&of the rename sanitizer and status strings), thePATH-UNC/PATH-PROChits (slotPathformats anintasslot-%02d.wav, so no separator or..can appear for any id), and the PII/SSRF/password hits inrules.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:
- 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.
- Blocker 3 — the new
IRadioBackendanswer surface is a seam addition that deserves its own yes. - 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
|
Cisco CodeGuard — static analysis of this PR (331 finding(s))
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
left a comment
There was a problem hiding this comment.
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.
|
@rfoust — thank you, that ordering is real, and your repro was exact. The blocker.
Your sequence is now 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 ( Both fixes are pushed ( |
ten9876
left a comment
There was a problem hiding this comment.
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
-
[P2] VOX-on does not stop a recording that began with VOX already off.
src/models/VoiceKeyerController.cpp:210checks!m_voxHeldOffbefore 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_recordingsThe 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. -
[P2] Rebinding the panel retargets an in-progress rename.
src/gui/DvkPanel.cpp:740swapsm_modelwithout ending or pinning the inline editor;src/gui/DvkPanel.cpp:875commits through the new pointer. In the real Qt widget, I double-clicked slot 1, entered a name on the first keyer, called the productionsetKeyer()on the second keyer and submitted the editor:PROBE rename: first= other=setName 1 Started on first [FAIL] review_rename_does_not_retarget_after_rebindAutomatic 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 currentm_modelafter 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:109andsrc/core/VoiceKeyerWavDecoder.h:22narrate 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-reviewskill was available; the manual/parallel audit results were independently checked.
Empirical verification
- Linux Qt 6.12 lean shared-core build of
AetherSDRand 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_holdtest 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-reviewagainst DEMO-0001 only, withtxAllowed:false.connect wait/get radioreturnedmodel:"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's2026-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.
|
@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 ( 2. Rebinding no longer retargets an in-progress rename. 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:
Pushed as |
…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>
94cd91d to
263acb7
Compare
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
left a comment
There was a problem hiding this comment.
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_testandmodel_control_routing_test. - An unanswered optimistic operator VOX-off cannot admit REC:
rec_after_an_unanswered_operator_vox_off_confirms_it_firstpasses, as does refusal of a rejected hold. - STOP cancels pending REC:
stop_cancels_a_recording_waiting_on_its_holdpasses. - VOX-on stops a recording admitted from confirmed-off without a hold:
vox_on_stops_a_recording_that_needed_no_holdpasses. - Rename cannot follow a panel rebind:
rename_does_not_follow_a_rebind_to_the_other_keyerpasses. - WAV walking handles near-maximum strides, zero-sized chunks and the chunk-count bound:
voice_keyer_wav_decoder_testpasses. - 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 sandboxiconutilfailure 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 --checkpasses. - Isolated offscreen bridge instance at build SHA
d047257f5, clean tree, explicit/tmp/aethersdr-pr6292-final.sock, isolated settings,txAllowed:false. Connected only toDEMO-0001, modelAetherSDR 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-paneldocumentation screenshot was not refreshed. The automatedcode-reviewskill 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.
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.
There was a problem hiding this comment.
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_testandmodel_control_routing_test. - An unanswered optimistic operator VOX-off cannot admit REC:
rec_after_an_unanswered_operator_vox_off_confirms_it_firstpasses, as does refusal of a rejected hold. - STOP cancels pending REC:
stop_cancels_a_recording_waiting_on_its_holdpasses. - VOX-on stops a recording admitted from confirmed-off without a hold:
vox_on_stops_a_recording_that_needed_no_holdpasses. - Rename cannot follow a panel rebind:
rename_does_not_follow_a_rebind_to_the_other_keyerpasses. - WAV walking handles near-maximum strides, zero-sized chunks and the chunk-count bound:
voice_keyer_wav_decoder_testpasses. - 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 sandboxiconutilfailure 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 --checkpasses. - Isolated offscreen bridge instance at build SHA
414edcab8, clean tree, explicit/tmp/aethersdr-pr6292-414edcab8.sock, isolated settings,txAllowed:false. Connected only toDEMO-0001, modelAetherSDR 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-paneldocumentation screenshot was not refreshed. The automatedcode-reviewskill 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.
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
VoiceKeyerinterface. 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
TxCoordinatorproducer 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.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
d047257f5with 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.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.DEMO-0001, withtxAllowed:false, one slice and one pan, andtransmitting:false; it disconnected and exited cleanly. Local title/REC description and the source menu were verified. The demo is RX-only.414edcab8preserves 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.dvk-paneldocumentation 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)