Skip to content

feat(kiwi): route KiwiSDR audio to the slice's DAX channel for WSJT-X/digimodes#4026

Open
skerker wants to merge 7 commits into
aethersdr:mainfrom
skerker:fix/kiwi-rx-dax
Open

feat(kiwi): route KiwiSDR audio to the slice's DAX channel for WSJT-X/digimodes#4026
skerker wants to merge 7 commits into
aethersdr:mainfrom
skerker:fix/kiwi-rx-dax

Conversation

@skerker

@skerker skerker commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

POC for the RX-decode slice of #3894. Opening as a draft to make the idea concrete and claim the work per CONTRIBUTING — not seeking merge ahead of the RFC's direction; happy to reshape scope or approach.

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) — emit daxAudioReady for 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 Kiwi decodedAudioReady, 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 daxAudioReady bus directly:

  • Bridge (deterministic — setup + ingestion): slice rxsource 0 <KPH>get slices shows externalReceiveReplacement: true, source: kiwi, endpoint kphsdr.com:8073. An audioCapture over the RX points returned kiwi-tagged chunks at 24000 Hz, 2ch, float32le — the Kiwi stream is live in the pipeline.
  • WSJT-X (decisive — last hop): with KPH assigned, WSJT-X decoded a full screen of trans-Pacific DX via the remote receiver (density a local antenna wouldn't produce), and completed a full FT8 QSO. Reverting the slice to Flex returns decoding to the local antenna.

Decode via remote KPH:

kiwi-kph-decode-wsjtx

Full RX-via-Kiwi / TX-via-Flex QSO (end-to-end demonstration; TX enablement is #4002):

kiwi-qso-wsjtx-085508

Notes

  • CHANGELOG.md intentionally not modified (handled by the automated tag process).
  • No new dependencies.
  • Local build clean (Linux/GCC); expecting the 3 CI compile checks to gate.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed 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 relaxed load, no mutex near the hot path.
  • All three daxAudioReady consumers (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 the injectDaxAudio comment 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:

  1. WSJT-X exit orphans the bit: TCI's releaseDaxForTci sets the slice's dax=0, and routeKiwiSdrAudioToDax early-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).
  2. Slice removal never clears it: the refresh calls are gated inside if (SliceModel* slice = …), but RadioModel removes the slice from m_slices before emitting sliceRemoved, so the lookup nulls and the refresh is skipped.
  3. It leaks across radio sessions: PanadapterStream lives for the app lifetime and the mask isn't reset in clearRegisteredStreams() — the class's documented disconnect-time reset for every other per-stream table. One line fixes that one.
  4. 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.

Comment thread src/gui/MainWindow_KiwiSdr.cpp Outdated
Comment thread src/gui/MainWindow_KiwiSdr.cpp
Comment thread src/core/PanadapterStream.cpp Outdated
Comment thread src/core/PanadapterStream.cpp
Comment thread src/core/PanadapterStream.h Outdated
@skerker
skerker force-pushed the fix/kiwi-rx-dax branch from 91a1a94 to 670d438 Compare July 6, 2026 17:40
skerker added a commit to skerker/AetherSDR that referenced this pull request Jul 6, 2026
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>
@skerker

skerker commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • Mask lifecycle is event-driven — took your "better still" option rather than the minimum swap: refreshKiwiSdrDaxSuppression() is wired to SliceModel::daxChannelChanged (mirroring wireDaxSlice, DAX virtual audio: only slice 0 gets audio client on Linux PipeWire headless (RPi 5, Ubuntu 24.04, FLEX-8600) #2895), RadioModel::sliceRemoved, and the Kiwi assign/clear lifecycle. The per-chunk refresh is gone entirely.
  • Teardown paths — the refresh calls moved outside the if (slice = …) lookups (slice removal drops the slice before sliceRemoved fires, as you noted), and clearRegisteredStreams() now zeroes the mask so nothing leaks across radio sessions.
  • Suppression no longer outlives the feed — of your two options I went with silence-fill rather than watchdog-unsuppress: a 250 ms tick feeds native-format silence on any suppressed channel that's gone >400 ms without Kiwi audio (covering the initial connect window too), with log lines on stall/resume. Rationale: unsuppressing would silently hand WSJT-X the local antenna mid-stall, so decodes would keep appearing but from the wrong receiver; silence keeps the sources unmixed and the decoder clocks running. Happy to switch if you'd rather fail visible-loud than fail quiet.
  • Thread contract restoredinjectDaxAudio() re-invokes onto the stream's (network) thread when called from elsewhere, so AutoConnection consumers get queued delivery identical to the Flex path.
  • Layering — renamed to setExternalDaxSourceMask / m_externalDaxSourceMask; PanadapterStream no longer knows what feeds it.
  • The smaller items — one shared isValidDaxChannel() predicate replaces the three divergent bounds, the redundant channel >= 0 guard is gone, braces added, and the format comment now pins its evidence (both PCC paths normalize to 24 kHz stereo float32, fw 4.2.18).

Also rebased onto current main (630b6ba) — needed anyway, and worth noting the injection API now sits alongside the #3305 centralized DAX-ownership block in PanadapterStream.h; the two coexist cleanly but say the word if you'd rather see the external-source mask folded into that ownership model instead of beside it.

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)

@skerker

skerker commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Verification report for the review-fix commit (670d438e), as promised in my previous
comment. All testing on Linux (RTX 5060 box) + FLEX-8400 (fw 4.2.18.41174), clean build of
this branch, driven through the Automation Bridge (tci verb + get dax — thank you for
those, they made every lifecycle claim directly assertable). Flex RX on a dummy load
throughout, so any real audio content is Kiwi by construction.

The three scenarios promised in the review response:

  1. WSJT-X exit → channel reusetci stop abrupt (socket close, no audio_stop),
    4 full cycles + an order-swapped variant (restart while Kiwi still assigned, then
    revert). Every cycle: debounced release + grace window freed the channel (~15 s),
    restart reacquired the same channel, and binaryFrames climbed immediately.
    Pre-fix signature (audioStarted:true, frames frozen) never appeared.
    Bonus: real WSJT-X TCP drop/reconnect cycles later in the session (unrelated cause)
    organically reproduced the same shape with a real client — audio re-armed every time.
  2. Kiwi stall mid-decode — induced with an iptables OUTPUT DROP to the Kiwi host.
    Within ~0.5 s: KiwiSDR audio stalled on DAX channel 1 - feeding silence until it resumes; frames kept climbing at exactly the 250 ms tick rate (4/s), msSinceLastFrame
    bounded <200 ms. Rule removed → reconnect → KiwiSDR audio resumed on DAX channel 1,
    live rate restored, no TCI restart. The watchdog also covered engage-time connect gaps
    and a "server full" camping period (KPH went 4/4 mid-test) the same way.
  3. Radio disconnect/reconnect — with Kiwi engaged + suppression active, full radio
    session bounce: fresh session came up with replacement inactive and TCI audio flowing
    immediately on the reacquired channel. No mask leak (clearRegisteredStreams() path).

Additionally verified: slice removal mid-suppression frees the removed slice's channel
for the next holder (survivor slice re-bound to it, frames at full Flex rate); clear-path
teardown ×3 (replacement flag + content switch with no gap beyond the switch itself);
Phase-0 baseline with no Kiwi assigned is untouched. Frame-rate signatures (Flex ~47/s,
Kiwi ~24/s, silence ticks 4/s) made source attribution deterministic without audio
inspection.

Two-pan workflow regression check (re: the #4014 ruling): with Pan A/slice A on a Kiwi
and Pan B/slice B on local RF, slice tx A is still refused (slice A never becomes an
operable TX slice) while slice B holds TX normally — verified through ~15 live FT8 TX
periods (steady 9–10 W into a dummy load) with Kiwi decode flowing concurrently. This PR
composes cleanly with that arrangement.

One observation for the open get dax question: all of the above still infers the mask
from frame flow; exposing externalDaxSourceMask (or folding it into the #3305 table,
per the earlier question) would have made these assertions direct.

Full per-phase tci status / get dax / get slices snapshots and log excerpts retained;
happy to attach any of them.

skerker and others added 2 commits July 9, 2026 13:32
…/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>
@skerker
skerker force-pushed the fix/kiwi-rx-dax branch from 670d438 to 4a379fe Compare July 9, 2026 20:38
@skerker

skerker commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main — conflicts cleared, clean build. Everything from the POC review is implemented and hardware-verified (the three lifecycle scenarios + the two-pan regression check are in my verification report above, 2026-07-07). Marking this ready for review.

73 — Jeff (KM6LFY)

@skerker
skerker marked this pull request as ready for review July 9, 2026 20:39
@skerker
skerker requested a review from a team as a code owner July 9, 2026 20:39
@aethersdr-agent

Copy link
Copy Markdown
Contributor

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 failed

It's not one of the three compile checks — build, check-macos, and check-windows were all still in_progress when the run reported. The only job that went red is Engine/UI dependency direction (run 29048632805), i.e. tools/check_engine_boundary.py --strict. So this is an architecture-boundary gate, not a code-doesn't-build problem.

Root cause — one line

In src/gui/MainWindow_KiwiSdr.cpp the diff adds:

#include "core/PanadapterStream.h"

core/PanadapterStream.h is tagged vendor(flex) in the touchpoint audit (docs/architecture/aetherd-touchpoint-tags.json:386 — "SmartSDR VITA-49 UDP receiver … DAX/IQ routing"). The EB3 rule keeps family-specific wire code behind the radio seam: any file in src/gui|core|models may only include vendor headers listed in its frozen baseline row, and that set may only shrink. MainWindow_KiwiSdr.cpp's row today is:

"src/gui/MainWindow_KiwiSdr.cpp": ["KiwiSdrClient", "KiwiSdrManager", "KiwiSdrProtocol"],

PanadapterStream isn't in it, so the new include trips:

EB3 … includes vendor(flex) header PanadapterStream.h, which is not in its EB3 baseline set — new (or laterally swapped-in) vendor coupling above the radio seam.

That's the whole failure. Your core-side edits (PanadapterStream.cpp/.h) are clean — #include <QThread> is QtCore, so no EB1/EB2 tripwire there.

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 tools/check_engine_boundary.py:5-6, docs/aetherd-headless-engine-design.md, and AGENTS.md → "Engine boundary ratchet — EB3").

The minimal ratchet-compatible move: forward through RadioModel instead of reaching around it. RadioModel.h/.cpp already legitimately hold PanadapterStream in their EB3 baselines, and you already go through m_radioModel.panStream(). So:

  1. Add thin pass-throughs on RadioModel — e.g. injectDaxAudio(channel, pcm) and setExternalDaxSourceMask(mask) — that forward to panStream().
  2. In MainWindow_KiwiSdr.cpp, call m_radioModel.injectDaxAudio(...) / m_radioModel.setExternalDaxSourceMask(...) and drop the #include "core/PanadapterStream.h".
  3. For the PanadapterStream::isValidDaxChannel(channel) static you call in routeKiwiSdrAudioToDax / refreshKiwiSdrDaxSuppression — either mirror it as a RadioModel helper or inline the channel >= 1 && channel < 32 check locally, so the gui TU no longer needs the vendor type at all.

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:

python3 tools/check_engine_boundary.py --strict src/gui/MainWindow_KiwiSdr.cpp src/core/PanadapterStream.cpp

(The RFC-pure endgame is a real IRadioBackend seam verb rather than a RadioModel forward, but for a POC the forward is a perfectly honest intermediate — it doesn't grow the ratchet.)

Re: @ten9876's review notes

Those are a separate track — lifecycle/threading correctness (the release-before-return ordering, the sliceRemoved-null-lookup teardown gap, the GUI-thread daxAudioReady emission), not the CI failure. They won't turn the boundary check green, and none of them block it. Worth folding in for the non-draft version, but the only thing standing between you and a green Engine/UI dependency direction is the one include above. Nice work overall. 👍


🤖 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>
@skerker

skerker commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Quick update after marking ready: the rebase onto current main tripped the new engine-boundary EB3 gate — MainWindow_KiwiSdr.cpp (above the radio seam) reached the vendor PanadapterStream directly (static isValidDaxChannel() + panStream()->injectDaxAudio()/setExternalDaxSourceMask()).

Fixed by routing those through RadioModel (already a sanctioned stream owner): thin isValidDaxChannel/injectDaxAudio/setExternalDaxSourceMask forwarders + a hasPanStream() bool guard, and dropped the direct include. The GUI file's EB3 vendor set is now empty and check_engine_boundary.py --strict passes.

It's pure delegation (the hasPanStream() guard preserves the stall-tick early-return exactly), so behavior is unchanged from my 2026-07-07 hardware verification. But since that verification predates this rebase + refactor, I'll re-run an on-air smoke (Kiwi decode + suppression engage/clear) before merge rather than lean on the earlier report.

73 — Jeff (KM6LFY)

@skerker

skerker commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

On-air smoke re-run on 95034716 (post-rebase + RadioModel EB3 refactor), as promised above rather than leaning on the 2026-07-07 report:

  • Kiwi decodeFT8 on 10.136 MHz (30m) decoding in WSJT-X via the KPH KiwiSDR, with the Flex on a dummy load — so the decode can only originate from the Kiwi→DAX path. ✅
  • Suppression engage/clear — clearing the slice's Kiwi source switches the DAX channel to Flex (live dummy-load noise on the waterfall = stream alive, mask lifted); re-engaging restores the FT8 decodes. Round-trips cleanly. ✅
  • Also confirmed the waterfall Flex/Kiwi display toggle doesn't affect decodes — display source is decoupled from DAX audio routing, as intended.

Behavior matches the earlier hardware verification; the delegation refactor is transparent. 73 — Jeff (KM6LFY)

@jensenpat

jensenpat commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Coordination note for maintainer review — I traced this PR against the explicit design ruling in #4014:

We shall not allow TX on a pan that is not displaying the local RF environment received at the antenna.

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 syncKiwiSdrPanadapterTxInhibit() or the RadioModel MOX/tune/CAT/DAX/TCI/slice-command gates.

Resulting behavior:

  • A pan displaying the Kiwi spectrum/waterfall remains TX-inhibited.
  • A separate local-FLEX pan may own the TX slice while Kiwi receive/DAX decoding continues on the other pan; this is the supported two-pan workflow stated in fix(gui): remove KiwiSDR panadapter TX inhibit #4014.
  • If the operator switches the affected pan's display source back to FLEX while retaining Kiwi audio/DAX, TX may be permitted because that pan is again displaying the local RF environment. The contributor's post-refactor smoke test confirms the display toggle is decoupled from DAX routing.
  • A Kiwi audio stall produces DAX silence and does not change display-source or TX-inhibit state.

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 NF0T self-assigned this Jul 11, 2026

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nicely done — this is a well-scoped POC and the design holds up. I verified the load-bearing assumption: KiwiSdrClient::decodedAudioReady(pcm24kStereoFloat) feeds through resampleSoundSamplesprocessMonoToStereo(..., 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)

  • refreshKiwiSdrDaxSuppression re-arm loop uses a raw channel < 32 instead of the shared isValidDaxChannel predicate the PR introduced to end exactly this divergence.
  • The Kiwi→DAX routing connect sits inside the if (m_audio) guard, coupling DAX decode routing to the local audio engine's existence even though routeKiwiSdrAudioToDax never touches m_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

Comment thread src/gui/MainWindow_KiwiSdr.cpp Outdated
++it;
}
}
for (int channel = 1; channel < 32; ++channel) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
for (int channel = 1; channel < 32; ++channel) {
for (int channel = 1; m_radioModel.isValidDaxChannel(channel); ++channel) {

Comment thread src/gui/MainWindow_KiwiSdr.cpp Outdated
}, 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work — this revision closes out all four of ten9876's earlier findings cleanly:

  • Disconnect leak → mask now reset in clearRegisteredStreams().
  • Thread contractinjectDaxAudio re-invokes daxAudioReady onto the network thread via QMetaObject::invokeMethod, so TCI/DAX consumers keep queued delivery.
  • Slice-removal / teardown null gaprefreshKiwiSdrDaxSuppression() recomputes from scratch and is now called outside the slice lookup; wired event-driven to daxChannelChanged / 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

  • refreshKiwiSdrDaxSuppression re-inlines the 1..32 DAX-channel bound instead of using the isValidDaxChannel predicate 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

Comment on lines +682 to +687
}
}
for (int channel = 1; channel < 32; ++channel) {
if ((mask & (1u << channel))
&& !m_kiwiDaxLastAudioMs.contains(channel)) {
m_kiwiDaxLastAudioMs.insert(channel, now);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +727 to +732
<< "- 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)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 off daxChannelChanged / sliceAdded / sliceRemoved and recomputes from scratch outside any slice lookup — so it stays correct through slice removal and while no Kiwi audio flows.
  • isValidDaxChannel is 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 injectDaxAudio re-hops onto the network thread so consumers keep the same queued-delivery contract the Flex path has. The pcm/silence captures 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

  • refreshKiwiSdrDaxSuppression bounds its scan with a bare 32 literal — 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 invokeMethod on top of the existing decodedAudioReady queued hop; fine for decode latency, worth remembering if the audio path ever gets tightened.

🤖 aethersdr-agent · cost: $1.6404 · model: claude-opus-4-8

Comment thread src/gui/MainWindow_KiwiSdr.cpp Outdated
++it;
}
}
for (int channel = 1; channel < 32; ++channel) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

