feat(kiwi): route KiwiSDR audio to the slice's DAX channel for WSJT-X/digimodes#4026
feat(kiwi): route KiwiSDR audio to the slice's DAX channel for WSJT-X/digimodes#4026skerker wants to merge 7 commits into
Conversation
ten9876
left a comment
There was a problem hiding this comment.
Reviewed as a draft POC — direction guidance, not a merge gate, matching how you framed it. First: this is how a POC should be done. Claiming the work per CONTRIBUTING, scoping RX-only with TX explicitly deferred to #4002, splitting the evidence into what the bridge can prove versus what WSJT-X proves, and a real trans-Pacific FT8 QSO through KPH as the decisive test — the evidence discipline here is exactly what this project runs on.
What the deep review verified clean (multi-agent, line-cited — the scary parts first):
- The cross-thread mask follows the AGENTS.md atomics convention correctly: GUI-thread
store, network-thread lock-free relaxedload, no mutex near the hot path. - All three
daxAudioReadyconsumers (TciServer, DaxBridge, RADE) serialize correctly under dual-thread emission. - The suppression gate sits after seq/stats bookkeeping, so DAX stream-health accounting is unaffected.
- Your load-bearing format claim is true: we verified both Flex PCC paths (full and reduced-BW) normalize to 24 kHz stereo float32 before
daxAudioReady, matching the Kiwi side's resample. (Please pin that reasoning + fw 4.2.18 in theinjectDaxAudiocomment per AGENTS.md — the claim deserves its evidence trail.)
The one structural gap — the suppression mask has no lifecycle. Every real defect we found is the same root: bits get set while Kiwi audio flows, and cleared almost nowhere. Inline comments carry the specifics; the shape:
- WSJT-X exit orphans the bit: TCI's
releaseDaxForTcisets the slice'sdax=0, androuteKiwiSdrAudioToDaxearly-returns before its refresh — the stale bit then silently kills Flex DAX for whichever slice next inherits that channel (TCI's lowest-free-channel scan makes reuse near-certain). - Slice removal never clears it: the refresh calls are gated inside
if (SliceModel* slice = …), but RadioModel removes the slice fromm_slicesbefore emittingsliceRemoved, so the lookup nulls and the refresh is skipped. - It leaks across radio sessions:
PanadapterStreamlives for the app lifetime and the mask isn't reset inclearRegisteredStreams()— the class's documented disconnect-time reset for every other per-stream table. One line fixes that one. - Suppression outlives the feed: it's keyed to
externalReceiveReplacementActive, not 'Kiwi audio actually flowing' — a Kiwi stall/reconnect mid-session leaves the channel with neither source, and every consumer is push-only, so WSJT-X's input freezes with no indication which side failed.
The good news: the fix is small and makes the design cleaner at the same time. SliceModel::daxChannelChanged already exists (the DAX bridge tracks exactly this transition at MainWindow_DigitalModes.cpp:1101) — wiring the refresh to it plus the kiwi lifecycle signals replaces the per-chunk polling entirely, closes 1 and 2, kills the per-chunk slice-loop cost, and removes the stall-window staleness in one move. Add the clearRegisteredStreams line for 3, and a flowing-watchdog (or silence-fill while suppressed-but-stalled) for 4.
Two smaller design notes (inline): injectDaxAudio currently emits from the GUI thread where every existing emission is network-thread — AutoConnection consumers now run their resampler/socket work synchronously inline (not a race, but a latency/re-entrancy contract change worth forcing back to queued); and the kiwi-named mask in the core VITA-49 parser is a layering leak — injectDaxAudio is already source-generic, the mask should be too (setExternalDaxSourceMask), which future-proofs the next external source for free.
Also noted for the non-draft version: brief dual-feed windows on engage and during the radio's dax=0→N rebroadcast (latching suppression on the profile assignment, mirroring TciServer's m_channelTrx approach, removes both), one redundant guard + three divergent 'valid channel' bounds worth collapsing into one predicate, braces on the new single-line guards, and the Principle <N>. commit-subject citation.
From the engineering side this direction looks viable — the mask-lifecycle work is the substance of turning the POC into the real thing. #3894 remains the deciding venue for the overall shape, and this lands useful evidence there. 73 — reviewed with Claude Code on behalf of KK7GWY.
Review follow-ups from aethersdr#4026 (all inline comments + review summary): - Mask lifecycle is now event-driven: refreshKiwiSdrDaxSuppression() is wired to SliceModel::daxChannelChanged (mirroring wireDaxSlice, aethersdr#2895), RadioModel::sliceRemoved, and the Kiwi assign/clear paths, replacing the per-chunk refresh that orphaned the mask bit when TCI released the channel (dax=0 on WSJT-X exit) or a slice was removed. - Teardown refreshes moved outside the slice lookup: RadioModel drops the slice before emitting sliceRemoved, so the refresh must not depend on finding it. - clearRegisteredStreams() resets the mask — suppression no longer leaks across radio sessions. - Stall watchdog: while a channel is suppressed but the Kiwi feed has stalled (initial connect window, stall, reconnect backoff), feed silence at the native DAX format so WSJT-X's input keeps its clock instead of freezing with no indication which side failed. - injectDaxAudio() re-invokes onto the stream's network thread so AutoConnection consumers keep queued delivery, identical to the Flex path (no synchronous resampler/socket work in the caller). - Renamed the mask API: setExternalDaxSourceMask / m_externalDaxSourceMask — PanadapterStream stays ignorant of which feature feeds it, matching the generic injectDaxAudio. - One shared isValidDaxChannel() predicate replaces three divergent channel bounds; dropped the redundant channel >= 0 guard; braces on single-line guards; the format comment pins its evidence (PCC_IF_NARROW + PCC_IF_NARROW_REDUCED both normalize to 24 kHz stereo float32, fw 4.2.18). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thank you @ten9876 for the draft PR review. All review feedback is now implemented and pushed (rebased, so the branch was force-updated). Point-by-point against your comments:
Also rebased onto current Verification status: builds clean (macOS; Linux/GCC next), but the new lifecycle behavior is not yet radio-tested. Next session on the FLEX-8400 I'll run the three scenarios the review identified: WSJT-X exit → a different slice inherits the DAX channel, Kiwi stall/reconnect mid-decode, and radio disconnect/reconnect. On scope: I've read the #4014 ruling and I agree with the safety principle — no TX from a pan that isn't displaying the local RF environment. This PR is deliberately the RX/decode half only, and it stands on its own under that ruling: decode a quiet remote ear while operating from a local-RF pan (the supported two-pan workflow). Since it routes at the slice-audio level rather than the pan display, it should also compose unchanged with whatever sanctioned TX approach comes out of #4002. Keeping it draft pending #3894's direction on the overall shape. Thank you! Jeff (KM6LFY) |
|
Verification report for the review-fix commit ( The three scenarios promised in the review response:
Additionally verified: slice removal mid-suppression frees the removed slice's channel Two-pan workflow regression check (re: the #4014 ruling): with Pan A/slice A on a Kiwi One observation for the open Full per-phase |
…/digimodes When a KiwiSDR replaces a slice's receive source, its decoded audio only reached the speaker/presentation path — the DAX/TCI stream (what WSJT-X and other digimode apps consume) stayed hard-wired to the Flex, which the overlay mutes. Result: WSJT-X decoded silence/the muted Flex, never the Kiwi. Add the missing audio->DAX route: - PanadapterStream::injectDaxAudio(channel, pcm) emits daxAudioReady() for non-Flex audio using the same signal consumers already subscribe to (TciServer, DAX bridge need no changes). Kiwi PCM is already the native DAX format (24 kHz stereo float32), so it is a passthrough. - PanadapterStream::setKiwiSuppressedDaxMask() + a gate in the Flex DAX emit path drop the Flex payload on Kiwi-owned channels, so WSJT-X hears only the Kiwi. Read lock-free on the RX thread. - MainWindow::routeKiwiSdrAudioToDax() maps a Kiwi profile -> its slice -> daxChannel and injects; refreshKiwiSdrDaxSuppression() keeps the suppress mask current (handles TCI's lazy audio_start channel allocation, and clears on detach). Wired off KiwiSdrManager::decodedAudioReady. Verified live on a FLEX-8400 + Point Reyes KPH (40m/7.074) + WSJT-X over TCI: WSJT-X now decodes KPH (quieter remote RX) instead of the local ANT1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review follow-ups from aethersdr#4026 (all inline comments + review summary): - Mask lifecycle is now event-driven: refreshKiwiSdrDaxSuppression() is wired to SliceModel::daxChannelChanged (mirroring wireDaxSlice, aethersdr#2895), RadioModel::sliceRemoved, and the Kiwi assign/clear paths, replacing the per-chunk refresh that orphaned the mask bit when TCI released the channel (dax=0 on WSJT-X exit) or a slice was removed. - Teardown refreshes moved outside the slice lookup: RadioModel drops the slice before emitting sliceRemoved, so the refresh must not depend on finding it. - clearRegisteredStreams() resets the mask — suppression no longer leaks across radio sessions. - Stall watchdog: while a channel is suppressed but the Kiwi feed has stalled (initial connect window, stall, reconnect backoff), feed silence at the native DAX format so WSJT-X's input keeps its clock instead of freezing with no indication which side failed. - injectDaxAudio() re-invokes onto the stream's network thread so AutoConnection consumers keep queued delivery, identical to the Flex path (no synchronous resampler/socket work in the caller). - Renamed the mask API: setExternalDaxSourceMask / m_externalDaxSourceMask — PanadapterStream stays ignorant of which feature feeds it, matching the generic injectDaxAudio. - One shared isValidDaxChannel() predicate replaces three divergent channel bounds; dropped the redundant channel >= 0 guard; braces on single-line guards; the format comment pins its evidence (PCC_IF_NARROW + PCC_IF_NARROW_REDUCED both normalize to 24 kHz stereo float32, fw 4.2.18). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Rebased onto current 73 — Jeff (KM6LFY) |
|
Hi @skerker — thanks for putting this together, and for the unusually thorough evidence split (bridge for ingestion, WSJT-X for the last hop). The routing approach reads cleanly. Quick heads-up on the red check so it's not a mystery. What actually failedIt's not one of the three compile checks — Root cause — one lineIn #include "core/PanadapterStream.h"
"src/gui/MainWindow_KiwiSdr.cpp": ["KiwiSdrClient", "KiwiSdrManager", "KiwiSdrProtocol"],
That's the whole failure. Your core-side edits ( How to fix (please don't just add the stem to the baseline)The checker's own header says it outright — "NEVER add a stem or a row to make a build pass" — the row is a ratchet that's supposed to drain to zero (see the RFC referenced in The minimal ratchet-compatible move: forward through
That keeps the vendor dependency in a file that's already an approved includer and leaves the boundary green. You can confirm locally before pushing with: (The RFC-pure endgame is a real Re: @ten9876's review notesThose are a separate track — lifecycle/threading correctness (the release-before-return ordering, the 🤖 aethersdr-agent · cost: $6.8445 · model: claude-opus-4-8 |
The rebase onto current main surfaced a new EB3 finding: MainWindow_KiwiSdr.cpp (above the radio seam) reached the vendor PanadapterStream directly — static isValidDaxChannel() plus panStream()->injectDaxAudio()/setExternalDaxSourceMask() — which the engine-boundary ratchet blocks (no new above-seam vendor coupling). Route the helpers through RadioModel, which already owns the stream and is a sanctioned includer: add thin isValidDaxChannel/injectDaxAudio/ setExternalDaxSourceMask forwarders plus a hasPanStream() bool guard, call them from the GUI, and drop the direct include. Pure delegation — behavior identical (hasPanStream() preserves the stall-tick early-return exactly). The GUI file's EB3 vendor set is now empty; --strict passes, full build + suite (77/77) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Quick update after marking ready: the rebase onto current Fixed by routing those through It's pure delegation (the 73 — Jeff (KM6LFY) |
|
On-air smoke re-run on
Behavior matches the earlier hardware verification; the delegation refactor is transparent. 73 — Jeff (KM6LFY) |
|
Coordination note for maintainer review — I traced this PR against the explicit design ruling in #4014:
Finding: #4026 complies with that ruling as currently implemented. It changes only the RX/DAX audio path: when a slice uses KiwiSDR receive replacement, Kiwi audio is injected onto the slice's DAX channel and the competing FLEX DAX payload is suppressed. It does not remove, weaken, or bypass Resulting behavior:
I found no path added by this PR that keys MOX, tune, PTT, or assigns a TX slice, so it does not introduce autonomous QRM generation. One bounded caveat for review: enforcement uses the selected display-source state as the proxy for local visibility; it does not independently prove that fresh local FLEX FFT frames are arriving before the inhibit clears. That is pre-existing behavior, not a regression in #4026. Likewise, another radio client or hardware PTT remains outside this PR's control. So my recommendation on this narrow question is: compliant with the #4014 ruling; no exception introduced by this diff. |
NF0T
left a comment
There was a problem hiding this comment.
Verified independently against the current (rebased) commits, not against the
existing review — which is pinned to commit 91a1a94, a hash that no longer
exists on this branch after the 2026-07-09 rebase. Nobody has re-reviewed the
actual current diff, so I checked all four of the maintainer's original
findings against the real code myself before trusting the follow-up commit
messages.
Confirmed genuinely fixed, verified against the actual mechanisms, not just
the commit messages:
- WSJT-X exit orphaning the bit: SliceModel::setDaxChannel() (:529-536) and
the radio-status delta path (:1200-1203) both emit daxChannelChanged
unconditionally, including on release to 0 — no special-case exclusion.
The new daxChannelChanged→refresh wiring will genuinely fire here. - Slice removal never clearing it: checked all three sliceRemoved emission
sites in RadioModel.cpp (:6074-6080, :6098-6100, :2705) — every one removes
the slice from storage before emitting the signal, so
refreshKiwiSdrDaxSuppression()'s re-scan of m_radioModel.slices() genuinely
won't see the removed slice. - Leaking across radio sessions: clearRegisteredStreams()'s mask reset is
called from the real disconnect handler (RadioModel.cpp:3438), alongside
stopNetworkMonitor()/m_daxIqModel.handleDisconnect() — properly wired into
the disconnect path. - Both smaller design notes (thread-reentrancy fix on injectDaxAudio, the
generic setExternalDaxSourceMask rename, the single isValidDaxChannel()
predicate) are present exactly as described.
New gap, introduced by the fix for the 4th concern (stall watchdog), not
present before it existed:
The stall watchdog's GUI-side state — m_kiwiDaxStallTimer,
m_kiwiDaxLastAudioMs, m_kiwiDaxStalledChannels — never gets reset on
disconnect, unlike its sibling core-side mask. MainWindow::
onConnectionStateChanged already resets other Kiwi GUI state on disconnect
(clearKiwiSdrPanDisplaySourceOverrides()) — this PR's new fields aren't
hooked into that existing pattern. I traced the actual consequence:
PanadapterStream is a non-owning pointer backed by the persistent m_backend,
so it survives a disconnect (that's exactly why clearRegisteredStreams()
exists — to re-arm a persisting object). kiwiSdrDaxStallTick()'s guard
(!m_radioModel.hasPanStream()) stays false across a disconnect, so it
doesn't help. With stale entries in m_kiwiDaxLastAudioMs and a QElapsedTimer
that never resets, every 250ms tick after a disconnect would find those
entries "stalled" and call injectDaxAudio(channel, silence) on stale channel
numbers while fully disconnected — until some event in the next session
happens to trigger a fresh refresh and prune them. Likely harmless in
practice (probably an emission with no listener), but it's real ongoing work
on a supposedly-idle app, and a plausible source of a spurious silence blip
if a future session reuses the same channel number before the first real
refresh lands.
One explicitly-flagged item I can't confirm was addressed:
The original review called out, "noted for the non-draft version": brief
dual-feed windows on engage and during the radio's dax=0→N rebroadcast,
suggesting latching suppression on profile assignment (mirroring
TciServer's m_channelTrx approach) to remove both. I don't see a latch-on-
assignment mechanism anywhere in the diff. The stall watchdog covers the
silence-gap half (a newly-suppressed channel is armed "as of now"), but not
the dual-feed half — a window where Flex's own in-flight packets and
injected Kiwi audio could both reach the DAX consumer around a mask flip.
Since this PR has since left draft status — the condition that requirement
was attached to — this reads as still open rather than resolved. Flagging
in case I'm missing where it was handled elsewhere.
To be clear about scope: this is a well-executed piece of work that
correctly closed 3 of 4 serious structural gaps from a real deep review, and
both smaller design notes. The two items above are the reason I'm not
approving as-is — requesting changes so the disconnect-reset gap and the
latching question get a second pair of eyes (author and/or the original
reviewer) before this merges, per the "mask-lifecycle work is the substance
of turning the POC into the real thing" bar the maintainer already set.
CI green across the full matrix.
There was a problem hiding this comment.
Nicely done — this is a well-scoped POC and the design holds up. I verified the load-bearing assumption: KiwiSdrClient::decodedAudioReady(pcm24kStereoFloat) feeds through resampleSoundSamples → processMonoToStereo(..., 24000.0), so the Kiwi payload really is 24 kHz stereo float32 and matches the native DAX format the Flex PCC_IF_NARROW* paths normalize to. The "no repackaging" claim is sound.
Worth noting for anyone reading the earlier thread: the current revision has already resolved the substantive review from @ten9876 — the mask is reset in clearRegisteredStreams() (cross-session leak), refreshKiwiSdrDaxSuppression() is now event-driven off daxChannelChanged/sliceAdded/sliceRemoved and called unconditionally on the teardown paths (the slice-already-removed orphan), injectDaxAudio re-invokes onto the network thread via QueuedConnection (the thread-contract change), the channel >= 0 redundancy is gone, and the stall watchdog feeds silence so a suppressed-but-stalled channel no longer starves the decoder. Those were all valid concerns and they look genuinely closed.
Only two minor, non-blocking notes remain — see inline.
Polish (non-blocking)
refreshKiwiSdrDaxSuppressionre-arm loop uses a rawchannel < 32instead of the sharedisValidDaxChannelpredicate the PR introduced to end exactly this divergence.- The Kiwi→DAX routing
connectsits inside theif (m_audio)guard, coupling DAX decode routing to the local audio engine's existence even thoughrouteKiwiSdrAudioToDaxnever touchesm_audio.
Nothing here blocks the RFC direction. Thanks for the thorough verification write-up and evidence.
🤖 aethersdr-agent · cost: $4.2914 · model: claude-opus-4-8
| ++it; | ||
| } | ||
| } | ||
| for (int channel = 1; channel < 32; ++channel) { |
There was a problem hiding this comment.
Minor: this re-arm loop bounds on a raw channel < 32, but the PR's own header comment on isValidDaxChannel says to "keep every channel-validity check on this predicate so the bounds can't diverge." The mask-build loop just above already routes through m_radioModel.isValidDaxChannel; this one is the lone raw check. Equivalent one-liner that honors the principle:
| for (int channel = 1; channel < 32; ++channel) { | |
| for (int channel = 1; m_radioModel.isValidDaxChannel(channel); ++channel) { |
| }, Qt::QueuedConnection); | ||
| // Also route the Kiwi audio onto its slice's DAX channel so WSJT-X | ||
| // (DAX/TCI) decodes the Kiwi, not the muted Flex. (feat/kiwi-audio-to-dax) | ||
| connect(m_kiwiSdrManager, &KiwiSdrManager::decodedAudioReady, |
There was a problem hiding this comment.
Non-blocking: this decodedAudioReady → routeKiwiSdrAudioToDax connect is nested inside the if (m_audio) block, but routeKiwiSdrAudioToDax only touches m_kiwiSdrManager and m_radioModel — never m_audio. DAX/TCI decode (m_daxBridge, TciServer) is independent of the local audio engine, so in an m_audio == null configuration the Kiwi would still feed the speaker path's absence but silently never reach WSJT-X. Suggest hoisting this one connect out of the if (m_audio) guard so DAX routing is wired regardless.
There was a problem hiding this comment.
Nice work — this revision closes out all four of ten9876's earlier findings cleanly:
- Disconnect leak → mask now reset in
clearRegisteredStreams(). - Thread contract →
injectDaxAudiore-invokesdaxAudioReadyonto the network thread viaQMetaObject::invokeMethod, so TCI/DAX consumers keep queued delivery. - Slice-removal / teardown null gap →
refreshKiwiSdrDaxSuppression()recomputes from scratch and is now called outside the slice lookup; wired event-driven todaxChannelChanged/sliceRemoved/ assign-clear instead of per-chunk polling. - Suppression outliving the feed → the stall watchdog fills silence so WSJT-X never freezes mid-stream.
I verified the ordering in setKiwiSdrVirtualAntennaForSlice: setExternalReceiveAudioReplacementMute(true) sets m_externalReceiveAudioReplacement before the refresh runs, so the newly-fed channel is included in the mask. Slices are deleteLater'd on removal, so the per-slice daxChannelChanged connections auto-disconnect — no accumulation across add/remove cycles. The seam forwarders through RadioModel (EB3) are clean pure delegation. Scope is tight — all six files serve the RX/decode half as described.
The design is sound; only non-blocking notes below. Given this is still gated behind #3894's RFC direction, none of these need to block the POC.
Non-blocking notes
refreshKiwiSdrDaxSuppressionre-inlines the1..32DAX-channel bound instead of using theisValidDaxChannelpredicate this PR introduced to prevent exactly that divergence.- Gap-fill silence + a buffered burst on Kiwi reconnect could push the DAX consumer ahead of real-time — worth confirming against WSJT-X's clock for the non-POC version.
🤖 aethersdr-agent · cost: $2.5419 · model: claude-opus-4-8
| } | ||
| } | ||
| for (int channel = 1; channel < 32; ++channel) { | ||
| if ((mask & (1u << channel)) | ||
| && !m_kiwiDaxLastAudioMs.contains(channel)) { | ||
| m_kiwiDaxLastAudioMs.insert(channel, now); |
There was a problem hiding this comment.
You added isValidDaxChannel specifically "so the bounds can't diverge," but this loop (and the 1u << it.key() / mask & (1u << it.key()) uses just above) open-code the < 32 bound again in three spots. Not a bug — 32 matches the mask width — but it re-introduces the divergence the predicate was meant to kill. Consider a shared kMaxDaxChannelExclusive constant or iterating with the predicate so all four channel-range checks stay in lockstep.
Non-blocking.
| << "- feeding silence until it resumes"; | ||
| } | ||
| // One tick's worth of 24 kHz stereo float32 silence. | ||
| static const QByteArray silence( | ||
| 24000 * kKiwiDaxStallTickMs / 1000 * 2 | ||
| * static_cast<int>(sizeof(float)), |
There was a problem hiding this comment.
The gap-fill keeps the sample clock advancing at real-time while the Kiwi is stalled, which is the right instinct for FT8's UTC-aligned windows. One thing worth confirming for the non-POC version: if the Kiwi stalls, silence fills the gap, and then the client delivers a buffered burst on reconnect (recoverableDisconnect backoff), the DAX consumer receives silence plus the backlog — i.e. more than real-time samples — which can push WSJT-X's stream ahead of the decode window. Whether that matters depends on how KiwiSdrClient handles its post-reconnect backlog (drop vs. flush). If it flushes, you may want to cap/drop instead of always injecting the full silence tick on the same channel. Non-blocking for the POC.
There was a problem hiding this comment.
Nice work — this is a well-shaped POC, and the three follow-up commits have genuinely resolved the earlier review feedback rather than papering over it. I re-checked each prior concern against the current diff and they all hold up:
- Mask reset added to
clearRegisteredStreams()so suppression can't leak across a disconnect. - The per-chunk mask polling is gone;
refreshKiwiSdrDaxSuppression()is now event-driven offdaxChannelChanged/sliceAdded/sliceRemovedand recomputes from scratch outside any slice lookup — so it stays correct through slice removal and while no Kiwi audio flows. isValidDaxChannelis the single validity predicate now, threaded through the RadioModel seam so the GUI never includes the vendor header (clean EB3 handling).- The stall watchdog + silence feed addresses the "neither source reaches the consumer" freeze, and
injectDaxAudiore-hops onto the network thread so consumers keep the same queued-delivery contract the Flex path has. Thepcm/silencecaptures are copy-by-value and safe across the hop.
The thread model and the atomic mask read on the RX parse thread look correct to me. Only minor polish below; nothing blocking from a code-correctness standpoint. As you noted, this is a draft pending the #3894 RFC direction, so scope is a maintainer call.
Polish
refreshKiwiSdrDaxSuppressionbounds its scan with a bare32literal — see inline; a named constant tied to the mask width would document intent.
Non-blocking notes
- Two shared-channel Kiwi slices would both inject onto one DAX channel (not realistic today, since channels are unique per slice — just flagging the assumption).
- Every Kiwi chunk now crosses GUI→network via
invokeMethodon top of the existingdecodedAudioReadyqueued hop; fine for decode latency, worth remembering if the audio path ever gets tightened.
🤖 aethersdr-agent · cost: $1.6404 · model: claude-opus-4-8
| ++it; | ||
| } | ||
| } | ||
| for (int channel = 1; channel < 32; ++channel) { |
There was a problem hiding this comment.
Minor: this is the one spot that hardcodes the mask width (< 32) rather than deriving it. The value is legitimately the quint32 bit count, not DAX validity — but since you introduced isValidDaxChannel as "the one definition" of a routable channel, a named constant (e.g. kExternalDaxSourceMaskBits) or a companion maxDaxChannel() seam forwarder would keep this loop from silently drifting if the mask type ever changes. Not blocking.
… Principle II. The radio's dax=0->N status rebroadcast (TCI re-arm, restart within the aethersdr#3305 grace window) dropped the suppression bit mid-flip, opening a brief window where the Flex payload and Kiwi audio could both reach the DAX consumer. Latch the resolved channel per Kiwi-fed slice at assignment, mirroring TciServer::m_channelTrx (aethersdr#3669), and hold the bit while the binding transiently reads 0. The latch is invalidated wherever it must not outlive reality, so it cannot recreate the orphaned-bit defect this lifecycle work prevents: slice cleared/removed (refresh prune), genuine stream release via the new RadioModel::daxStreamUnregistered seam forward (grace-expiry after WSJT-X exit, radio-side removal), and channel takeover by another slice (the new owner's live Flex feed wins). Audio injection is deliberately NOT latched: routeKiwiSdrAudioToDax still requires a live valid binding; the stall watchdog's silence-fill covers longer transients, keeping the sources unmixed. Addresses the latch-on-assignment item from the 2026-07-04 review, flagged still-open in the 2026-07-11 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e II. On a disconnect RadioModel stages slices out WITHOUT emitting sliceRemoved, so the event-driven suppression refresh never runs there; the core-side mask reset in clearRegisteredStreams() had no GUI-side sibling. The watchdog's m_kiwiDaxLastAudioMs / m_kiwiDaxStalledChannels / stall timer survived the disconnect, and kiwiSdrDaxStallTick() kept injecting silence on stale channel numbers into a fully disconnected app (hasPanStream() stays true across disconnects by design — the stream object persists). Reset the watchdog state and the suppression latch from the same onConnectionStateChanged disconnect branch that already clears the other per-session Kiwi GUI state (clearKiwiSdrPanDisplaySourceOverrides), and mirroring TciServer's m_channelTrx.clear() on disconnect. Reconnect re-arms event-driven from scratch. Fixes the disconnect-reset gap from the 2026-07-11 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting independent of the local audio engine. Two non-blocking notes from the 2026-07-11 bot review: - The watchdog re-arm loop was the one remaining raw 'channel < 32' bound; iterate on isValidDaxChannel() so every channel-range check sits on the single predicate this PR introduced. - The decodedAudioReady -> routeKiwiSdrAudioToDax connect lived inside the if (m_audio) guard, coupling DAX/TCI decode to the local audio engine's existence even though the routing path never touches m_audio. Hoist it out so a no-local-audio configuration still feeds WSJT-X. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@NF0T Thank you for the review. I'm working on the requested changes and will update the PR after I do a final on-the-air test and collect the necessary logs and evidence. 73, KM6LFY |
|
@NF0T Thanks again for the review. Please let me know if you need anything else. Follow-up to the 2026-07-11 CHANGES_REQUESTED review. This PR routes a KiwiSDR's RX
Verified: clean builds + full ctest on macOS (76/76) and Linux (77/77), EB3 strict green, Method: the test plan's Phases 8–10, driven through the Automation Bridge ( Evidence attached (zip), one folder per claim: Two disclosures:
|
# Conflicts: # src/models/RadioModel.h
NF0T
left a comment
There was a problem hiding this comment.
Re-reviewed after the three follow-up commits (a12f5c4d, ee985601, 3b118a22). Both mask-lifecycle gaps are resolved — I traced each against the current source rather than the commit messages:
- The GUI-side stall-watchdog state is now reset on disconnect via
resetKiwiSdrDaxSuppressionState()(my finding from the last pass). - The dual-feed window on a transient
dax=0→Nrebroadcast is closed by the newm_kiwiDaxLatchedChannelslatch, which I traced throughRadioModel's slice-removal ordering to confirm it never holds a bit past a genuine teardown (ten9876's original finding). - The three prior bot-review notes about a raw channel-bound literal were against the pre-fix commit (posted ~an hour before
3b118a22landed); current code already uses the sharedisValidDaxChannelpredicate.
CI is green across the board.
Switching this from CHANGES_REQUESTED to COMMENT — the technical execution is sound and I don't have a further technical blocker. But @ten9876, your original review framed this explicitly as "direction guidance, not a merge gate," with #3894 as the deciding venue for the overall shape — this PR still routes Kiwi audio through the existing Flex-slice/virtual-RX-antenna model, which #3894's Receiver/ReceiveProvider abstraction may reshape. Now that the mask-lifecycle work you asked for is done, the remaining question is yours to call: merge this as-is against the current architecture, hold it pending #3894 landing, or ask for it to be reshaped to fit the session/receiver/provider split before merging. Deferring to you on timing and shape.
|
@skerker Heads up, planning to merge in next week's release 7/26. |
|
First-pass triage review (not exhaustive — surfacing blockers to keep this moving): Blockers None spotted in a first pass. The four mask-lifecycle gaps from the original @ten9876 deep review and @NF0T's follow-up (cross-session leak, GUI-thread emission contract, slice-removal/teardown null, suppression-outliving-the-feed) all read as genuinely closed in the current diff: mask reset in Secondary notes
Closing: from a code-correctness standpoint I don't see anything blocking a first pass — the substance here is the merge-timing/shape call @NF0T handed back to @ten9876 (land as-is vs. hold for #3894's Receiver/ReceiveProvider reshape), which is a maintainer decision, not an author action item. 73. |
Fixes #4025
Summary
Routes a slice's KiwiSDR RX audio onto the DAX channel that WSJT-X / TCI decode, and suppresses the Flex's own DAX audio on that channel so the client sees only the Kiwi. Kiwi and DAX audio are both 24 kHz stereo float32, so the payload passes through unchanged (no resampling).
This is the RX / decode half only. The transmit side (allowing local TX while a KiwiSDR is the RX source) is already tracked separately in #4002 and is not part of this PR.
Approach
PanadapterStream::injectDaxAudio(channel, pcm)— emitdaxAudioReadyfor externally-sourced (Kiwi) audio.PanadapterStream::setKiwiSuppressedDaxMask(mask)+ a gate at the top of the DAX branch — drop the Flex's own packets on any channel currently fed by a Kiwi, so the two sources don't mix.MainWindow::routeKiwiSdrAudioToDax()— on each KiwidecodedAudioReady, if the slice's RX is Kiwi-replaced and it owns a DAX channel, inject onto that channel.MainWindow::refreshKiwiSdrDaxSuppression()— rebuild the suppression mask; called when the Kiwi RX overlay turns on/off.Additive: no change to existing DAX/Flex behavior when no Kiwi is assigned.
Verification (FLEX-8400, Qt 6.8.3 / Linux)
Evidence splits in two, because the Automation Bridge observes AudioEngine RX ingestion but cannot see the
daxAudioReadybus directly:slice rxsource 0 <KPH>→get slicesshowsexternalReceiveReplacement: true,source: kiwi, endpointkphsdr.com:8073. AnaudioCaptureover the RX points returnedkiwi-tagged chunks at 24000 Hz, 2ch, float32le — the Kiwi stream is live in the pipeline.Decode via remote KPH:
Full RX-via-Kiwi / TX-via-Flex QSO (end-to-end demonstration; TX enablement is #4002):
Notes