From bd229c2b1127c79523e700e955ff8cbaf1b57783 Mon Sep 17 00:00:00 2001 From: skerker <7691216+skerker@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:41:16 -0700 Subject: [PATCH 1/6] feat(kiwi): route KiwiSDR audio to the slice's DAX channel for WSJT-X/digimodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/PanadapterStream.cpp | 23 +++++++++++++++++ src/core/PanadapterStream.h | 14 +++++++++++ src/gui/MainWindow.h | 5 ++++ src/gui/MainWindow_KiwiSdr.cpp | 46 ++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/src/core/PanadapterStream.cpp b/src/core/PanadapterStream.cpp index 96eeae368..c38950b42 100644 --- a/src/core/PanadapterStream.cpp +++ b/src/core/PanadapterStream.cpp @@ -765,6 +765,13 @@ void PanadapterStream::processDatagram(const QByteArray& data) if (daxChannel >= 0) { int channel = daxChannel; + // A KiwiSDR is supplying this channel's audio (injectDaxAudio) — drop + // the Flex payload so WSJT-X hears only the Kiwi. (feat/kiwi-audio-to-dax) + if (channel >= 0 && channel < 32 + && (m_kiwiSuppressedDaxMask.load(std::memory_order_relaxed) + & (1u << channel))) { + return; + } QByteArray pcm; if (pcc == PCC_IF_NARROW) { // Float32 stereo big-endian from radio → native float32 stereo @@ -1447,6 +1454,22 @@ quint32 PanadapterStream::daxStreamIdForChannel(int channel) const return 0; } +void PanadapterStream::injectDaxAudio(int channel, const QByteArray& pcm) +{ + // Feed non-Flex audio (KiwiSDR) onto a DAX channel using the same signal + // the Flex path uses, so TciServer / the DAX bridge need no changes. `pcm` + // is already the native DAX format (24 kHz stereo float32); no repackaging + // is required — consumers handle arbitrary payload sizes. (feat/kiwi-audio-to-dax) + if (channel < 0 || pcm.isEmpty()) + return; + emit daxAudioReady(channel, pcm); +} + +void PanadapterStream::setKiwiSuppressedDaxMask(quint32 mask) +{ + m_kiwiSuppressedDaxMask.store(mask, std::memory_order_relaxed); +} + void PanadapterStream::unregisterDaxStream(quint32 streamId) { int channel = 0; diff --git a/src/core/PanadapterStream.h b/src/core/PanadapterStream.h index 67fa6cc28..e899713bc 100644 --- a/src/core/PanadapterStream.h +++ b/src/core/PanadapterStream.h @@ -167,6 +167,16 @@ class PanadapterStream : public QObject { }; QVector daxChannelSnapshot() const; + // External DAX audio injection (KiwiSDR → DAX/TCI, feat/kiwi-audio-to-dax). + // Emits `daxAudioReady(channel, pcm)` for audio that did not come from the + // Flex (e.g. a KiwiSDR replacing a slice's receive source). `pcm` must be + // the native DAX format: 24 kHz stereo float32. Safe to call from the GUI + // thread. `setKiwiSuppressedDaxMask` marks channels (bit N = channel N) + // whose *Flex* DAX payload must be dropped, so the injected audio is the + // only thing WSJT-X hears on that channel; read lock-free on the RX thread. + void injectDaxAudio(int channel, const QByteArray& pcm); + void setKiwiSuppressedDaxMask(quint32 mask); + // DAX IQ stream routing void registerIqStream(quint32 streamId, int channel); void unregisterIqStream(quint32 streamId); @@ -402,6 +412,10 @@ private slots: int audioPacketJitterMs() const { return m_audioPacketJitterMs.load(); } private: + // Bit N set => drop the Flex DAX payload for channel N (a KiwiSDR is + // supplying that channel's audio instead). Read on the RX parse thread, + // written from the GUI thread. (feat/kiwi-audio-to-dax) + std::atomic m_kiwiSuppressedDaxMask{0}; std::atomic m_totalRxBytes{0}; std::atomic m_totalTxBytes{0}; std::atomic m_audioPacketGapMs{0}; diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 147574a9d..09181ffda 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -356,6 +356,11 @@ private slots: void syncKiwiSdrTransmitMute(); void setKiwiSdrVirtualAntennaForSlice(int sliceId, const QString& profileId); void clearKiwiSdrVirtualAntennaForSlice(int sliceId); + // Route a KiwiSDR profile's decoded audio onto its slice's DAX channel so + // WSJT-X (over DAX/TCI) decodes the Kiwi, and keep the "suppress the Flex + // payload on Kiwi-owned DAX channels" mask current. (feat/kiwi-audio-to-dax) + void routeKiwiSdrAudioToDax(const QString& profileId, const QByteArray& pcm); + void refreshKiwiSdrDaxSuppression(); void updateKiwiSdrVirtualTrackingForSlice(SliceModel* slice); void updateKiwiSdrVirtualAudioControlsForSlice(SliceModel* slice); void updateKiwiSdrVirtualReceiverControlsForSlice(SliceModel* slice); diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index 4921f7239..d75d6a0a9 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -15,6 +15,7 @@ #include "core/KiwiSdrManager.h" #include "core/KiwiSdrProtocol.h" #include "core/LogManager.h" +#include "core/PanadapterStream.h" #include "models/BandSettings.h" #include "models/RadioModel.h" #include "models/SliceModel.h" @@ -560,6 +561,7 @@ void MainWindow::setKiwiSdrVirtualAntennaForSlice(int sliceId, m_kiwiSdrVirtualPreviousMute.insert(sliceId, slice->flexAudioMute()); } slice->setExternalReceiveAudioReplacementMute(true); + refreshKiwiSdrDaxSuppression(); // (feat/kiwi-audio-to-dax) if (m_appletPanel) { m_appletPanel->updateSliceButtons(m_radioModel.slices(), m_activeSliceId); } @@ -611,6 +613,42 @@ void MainWindow::setKiwiSdrVirtualAntennaForSlice(int sliceId, | KiwiSdrUiSyncWaterfallAvailability); } +void MainWindow::routeKiwiSdrAudioToDax(const QString& profileId, + const QByteArray& pcm) +{ + if (!m_kiwiSdrManager || pcm.isEmpty()) + return; + const int sliceId = m_kiwiSdrManager->assignedSliceForProfile(profileId); + if (sliceId < 0) + return; + SliceModel* slice = m_radioModel.slice(sliceId); + if (!slice || !slice->externalReceiveReplacementActive()) + return; + const int channel = slice->daxChannel(); + if (channel <= 0) + return; // no DAX channel bound yet (no TCI/DAX client on this slice) + // TCI allocates the DAX channel lazily on audio_start — it may have bound + // after the Kiwi overlay engaged — so refresh the suppress mask here to be + // sure the Flex payload is dropped the moment we start injecting. + refreshKiwiSdrDaxSuppression(); + if (PanadapterStream* pan = m_radioModel.panStream()) + pan->injectDaxAudio(channel, pcm); +} + +void MainWindow::refreshKiwiSdrDaxSuppression() +{ + quint32 mask = 0; + for (SliceModel* slice : m_radioModel.slices()) { + if (slice && slice->externalReceiveReplacementActive()) { + const int channel = slice->daxChannel(); + if (channel > 0 && channel < 32) + mask |= (1u << channel); + } + } + if (PanadapterStream* pan = m_radioModel.panStream()) + pan->setKiwiSuppressedDaxMask(mask); +} + void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) { qCInfo(lcKiwiSdr).noquote() @@ -624,6 +662,7 @@ void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) ? m_kiwiSdrVirtualPreviousMute.take(sliceId) : slice->flexAudioMute(); slice->setExternalReceiveAudioReplacementMute(false, restoreMute); + refreshKiwiSdrDaxSuppression(); // (feat/kiwi-audio-to-dax) if (m_appletPanel) { m_appletPanel->updateSliceButtons(m_radioModel.slices(), m_activeSliceId); } @@ -1525,6 +1564,12 @@ void MainWindow::wireKiwiSdr() const QByteArray& pcm) { audio->feedKiwiSdrAudioData(id, pcm); }, 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, + this, [this](const QString& id, const QByteArray& pcm) { + routeKiwiSdrAudioToDax(id, pcm); + }, Qt::QueuedConnection); connect(m_kiwiSdrManager, &KiwiSdrManager::audioSourceEnabledChanged, m_audio, [audio = m_audio](const QString& id, bool enabled) { audio->setKiwiSdrAudioSourceEnabled(id, enabled); @@ -1742,6 +1787,7 @@ void MainWindow::wireKiwiSdr() ? m_kiwiSdrVirtualPreviousMute.take(sliceId) : slice->flexAudioMute(); slice->setExternalReceiveAudioReplacementMute(false, restoreMute); + refreshKiwiSdrDaxSuppression(); // (feat/kiwi-audio-to-dax) if (m_appletPanel) { m_appletPanel->updateSliceButtons( m_radioModel.slices(), m_activeSliceId); From 4a379fecdff5fa739d2506565975d85e27e91476 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:06:13 -0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(kiwi):=20make=20DAX=20suppression=20mas?= =?UTF-8?q?k=20event-driven=20=E2=80=94=20Principle=20II.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups from #4026 (all inline comments + review summary): - Mask lifecycle is now event-driven: refreshKiwiSdrDaxSuppression() is wired to SliceModel::daxChannelChanged (mirroring wireDaxSlice, #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 --- src/core/PanadapterStream.cpp | 45 +++++++--- src/core/PanadapterStream.h | 35 +++++--- src/gui/MainWindow.h | 15 +++- src/gui/MainWindow_KiwiSdr.cpp | 150 ++++++++++++++++++++++++++++++--- 4 files changed, 208 insertions(+), 37 deletions(-) diff --git a/src/core/PanadapterStream.cpp b/src/core/PanadapterStream.cpp index c38950b42..9bc29ca78 100644 --- a/src/core/PanadapterStream.cpp +++ b/src/core/PanadapterStream.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -465,6 +466,10 @@ void PanadapterStream::clearRegisteredStreams() m_orphanStreams.clear(); m_everRegisteredPanStreams.clear(); // new session — re-arm from scratch (#3856) m_everRegisteredWfStreams.clear(); + // The external-source suppression mask is session state too — a bit left + // set across a disconnect would silently mute that DAX channel on the + // next radio connection. (feat/kiwi-audio-to-dax) + m_externalDaxSourceMask.store(0, std::memory_order_relaxed); resetAudioStreamStats(); qCDebug(lcVita49) << "PanadapterStream: cleared all registered streams"; } @@ -765,10 +770,11 @@ void PanadapterStream::processDatagram(const QByteArray& data) if (daxChannel >= 0) { int channel = daxChannel; - // A KiwiSDR is supplying this channel's audio (injectDaxAudio) — drop - // the Flex payload so WSJT-X hears only the Kiwi. (feat/kiwi-audio-to-dax) - if (channel >= 0 && channel < 32 - && (m_kiwiSuppressedDaxMask.load(std::memory_order_relaxed) + // An external source (injectDaxAudio, e.g. a KiwiSDR) is supplying + // this channel's audio — drop the Flex payload so the two sources + // don't mix. (feat/kiwi-audio-to-dax) + if (isValidDaxChannel(channel) + && (m_externalDaxSourceMask.load(std::memory_order_relaxed) & (1u << channel))) { return; } @@ -1456,18 +1462,35 @@ quint32 PanadapterStream::daxStreamIdForChannel(int channel) const void PanadapterStream::injectDaxAudio(int channel, const QByteArray& pcm) { - // Feed non-Flex audio (KiwiSDR) onto a DAX channel using the same signal - // the Flex path uses, so TciServer / the DAX bridge need no changes. `pcm` - // is already the native DAX format (24 kHz stereo float32); no repackaging - // is required — consumers handle arbitrary payload sizes. (feat/kiwi-audio-to-dax) - if (channel < 0 || pcm.isEmpty()) + // Feed non-Flex audio (e.g. a KiwiSDR) onto a DAX channel using the same + // signal the Flex path uses, so TciServer / the DAX bridge need no + // changes. `pcm` is already the native DAX format — 24 kHz stereo + // float32: both Flex packet-class paths above (PCC_IF_NARROW and + // PCC_IF_NARROW_REDUCED, fw 4.2.18) normalize to exactly that before + // `daxAudioReady`, and KiwiSdrClient resamples its 12 kHz feed to match. + // No repackaging is required — consumers handle arbitrary payload sizes. + // (feat/kiwi-audio-to-dax) + if (!isValidDaxChannel(channel) || pcm.isEmpty()) { return; + } + // Every Flex-path emission of daxAudioReady happens on this object's + // network thread; AutoConnection consumers (TciServer, DaxBridge) rely on + // that to get queued delivery. Re-invoke so an injection from the GUI + // thread keeps the identical contract instead of running the consumers' + // resampler/socket work synchronously inside the caller's slot. + if (QThread::currentThread() != thread()) { + QMetaObject::invokeMethod( + this, + [this, channel, pcm]() { emit daxAudioReady(channel, pcm); }, + Qt::QueuedConnection); + return; + } emit daxAudioReady(channel, pcm); } -void PanadapterStream::setKiwiSuppressedDaxMask(quint32 mask) +void PanadapterStream::setExternalDaxSourceMask(quint32 mask) { - m_kiwiSuppressedDaxMask.store(mask, std::memory_order_relaxed); + m_externalDaxSourceMask.store(mask, std::memory_order_relaxed); } void PanadapterStream::unregisterDaxStream(quint32 streamId) diff --git a/src/core/PanadapterStream.h b/src/core/PanadapterStream.h index e899713bc..b993afd85 100644 --- a/src/core/PanadapterStream.h +++ b/src/core/PanadapterStream.h @@ -167,15 +167,29 @@ class PanadapterStream : public QObject { }; QVector daxChannelSnapshot() const; - // External DAX audio injection (KiwiSDR → DAX/TCI, feat/kiwi-audio-to-dax). + // External DAX audio injection (feat/kiwi-audio-to-dax). // Emits `daxAudioReady(channel, pcm)` for audio that did not come from the // Flex (e.g. a KiwiSDR replacing a slice's receive source). `pcm` must be - // the native DAX format: 24 kHz stereo float32. Safe to call from the GUI - // thread. `setKiwiSuppressedDaxMask` marks channels (bit N = channel N) - // whose *Flex* DAX payload must be dropped, so the injected audio is the - // only thing WSJT-X hears on that channel; read lock-free on the RX thread. + // the native DAX format: 24 kHz stereo float32 — both Flex packet-class + // paths (PCC_IF_NARROW and PCC_IF_NARROW_REDUCED, fw 4.2.18) normalize to + // that before `daxAudioReady`, and the external source must match it; a + // firmware DAX-rate change breaks this assumption (pitch shift), so + // re-verify here first. Callable from any thread — the emission is + // re-invoked onto this object's (network) thread so consumers see the + // same thread contract as the Flex path. `setExternalDaxSourceMask` marks + // channels (bit N = channel N) whose *Flex* DAX payload must be dropped, + // so the injected audio is the only thing WSJT-X hears on that channel; + // read lock-free on the RX thread. void injectDaxAudio(int channel, const QByteArray& pcm); - void setKiwiSuppressedDaxMask(quint32 mask); + void setExternalDaxSourceMask(quint32 mask); + + // The one definition of a routable DAX audio channel (1..4 on current + // radios; bounded above by the 32-bit suppression mask). Keep every + // channel-validity check on this predicate so the bounds can't diverge. + static constexpr bool isValidDaxChannel(int channel) + { + return channel >= 1 && channel < 32; + } // DAX IQ stream routing void registerIqStream(quint32 streamId, int channel); @@ -412,10 +426,11 @@ private slots: int audioPacketJitterMs() const { return m_audioPacketJitterMs.load(); } private: - // Bit N set => drop the Flex DAX payload for channel N (a KiwiSDR is - // supplying that channel's audio instead). Read on the RX parse thread, - // written from the GUI thread. (feat/kiwi-audio-to-dax) - std::atomic m_kiwiSuppressedDaxMask{0}; + // Bit N set => drop the Flex DAX payload for channel N (an external + // source — e.g. a KiwiSDR — is supplying that channel's audio via + // injectDaxAudio instead). Read on the RX parse thread, written from the + // GUI thread. (feat/kiwi-audio-to-dax) + std::atomic m_externalDaxSourceMask{0}; std::atomic m_totalRxBytes{0}; std::atomic m_totalTxBytes{0}; std::atomic m_audioPacketGapMs{0}; diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 09181ffda..e801ab0a2 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -357,10 +357,13 @@ private slots: void setKiwiSdrVirtualAntennaForSlice(int sliceId, const QString& profileId); void clearKiwiSdrVirtualAntennaForSlice(int sliceId); // Route a KiwiSDR profile's decoded audio onto its slice's DAX channel so - // WSJT-X (over DAX/TCI) decodes the Kiwi, and keep the "suppress the Flex - // payload on Kiwi-owned DAX channels" mask current. (feat/kiwi-audio-to-dax) + // WSJT-X (over DAX/TCI) decodes the Kiwi. The "suppress the Flex payload + // on Kiwi-fed DAX channels" mask is kept current event-driven — see + // refreshKiwiSdrDaxSuppression — and kiwiSdrDaxStallTick feeds silence + // while a suppressed channel's Kiwi source stalls. (feat/kiwi-audio-to-dax) void routeKiwiSdrAudioToDax(const QString& profileId, const QByteArray& pcm); void refreshKiwiSdrDaxSuppression(); + void kiwiSdrDaxStallTick(); void updateKiwiSdrVirtualTrackingForSlice(SliceModel* slice); void updateKiwiSdrVirtualAudioControlsForSlice(SliceModel* slice); void updateKiwiSdrVirtualReceiverControlsForSlice(SliceModel* slice); @@ -945,6 +948,14 @@ private slots: QMetaObject::Connection m_kiwiSdrAudioMuteConnection; QHash m_kiwiSdrVirtualPreviousMute; QSet m_kiwiSdrFlexDisplayPans; + // Kiwi→DAX stall watchdog (feat/kiwi-audio-to-dax): monotonic clock, + // per-channel last-Kiwi-chunk timestamps for suppressed channels, the + // channels currently being silence-filled, and the tick timer (runs only + // while the suppression mask is non-zero). + QElapsedTimer m_kiwiDaxClock; + QHash m_kiwiDaxLastAudioMs; + QSet m_kiwiDaxStalledChannels; + QTimer* m_kiwiDaxStallTimer{nullptr}; ReceivePresentationSync m_receivePresentationSync; ReceiveAudioDelayEstimator m_receiveAudioDelayEstimator; ReceivePresentationQueue> m_receivePresentationVisualQueue; diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index d75d6a0a9..840ca4615 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -613,40 +613,132 @@ void MainWindow::setKiwiSdrVirtualAntennaForSlice(int sliceId, | KiwiSdrUiSyncWaterfallAvailability); } +namespace { +// Kiwi→DAX stall watchdog (feat/kiwi-audio-to-dax): while a channel's Flex +// payload is suppressed, the Kiwi must keep it fed — see kiwiSdrDaxStallTick. +constexpr int kKiwiDaxStallTickMs = 250; // watchdog cadence +constexpr int kKiwiDaxStallThresholdMs = 400; // > worst-case Kiwi chunk jitter +} // namespace + void MainWindow::routeKiwiSdrAudioToDax(const QString& profileId, const QByteArray& pcm) { - if (!m_kiwiSdrManager || pcm.isEmpty()) + if (!m_kiwiSdrManager || pcm.isEmpty()) { return; + } const int sliceId = m_kiwiSdrManager->assignedSliceForProfile(profileId); - if (sliceId < 0) + if (sliceId < 0) { return; + } SliceModel* slice = m_radioModel.slice(sliceId); - if (!slice || !slice->externalReceiveReplacementActive()) + if (!slice || !slice->externalReceiveReplacementActive()) { return; + } const int channel = slice->daxChannel(); - if (channel <= 0) + if (!PanadapterStream::isValidDaxChannel(channel)) { return; // no DAX channel bound yet (no TCI/DAX client on this slice) - // TCI allocates the DAX channel lazily on audio_start — it may have bound - // after the Kiwi overlay engaged — so refresh the suppress mask here to be - // sure the Flex payload is dropped the moment we start injecting. - refreshKiwiSdrDaxSuppression(); - if (PanadapterStream* pan = m_radioModel.panStream()) + } + if (m_kiwiDaxClock.isValid()) { + m_kiwiDaxLastAudioMs.insert(channel, m_kiwiDaxClock.elapsed()); + } + if (m_kiwiDaxStalledChannels.remove(channel)) { + qCInfo(lcKiwiSdr) << "KiwiSDR audio resumed on DAX channel" << channel; + } + if (PanadapterStream* pan = m_radioModel.panStream()) { pan->injectDaxAudio(channel, pcm); + } } +// Recompute the Flex-suppression mask from live slice state. Event-driven: +// wired to SliceModel::daxChannelChanged, RadioModel::sliceRemoved, and the +// Kiwi assign/clear lifecycle (wireKiwiSdr), so it stays correct even while +// no Kiwi audio is flowing (connect window, stall, reconnect backoff) and +// after teardowns where the slice is already gone — it must never depend on +// looking a particular slice up. void MainWindow::refreshKiwiSdrDaxSuppression() { quint32 mask = 0; for (SliceModel* slice : m_radioModel.slices()) { if (slice && slice->externalReceiveReplacementActive()) { const int channel = slice->daxChannel(); - if (channel > 0 && channel < 32) + if (PanadapterStream::isValidDaxChannel(channel)) { mask |= (1u << channel); + } + } + } + if (PanadapterStream* pan = m_radioModel.panStream()) { + pan->setExternalDaxSourceMask(mask); + } + + // Watchdog bookkeeping: track exactly the suppressed channels. A channel + // that just became suppressed is armed "as of now" so the stall timer + // covers the initial websocket connect window too. + if (!m_kiwiDaxClock.isValid()) { + m_kiwiDaxClock.start(); + } + const qint64 now = m_kiwiDaxClock.elapsed(); + for (auto it = m_kiwiDaxLastAudioMs.begin(); + it != m_kiwiDaxLastAudioMs.end();) { + if (!(mask & (1u << it.key()))) { + m_kiwiDaxStalledChannels.remove(it.key()); + it = m_kiwiDaxLastAudioMs.erase(it); + } else { + ++it; + } + } + for (int channel = 1; channel < 32; ++channel) { + if ((mask & (1u << channel)) + && !m_kiwiDaxLastAudioMs.contains(channel)) { + m_kiwiDaxLastAudioMs.insert(channel, now); + } + } + if (mask != 0) { + if (!m_kiwiDaxStallTimer) { + m_kiwiDaxStallTimer = new QTimer(this); + m_kiwiDaxStallTimer->setInterval(kKiwiDaxStallTickMs); + connect(m_kiwiDaxStallTimer, &QTimer::timeout, + this, &MainWindow::kiwiSdrDaxStallTick); + } + if (!m_kiwiDaxStallTimer->isActive()) { + m_kiwiDaxStallTimer->start(); + } + } else if (m_kiwiDaxStallTimer) { + m_kiwiDaxStallTimer->stop(); + } +} + +// While a channel is suppressed but the Kiwi has stopped delivering (stall, +// reconnect backoff, initial connect), neither source reaches the DAX +// consumers — they are all push-only, so WSJT-X's input would freeze +// mid-stream with no indication which side failed. Feed silence at the +// native DAX format instead: decoders keep their clocks, and real audio +// resumes seamlessly when the Kiwi recovers. +void MainWindow::kiwiSdrDaxStallTick() +{ + PanadapterStream* pan = m_radioModel.panStream(); + if (!pan || !m_kiwiDaxClock.isValid()) { + return; + } + const qint64 now = m_kiwiDaxClock.elapsed(); + for (auto it = m_kiwiDaxLastAudioMs.cbegin(); + it != m_kiwiDaxLastAudioMs.cend(); ++it) { + if (now - it.value() < kKiwiDaxStallThresholdMs) { + continue; } + const int channel = it.key(); + if (!m_kiwiDaxStalledChannels.contains(channel)) { + m_kiwiDaxStalledChannels.insert(channel); + qCInfo(lcKiwiSdr) + << "KiwiSDR audio stalled on DAX channel" << channel + << "- 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(sizeof(float)), + '\0'); + pan->injectDaxAudio(channel, silence); } - if (PanadapterStream* pan = m_radioModel.panStream()) - pan->setKiwiSuppressedDaxMask(mask); } void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) @@ -662,7 +754,6 @@ void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) ? m_kiwiSdrVirtualPreviousMute.take(sliceId) : slice->flexAudioMute(); slice->setExternalReceiveAudioReplacementMute(false, restoreMute); - refreshKiwiSdrDaxSuppression(); // (feat/kiwi-audio-to-dax) if (m_appletPanel) { m_appletPanel->updateSliceButtons(m_radioModel.slices(), m_activeSliceId); } @@ -672,6 +763,10 @@ void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) spectrum->setKiwiSdrWaterfallProfile(QString()); } } + // Outside the slice lookup on purpose: during slice removal RadioModel + // has already dropped the slice, and the mask must still be recomputed. + // (feat/kiwi-audio-to-dax) + refreshKiwiSdrDaxSuppression(); if (!panId.isEmpty()) { m_kiwiSdrFlexDisplayPans.remove(panId); @@ -1579,6 +1674,29 @@ void MainWindow::wireKiwiSdr() audio->removeKiwiSdrAudioSource(id); }, Qt::QueuedConnection); } + // Keep the DAX suppression mask event-driven (feat/kiwi-audio-to-dax): + // a slice's DAX channel can (re)bind lazily (TCI audio_start) or drop + // to 0 (WSJT-X exit) while no Kiwi audio is flowing, so the refresh + // must follow the transitions themselves — mirroring wireDaxSlice + // (MainWindow_DigitalModes.cpp, #2895) — not the audio chunks. + const auto wireKiwiDaxSlice = [this](SliceModel* s) { + if (!s) { + return; + } + connect(s, &SliceModel::daxChannelChanged, + this, [this](int) { refreshKiwiSdrDaxSuppression(); }); + }; + for (SliceModel* s : m_radioModel.slices()) { + wireKiwiDaxSlice(s); + } + connect(&m_radioModel, &RadioModel::sliceAdded, + this, wireKiwiDaxSlice); + // RadioModel drops the slice from its list BEFORE emitting + // sliceRemoved; refreshKiwiSdrDaxSuppression recomputes from the + // remaining slices, so this is exactly what clears the removed + // slice's mask bit. + connect(&m_radioModel, &RadioModel::sliceRemoved, + this, [this](int) { refreshKiwiSdrDaxSuppression(); }); connect(&m_radioModel.transmitModel(), &TransmitModel::moxChanged, this, [this](bool) { syncKiwiSdrTransmitMute(); }); connect(&m_radioModel.transmitModel(), &TransmitModel::tuneChanged, @@ -1787,7 +1905,6 @@ void MainWindow::wireKiwiSdr() ? m_kiwiSdrVirtualPreviousMute.take(sliceId) : slice->flexAudioMute(); slice->setExternalReceiveAudioReplacementMute(false, restoreMute); - refreshKiwiSdrDaxSuppression(); // (feat/kiwi-audio-to-dax) if (m_appletPanel) { m_appletPanel->updateSliceButtons( m_radioModel.slices(), m_activeSliceId); @@ -1799,6 +1916,11 @@ void MainWindow::wireKiwiSdr() spectrum->setKiwiSdrWaterfallProfile(QString()); } } + // After the replacement-mute restore, and outside the slice + // lookup on purpose: during slice removal RadioModel has already + // dropped the slice, and the mask must still be recomputed. + // (feat/kiwi-audio-to-dax) + refreshKiwiSdrDaxSuppression(); scheduleKiwiSdrUiSync(KiwiSdrUiSyncWaterfallAvailability | KiwiSdrUiSyncDiversityEsc); syncKiwiSdrPanadapterUiState(panId); From 9503471619722330453c563dda963ef86d7019e8 Mon Sep 17 00:00:00 2001 From: skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:04:48 -0700 Subject: [PATCH 3/6] fix(kiwi): route DAX calls through RadioModel for engine-boundary EB3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/gui/MainWindow_KiwiSdr.cpp | 18 ++++++------------ src/models/RadioModel.cpp | 20 ++++++++++++++++++++ src/models/RadioModel.h | 9 +++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index 840ca4615..d53edb4c9 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -15,7 +15,6 @@ #include "core/KiwiSdrManager.h" #include "core/KiwiSdrProtocol.h" #include "core/LogManager.h" -#include "core/PanadapterStream.h" #include "models/BandSettings.h" #include "models/RadioModel.h" #include "models/SliceModel.h" @@ -635,7 +634,7 @@ void MainWindow::routeKiwiSdrAudioToDax(const QString& profileId, return; } const int channel = slice->daxChannel(); - if (!PanadapterStream::isValidDaxChannel(channel)) { + if (!m_radioModel.isValidDaxChannel(channel)) { return; // no DAX channel bound yet (no TCI/DAX client on this slice) } if (m_kiwiDaxClock.isValid()) { @@ -644,9 +643,7 @@ void MainWindow::routeKiwiSdrAudioToDax(const QString& profileId, if (m_kiwiDaxStalledChannels.remove(channel)) { qCInfo(lcKiwiSdr) << "KiwiSDR audio resumed on DAX channel" << channel; } - if (PanadapterStream* pan = m_radioModel.panStream()) { - pan->injectDaxAudio(channel, pcm); - } + m_radioModel.injectDaxAudio(channel, pcm); } // Recompute the Flex-suppression mask from live slice state. Event-driven: @@ -661,14 +658,12 @@ void MainWindow::refreshKiwiSdrDaxSuppression() for (SliceModel* slice : m_radioModel.slices()) { if (slice && slice->externalReceiveReplacementActive()) { const int channel = slice->daxChannel(); - if (PanadapterStream::isValidDaxChannel(channel)) { + if (m_radioModel.isValidDaxChannel(channel)) { mask |= (1u << channel); } } } - if (PanadapterStream* pan = m_radioModel.panStream()) { - pan->setExternalDaxSourceMask(mask); - } + m_radioModel.setExternalDaxSourceMask(mask); // Watchdog bookkeeping: track exactly the suppressed channels. A channel // that just became suppressed is armed "as of now" so the stall timer @@ -715,8 +710,7 @@ void MainWindow::refreshKiwiSdrDaxSuppression() // resumes seamlessly when the Kiwi recovers. void MainWindow::kiwiSdrDaxStallTick() { - PanadapterStream* pan = m_radioModel.panStream(); - if (!pan || !m_kiwiDaxClock.isValid()) { + if (!m_radioModel.hasPanStream() || !m_kiwiDaxClock.isValid()) { return; } const qint64 now = m_kiwiDaxClock.elapsed(); @@ -737,7 +731,7 @@ void MainWindow::kiwiSdrDaxStallTick() 24000 * kKiwiDaxStallTickMs / 1000 * 2 * static_cast(sizeof(float)), '\0'); - pan->injectDaxAudio(channel, silence); + m_radioModel.injectDaxAudio(channel, silence); } } diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 89b0c1ce0..4e912f894 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -6415,6 +6415,26 @@ bool RadioModel::ensureDaxTxStream(DaxTxRequestReason reason) return true; } +// Seam forwarders (engine-boundary EB3): let above-seam callers reach the DAX +// stream through the model instead of including the vendor PanadapterStream +// header. Pure delegation — no behavior of its own. +bool RadioModel::isValidDaxChannel(int channel) const +{ + return PanadapterStream::isValidDaxChannel(channel); +} + +void RadioModel::injectDaxAudio(int channel, const QByteArray& pcm) +{ + if (m_panStream) + m_panStream->injectDaxAudio(channel, pcm); +} + +void RadioModel::setExternalDaxSourceMask(quint32 mask) +{ + if (m_panStream) + m_panStream->setExternalDaxSourceMask(mask); +} + QJsonObject RadioModel::troubleshootingSnapshot() const { QJsonObject snapshot; diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 54411beb2..bd7e46d58 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -69,6 +69,15 @@ class RadioModel : public QObject { // Access the underlying connection and panadapter stream RadioConnection* connection() { return m_connection; } PanadapterStream* panStream() { return m_panStream; } + + // Seam forwarders for above-seam callers (GUI) that must not include the + // vendor PanadapterStream header directly (engine-boundary EB3). RadioModel + // already owns the stream, so DAX-channel helpers route through here rather + // than leaking the vendor type into the GUI. + bool hasPanStream() const { return m_panStream != nullptr; } + bool isValidDaxChannel(int channel) const; + void injectDaxAudio(int channel, const QByteArray& pcm); + void setExternalDaxSourceMask(quint32 mask); // Sub-models owned by RadioModel (main thread). (#502) MeterModel& meterModel() { return m_meterModel; } TunerModel& tunerModel() { return m_tunerModel; } From a12f5c4ded7c06ef1593399b6234062c55d0db07 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:14:22 -0700 Subject: [PATCH 4/6] fix(kiwi): latch DAX suppression across transient dax=0 rebroadcasts. Principle II. The radio's dax=0->N status rebroadcast (TCI re-arm, restart within the #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 (#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 --- src/gui/MainWindow.h | 8 ++++ src/gui/MainWindow_KiwiSdr.cpp | 69 ++++++++++++++++++++++++++++++++-- src/models/RadioModel.cpp | 4 ++ src/models/RadioModel.h | 6 +++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index e801ab0a2..e551abab5 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -956,6 +956,14 @@ private slots: QHash m_kiwiDaxLastAudioMs; QSet m_kiwiDaxStalledChannels; QTimer* m_kiwiDaxStallTimer{nullptr}; + // Suppression latch (mirrors TciServer::m_channelTrx, #3669): sliceId → + // the last DAX channel that slice held while Kiwi-fed. Keeps the + // suppression bit set through the radio's transient dax=0→N status + // rebroadcasts so Flex payload can't dual-feed the consumer mid-flip. + // Invalidated on clear/slice-removal (refresh prune), genuine stream + // release (RadioModel::daxStreamUnregistered), channel takeover by + // another slice, and disconnect (resetKiwiSdrDaxSuppressionState). + QHash m_kiwiDaxLatchedChannels; ReceivePresentationSync m_receivePresentationSync; ReceiveAudioDelayEstimator m_receiveAudioDelayEstimator; ReceivePresentationQueue> m_receivePresentationVisualQueue; diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index d53edb4c9..561ccf21b 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -654,14 +654,54 @@ void MainWindow::routeKiwiSdrAudioToDax(const QString& profileId, // looking a particular slice up. void MainWindow::refreshKiwiSdrDaxSuppression() { + // Latch upkeep first: an entry whose slice is gone (removed — RadioModel + // has already dropped it) or no longer Kiwi-fed must not keep a bit alive. + for (auto it = m_kiwiDaxLatchedChannels.begin(); + it != m_kiwiDaxLatchedChannels.end();) { + SliceModel* slice = m_radioModel.slice(it.key()); + if (!slice || !slice->externalReceiveReplacementActive()) { + it = m_kiwiDaxLatchedChannels.erase(it); + } else { + ++it; + } + } + quint32 mask = 0; for (SliceModel* slice : m_radioModel.slices()) { - if (slice && slice->externalReceiveReplacementActive()) { - const int channel = slice->daxChannel(); - if (m_radioModel.isValidDaxChannel(channel)) { - mask |= (1u << channel); + if (!slice || !slice->externalReceiveReplacementActive()) { + continue; + } + const int channel = slice->daxChannel(); + if (m_radioModel.isValidDaxChannel(channel)) { + // Latch on assignment: remember the resolved channel so the bit + // survives the radio's transient dax=0→N rebroadcast (the + // dual-feed window) — mirroring TciServer::m_channelTrx (#3669). + m_kiwiDaxLatchedChannels.insert(slice->sliceId(), channel); + mask |= (1u << channel); + continue; + } + const auto latched = m_kiwiDaxLatchedChannels.constFind(slice->sliceId()); + if (latched == m_kiwiDaxLatchedChannels.constEnd()) { + continue; // never resolved a channel — nothing to hold + } + // dax reads 0: either a transient rebroadcast (hold the bit — this is + // the latch's whole purpose) or the channel moved on. If another slice + // now owns the latched channel, holding the bit would suppress that + // slice's live Flex feed — the orphaned-bit defect this lifecycle work + // exists to prevent — so the latch yields to the new owner. + bool takenOver = false; + for (SliceModel* other : m_radioModel.slices()) { + if (other && other != slice + && other->daxChannel() == latched.value()) { + takenOver = true; + break; } } + if (takenOver) { + m_kiwiDaxLatchedChannels.erase(latched); + } else { + mask |= (1u << latched.value()); + } } m_radioModel.setExternalDaxSourceMask(mask); @@ -1691,6 +1731,27 @@ void MainWindow::wireKiwiSdr() // slice's mask bit. connect(&m_radioModel, &RadioModel::sliceRemoved, this, [this](int) { refreshKiwiSdrDaxSuppression(); }); + // The latch (m_kiwiDaxLatchedChannels) deliberately holds a bit + // through a slice's transient dax=0; a channel whose dax_rx stream is + // genuinely gone (grace-expiry release after WSJT-X exit, radio-side + // removal) must drop out of it here, or the held bit would suppress + // Flex DAX for the channel's next holder. + connect(&m_radioModel, &RadioModel::daxStreamUnregistered, + this, [this](int channel) { + bool dropped = false; + for (auto it = m_kiwiDaxLatchedChannels.begin(); + it != m_kiwiDaxLatchedChannels.end();) { + if (it.value() == channel) { + it = m_kiwiDaxLatchedChannels.erase(it); + dropped = true; + } else { + ++it; + } + } + if (dropped) { + refreshKiwiSdrDaxSuppression(); + } + }); connect(&m_radioModel.transmitModel(), &TransmitModel::moxChanged, this, [this](bool) { syncKiwiSdrTransmitMute(); }); connect(&m_radioModel.transmitModel(), &TransmitModel::tuneChanged, diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 4e912f894..ad703d764 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -609,6 +609,10 @@ RadioModel::RadioModel(QObject* parent) if (!isConnected()) return; sendCommand(QString("stream remove 0x%1").arg(streamId, 0, 16)); }); + // Seam forward for above-seam consumers (EB3) — Qt drops the trailing + // streamId argument. + connect(m_panStream, &PanadapterStream::daxStreamUnregistered, + this, &RadioModel::daxStreamUnregistered); // RadioConnection (created + owned by the backend above, on its own worker // thread #502 so TCP I/O never blocks paintEvent) — wire its signals to us. diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index bd7e46d58..0ab423524 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -449,6 +449,12 @@ class RadioModel : public QObject { void cwKeyDownChanged(bool down); void sliceAdded(SliceModel* slice); void sliceRemoved(int sliceId); + // Seam forward of PanadapterStream::daxStreamUnregistered (streamId + // dropped): fires when a dax_rx stream is genuinely gone — radio-side + // removal or the #3305 grace-expiry release — as opposed to a slice's + // dax binding transiently reading 0 during a status rebroadcast. GUI + // consumers (kiwi DAX suppression latch) key invalidation off this. + void daxStreamUnregistered(int channel); void metersChanged(); void connectionError(const QString& msg); // Phase 2 of GHSA-wfx7-w6p8-4jr2 (#2951): forwarded from From ee9856016eb0f90affc905f437ca67c46a0ae4dd Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:15:31 -0700 Subject: [PATCH 5/6] fix(kiwi): reset the DAX stall watchdog on radio disconnect. Principle II. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/gui/MainWindow.cpp | 1 + src/gui/MainWindow.h | 1 + src/gui/MainWindow_KiwiSdr.cpp | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index ba2a563a9..05f01102c 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -5151,6 +5151,7 @@ void MainWindow::onConnectionStateChanged(bool connected) m_suppressStartupPanLayoutRearrange = false; m_layoutRestoreUntilMs = 0; clearKiwiSdrPanDisplaySourceOverrides(); + resetKiwiSdrDaxSuppressionState(); if (m_appletPanel) { m_appletPanel->clearSliceButtons(); } diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index e551abab5..31c7d2a14 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -364,6 +364,7 @@ private slots: void routeKiwiSdrAudioToDax(const QString& profileId, const QByteArray& pcm); void refreshKiwiSdrDaxSuppression(); void kiwiSdrDaxStallTick(); + void resetKiwiSdrDaxSuppressionState(); void updateKiwiSdrVirtualTrackingForSlice(SliceModel* slice); void updateKiwiSdrVirtualAudioControlsForSlice(SliceModel* slice); void updateKiwiSdrVirtualReceiverControlsForSlice(SliceModel* slice); diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index 561ccf21b..40889d23b 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -775,6 +775,25 @@ void MainWindow::kiwiSdrDaxStallTick() } } +// Radio disconnect: RadioModel stages slices out WITHOUT emitting +// sliceRemoved (they go to the stale-session store), so the event-driven +// refresh never runs and the core-side mask reset in clearRegisteredStreams() +// has no GUI-side sibling — the stall timer would keep injecting silence on +// stale channels into a fully disconnected app. Reset here, from the same +// disconnect hook that clears the other per-session Kiwi GUI state +// (onConnectionStateChanged → clearKiwiSdrPanDisplaySourceOverrides). +void MainWindow::resetKiwiSdrDaxSuppressionState() +{ + if (m_kiwiDaxStallTimer) { + m_kiwiDaxStallTimer->stop(); + } + m_kiwiDaxLastAudioMs.clear(); + m_kiwiDaxStalledChannels.clear(); + // Channel latch is per-session state too (TciServer clears m_channelTrx + // on disconnect for the same reason): reconnect re-resolves from scratch. + m_kiwiDaxLatchedChannels.clear(); +} + void MainWindow::clearKiwiSdrVirtualAntennaForSlice(int sliceId) { qCInfo(lcKiwiSdr).noquote() From 3b118a2219f2c6732f2921b766cd54829ef1229e Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:16:21 -0700 Subject: [PATCH 6/6] refactor(kiwi): re-arm loop on the shared DAX predicate; wire DAX routing 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 --- src/gui/MainWindow_KiwiSdr.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index 40889d23b..04716da0f 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -721,7 +721,7 @@ void MainWindow::refreshKiwiSdrDaxSuppression() ++it; } } - for (int channel = 1; channel < 32; ++channel) { + for (int channel = 1; m_radioModel.isValidDaxChannel(channel); ++channel) { if ((mask & (1u << channel)) && !m_kiwiDaxLastAudioMs.contains(channel)) { m_kiwiDaxLastAudioMs.insert(channel, now); @@ -1712,12 +1712,6 @@ void MainWindow::wireKiwiSdr() const QByteArray& pcm) { audio->feedKiwiSdrAudioData(id, pcm); }, 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, - this, [this](const QString& id, const QByteArray& pcm) { - routeKiwiSdrAudioToDax(id, pcm); - }, Qt::QueuedConnection); connect(m_kiwiSdrManager, &KiwiSdrManager::audioSourceEnabledChanged, m_audio, [audio = m_audio](const QString& id, bool enabled) { audio->setKiwiSdrAudioSourceEnabled(id, enabled); @@ -1727,6 +1721,15 @@ void MainWindow::wireKiwiSdr() audio->removeKiwiSdrAudioSource(id); }, Qt::QueuedConnection); } + // Route the Kiwi audio onto its slice's DAX channel so WSJT-X + // (DAX/TCI) decodes the Kiwi, not the muted Flex. Deliberately + // outside the if (m_audio) guard: DAX/TCI decode is independent of + // the local audio engine, and routeKiwiSdrAudioToDax never touches + // m_audio. (feat/kiwi-audio-to-dax) + connect(m_kiwiSdrManager, &KiwiSdrManager::decodedAudioReady, + this, [this](const QString& id, const QByteArray& pcm) { + routeKiwiSdrAudioToDax(id, pcm); + }, Qt::QueuedConnection); // Keep the DAX suppression mask event-driven (feat/kiwi-audio-to-dax): // a slice's DAX channel can (re)bind lazily (TCI audio_start) or drop // to 0 (WSJT-X exit) while no Kiwi audio is flowing, so the refresh