skerker and others added 3 commits July 11, 2026 14:14
… 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>
@skerker

skerker commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

@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

@skerker

skerker commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

@NF0T Thanks again for the review. Please let me know if you need anything else.
73, KM6LFY

Follow-up to the 2026-07-11 CHANGES_REQUESTED review. This PR routes a KiwiSDR's RX
audio onto the slice's DAX channel (so WSJT-X decodes the remote receiver) and suppresses the
Flex's own DAX payload while it does; the review found two lifecycle gaps in that suppression.
Three commits are now appended on top of the reviewed head 95034716 — no rebase, so the
review trail stays anchored — and the whole set has been re-verified on hardware.

  • Disconnect reset (ee985601): new resetKiwiSdrDaxSuppressionState() stops the stall
    watchdog and clears its state + the latch, called from the existing
    onConnectionStateChanged disconnect branch you pointed at.

  • Suppression latch (a12f5c4d): latches the resolved channel per Kiwi-fed slice
    (mirrors TciServer::m_channelTrx); holds the bit through transient dax=0→N
    rebroadcasts; invalidated on slice clear/removal, genuine stream release (new
    RadioModel::daxStreamUnregistered seam forward), channel takeover, and disconnect.

  • Bot polish (3b118a22): both notes taken. The reconnect-burst question:
    KiwiSdrClient drops rather than flushes (gap-padding capped at 8 frames, buffer
    cleared on session reset), so no post-reconnect burst is possible.

Verified: clean builds + full ctest on macOS (76/76) and Linux (77/77), EB3 strict green,
and on-air on a FLEX-8400 (fw 4.2.20.41343, real antenna, WSJT-X over TCI, 30 m FT8).

Method: the test plan's Phases 8–10, driven through the Automation Bridge (get dax,
slice rxsource, disconnect, and the in-process TCI client simulator for scripted
abrupt-exit / in-grace-restart cycles) plus small custom scripts: a ~12 Hz suppression-mask
poller, a kernel-side TCI egress meter (ss bytes_acked deltas — independent of the app's
own accounting), and operator-run iptables blocks to induce genuine Kiwi stalls. Headline
measurements: disconnect kills the silence-fill injection (384 kB/s → 0 B/s within a
tick, mask cleared); the latch holds unbroken through an abrupt-client-kill +
in-grace-restart cycle and releases cleanly on grace expiry; the engage/clear/stall/resume
regression sweep passes with decodes tracking the source at every step.

Evidence attached (zip), one folder per claim: item1-disconnect-reset/, item2-latch/,
and regression-sweep/ each hold a RESULT.md walking through that item plus the raw
get dax snapshots and app-log excerpts backing it. Also inside: the full sanitized session
log, the custom scripts (tools/), a README explaining every artifact, and a REPRODUCE.md
step-by-step if you want to re-run any of it on your own rig.

Two disclosures:

  1. The Linux test binary was this head plus one read-only instrumentation commit
    (30b6e1a7
    cherry-picked, not in this PR) exposing externalDaxSourceMask in the bridge's get dax,
    so the mask assertions are direct reads.

  2. Potential new issue uncovered: While testing the latch's channel-takeover case
    (reassigning the Kiwi slice's DAX channel to a
    second slice via the DAX pulldown), the app went into a runaway slice set dax= command
    loop — up to ~50 commands/s to the radio — so channel ownership never settled and the
    "second slice ends up with clean Flex audio" end state was never reached. Two things I
    can say from the captures (in item2-latch/): the latch itself behaved correctly the
    whole time (at every one of 422 mask samples it suppressed only while the Kiwi slice
    truly owned the channel), and the loop is not from this branch — it reproduces with the
    Kiwi feature fully disengaged. It looks related to the DAX + TCI re-assert storm: continuous "slice set N dax=<ch>" spam when WSJT-X connects via TCI with DAX active (Linux/macOS) #4009 family but survives that
    fix (fw 4.2.20.41343). I'm not sure whether this storm is already a known/tracked
    issue — if it isn't, I can go ahead and file it with the full captures.

pr4026-nf0t-evidence-2026-07-11.zip

@skerker
skerker requested review from NF0T and ten9876 July 12, 2026 02:13

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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→N rebroadcast is closed by the new m_kiwiDaxLatchedChannels latch, which I traced through RadioModel'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 3b118a22 landed); current code already uses the shared isValidDaxChannel predicate.

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.

@jensenpat

jensenpat commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

@skerker Heads up, planning to merge in next week's release 7/26.

@jensenpat
jensenpat marked this pull request as draft July 17, 2026 16:24
@jensenpat
jensenpat marked this pull request as ready for review July 19, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement: Route KiwiSDR RX audio to the slice's DAX channel so WSJT-X / digimodes can decode it

4 participants