From 49fc50ebe0122b46234a396f6019a2aeae8e3255 Mon Sep 17 00:00:00 2001 From: Wayne Date: Fri, 24 Jul 2026 16:24:55 -0400 Subject: [PATCH 1/2] Offset KiwiSDR CW sound-stream BFO by the passband center (#4423) KiwiSDR's SET freq= is the BFO/mixdown point, not a dial frequency; low_cut/high_cut are applied as an audio passband relative to it. Our CW passband is already centered on the sidetone pitch above (or, for CWL, below) the carrier, so sending freq=carrier put the carrier at 0 Hz, outside the passband, and the tone was filtered out even though the tuned-frequency pip overlaid the signal. Extract SET mod=/low_cut=/high_cut=/freq= construction into KiwiSdrProtocol::formatSoundTuneCommand() so the CW BFO math is unit testable, and shift freq by the passband center for mode=="cw". Co-Authored-By: Claude Sonnet 5 --- src/core/KiwiSdrClient.cpp | 7 ++----- src/core/KiwiSdrProtocol.cpp | 23 +++++++++++++++++++++++ src/core/KiwiSdrProtocol.h | 2 ++ tests/kiwi_sdr_protocol_test.cpp | 14 ++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/core/KiwiSdrClient.cpp b/src/core/KiwiSdrClient.cpp index 4bed5f890..ba6bf6a1f 100644 --- a/src/core/KiwiSdrClient.cpp +++ b/src/core/KiwiSdrClient.cpp @@ -1391,11 +1391,8 @@ void KiwiSdrClient::sendTrackedSliceToServer() << "high_cut=" << highCutHz; return; } - sendSoundCommand(QStringLiteral("SET mod=%1 low_cut=%2 high_cut=%3 freq=%4") - .arg(mode) - .arg(lowCutHz) - .arg(highCutHz) - .arg(freqKhz, 0, 'f', 3)); + sendSoundCommand(KiwiSdrProtocol::formatSoundTuneCommand( + mode, lowCutHz, highCutHz, freqKhz)); } void KiwiSdrClient::sendReceiverControlsToServer() diff --git a/src/core/KiwiSdrProtocol.cpp b/src/core/KiwiSdrProtocol.cpp index 707e01be6..96936a273 100644 --- a/src/core/KiwiSdrProtocol.cpp +++ b/src/core/KiwiSdrProtocol.cpp @@ -1585,6 +1585,29 @@ QString formatAgcCommand(bool enabled, bool hang, int thresholdDb, .arg(clampedManualGain); } +QString formatSoundTuneCommand(const QString& mode, int lowCutHz, int highCutHz, + double freqKhz) +{ + double tunedFreqKhz = freqKhz; + if (mode == QStringLiteral("cw")) { + // The Kiwi's 'freq' is the BFO/mixdown point, not a dial frequency: + // low_cut/high_cut are applied as an audio passband relative to it. + // Our CW passband is already centered on the sidetone pitch above + // (or, for CWL, below) the carrier, so sending freq=carrier puts the + // carrier at 0 Hz — outside the passband — and the tone is filtered + // out (#4423). Shift the BFO by the passband center so the carrier + // lands inside the passband and beats to the pitch tone, matching + // what the operator hears from the Flex sidetone. + const double pitchHz = (lowCutHz + highCutHz) / 2.0; + tunedFreqKhz -= pitchHz / 1000.0; + } + return QStringLiteral("SET mod=%1 low_cut=%2 high_cut=%3 freq=%4") + .arg(mode) + .arg(lowCutHz) + .arg(highCutHz) + .arg(tunedFreqKhz, 0, 'f', 3); +} + MeterReading meterUnavailable(MeterSource source, const QString& notes) { MeterReading reading; diff --git a/src/core/KiwiSdrProtocol.h b/src/core/KiwiSdrProtocol.h index e3d781860..d0b1e0b85 100644 --- a/src/core/KiwiSdrProtocol.h +++ b/src/core/KiwiSdrProtocol.h @@ -325,6 +325,8 @@ QString formatSquelchCommand(bool enabled, int thresholdDb, int agcDecayMsForMode(const QString& mode); QString formatAgcCommand(bool enabled, bool hang, int thresholdDb, int manualGainDb, int decayMs); +QString formatSoundTuneCommand(const QString& mode, int lowCutHz, int highCutHz, + double freqKhz); MeterReading meterUnavailable(MeterSource source, const QString& notes = {}); MeterReading extractMeterFromSndVerifiedLayout(const QByteArray& frame, const MeterContext& context); diff --git a/tests/kiwi_sdr_protocol_test.cpp b/tests/kiwi_sdr_protocol_test.cpp index 9eab17db0..da88b9f85 100644 --- a/tests/kiwi_sdr_protocol_test.cpp +++ b/tests/kiwi_sdr_protocol_test.cpp @@ -294,6 +294,20 @@ int main() return fail("AGC command formatting is wrong"); } + // #4423: Kiwi 'freq' is the BFO, so CW must shift it by the passband + // center (the sidetone pitch) or the carrier demodulates to DC and gets + // filtered out, leaving no audible tone even though the pip overlays it. + if (formatSoundTuneCommand(QStringLiteral("cw"), 400, 800, 7000.0) + != QStringLiteral("SET mod=cw low_cut=400 high_cut=800 freq=6999.400") + || formatSoundTuneCommand(QStringLiteral("cw"), -800, -400, 7000.0) + != QStringLiteral("SET mod=cw low_cut=-800 high_cut=-400 freq=7000.600") + || formatSoundTuneCommand(QStringLiteral("usb"), 100, 2900, 7000.0) + != QStringLiteral("SET mod=usb low_cut=100 high_cut=2900 freq=7000.000") + || formatSoundTuneCommand(QStringLiteral("lsb"), -2900, -100, 7000.0) + != QStringLiteral("SET mod=lsb low_cut=-2900 high_cut=-100 freq=7000.000")) { + return fail("sound tune command formatting is wrong"); + } + const QVector msgTokens = parseMsgTokens( QStringLiteral("MSG wb_only password_timeout inactivity_timeout=15 " "kiwi_kick=1%2coperator%20request badp=5 =ignored")); From 54ee8869c052c8c4108040e569a8ed0598905108 Mon Sep 17 00:00:00 2001 From: Wayne Date: Fri, 24 Jul 2026 16:45:02 -0400 Subject: [PATCH 2/2] Use the radio's actual CW pitch for KiwiSDR BFO offset, not passband center (#4423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real session log (attached to the issue) showed Flex reports the CW passband symmetric about the carrier (low_cut=-400 high_cut=400) — the sidetone pitch is a DSP shift Flex applies AFTER that filter, not baked into the reported passband. With a symmetric passband the previous fix's "shift by passband center" computed a zero offset, so nothing changed: the carrier still demodulated to 0 Hz (heard as a DC thump while tuning, not a tone), matching what was reported after the first fix landed. Shift the whole receive chain by the radio's actual CW pitch instead: move the Kiwi BFO down by the pitch so the carrier demodulates to +cwPitchHz, and slide low_cut/high_cut up by the same amount so that frequency is still inside the passband. Plumb TransmitModel::cwPitch() from MainWindow down through KiwiSdrManager and into KiwiSdrClient::setTrackedSlice(), and re-send tracked-slice state when the pitch changes live so an active KiwiSDR CW session doesn't go stale. Co-Authored-By: Claude Sonnet 5 --- src/core/KiwiSdrClient.cpp | 8 +++++--- src/core/KiwiSdrClient.h | 3 ++- src/core/KiwiSdrManager.cpp | 24 ++++++++++++---------- src/core/KiwiSdrManager.h | 6 +++--- src/core/KiwiSdrProtocol.cpp | 34 +++++++++++++++++++------------- src/core/KiwiSdrProtocol.h | 2 +- src/gui/MainWindow_KiwiSdr.cpp | 9 ++++++--- src/gui/MainWindow_Wiring.cpp | 5 +++++ tests/kiwi_sdr_protocol_test.cpp | 22 ++++++++++++--------- 9 files changed, 69 insertions(+), 44 deletions(-) diff --git a/src/core/KiwiSdrClient.cpp b/src/core/KiwiSdrClient.cpp index ba6bf6a1f..fb4069d8c 100644 --- a/src/core/KiwiSdrClient.cpp +++ b/src/core/KiwiSdrClient.cpp @@ -975,7 +975,7 @@ void KiwiSdrClient::disconnectFromEndpoint() void KiwiSdrClient::setTrackedSlice(int sliceId, double frequencyMhz, const QString& mode, int filterLowHz, int filterHighHz, const QString& panId, - const QString& bandName) + const QString& bandName, int cwPitchHz) { const QString normalizedBandName = bandName.trimmed(); if (m_trackedSliceId == sliceId @@ -984,7 +984,8 @@ void KiwiSdrClient::setTrackedSlice(int sliceId, double frequencyMhz, && m_trackedFilterLowHz == filterLowHz && m_trackedFilterHighHz == filterHighHz && m_trackedPanId == panId - && m_trackedBandName == normalizedBandName) { + && m_trackedBandName == normalizedBandName + && m_trackedCwPitchHz == cwPitchHz) { return; } @@ -998,6 +999,7 @@ void KiwiSdrClient::setTrackedSlice(int sliceId, double frequencyMhz, m_trackedFilterLowHz = filterLowHz; m_trackedFilterHighHz = filterHighHz; m_trackedPanId = panId; + m_trackedCwPitchHz = cwPitchHz; if (bandChanged) { resetWaterfallAutoScaleHistory(); } @@ -1392,7 +1394,7 @@ void KiwiSdrClient::sendTrackedSliceToServer() return; } sendSoundCommand(KiwiSdrProtocol::formatSoundTuneCommand( - mode, lowCutHz, highCutHz, freqKhz)); + mode, lowCutHz, highCutHz, freqKhz, m_trackedCwPitchHz)); } void KiwiSdrClient::sendReceiverControlsToServer() diff --git a/src/core/KiwiSdrClient.h b/src/core/KiwiSdrClient.h index ea33fb529..a879fbddd 100644 --- a/src/core/KiwiSdrClient.h +++ b/src/core/KiwiSdrClient.h @@ -105,7 +105,7 @@ public slots: void setTrackedSlice(int sliceId, double frequencyMhz, const QString& mode, int filterLowHz, int filterHighHz, const QString& panId, - const QString& bandName); + const QString& bandName, int cwPitchHz); void setWaterfallView(const QString& panId, double centerMhz, double bandwidthMhz); void setWaterfallLineDurationMs(int lineDurationMs); @@ -278,6 +278,7 @@ public slots: QString m_trackedMode; int m_trackedFilterLowHz{0}; int m_trackedFilterHighHz{0}; + int m_trackedCwPitchHz{600}; QString m_trackedPanId; bool m_userDisconnecting{false}; bool m_secureWebSocket{false}; diff --git a/src/core/KiwiSdrManager.cpp b/src/core/KiwiSdrManager.cpp index 3a0825122..9e20f7ae4 100644 --- a/src/core/KiwiSdrManager.cpp +++ b/src/core/KiwiSdrManager.cpp @@ -440,7 +440,7 @@ void KiwiSdrManager::connectProfile(const QString& id) m_clientHasTrackedSlice.insert(id, false); invokeClient(id, [](KiwiSdrClient* client) { client->setTrackedSlice(-1, 0.0, QString(), 0, 0, QString(), - QString()); + QString(), 0); }); emit profileNeedsInitialTracking(id); } @@ -521,7 +521,8 @@ void KiwiSdrManager::primeProfileTracking(const QString& id, int sliceId, double centerMhz, double bandwidthMhz, int lineDurationMs, - const QString& bandName) + const QString& bandName, + int cwPitchHz) { if (!hasProfile(id) || sliceId < 0 || frequencyMhz <= 0.0) { return; @@ -532,10 +533,10 @@ void KiwiSdrManager::primeProfileTracking(const QString& id, int sliceId, m_clientHasTrackedSlice.insert(id, true); invokeClient(id, [sliceId, frequencyMhz, mode, filterLowHz, filterHighHz, panId, lineDurationMs, - centerMhz, bandwidthMhz, bandName]( + centerMhz, bandwidthMhz, bandName, cwPitchHz]( KiwiSdrClient* client) { client->setTrackedSlice(sliceId, frequencyMhz, mode, filterLowHz, - filterHighHz, panId, bandName); + filterHighHz, panId, bandName, cwPitchHz); client->setWaterfallLineDurationMs(lineDurationMs); if (!panId.isEmpty() && centerMhz > 0.0 && bandwidthMhz > 0.0) { client->setWaterfallView(panId, centerMhz, bandwidthMhz); @@ -549,7 +550,8 @@ void KiwiSdrManager::assignSliceToProfile(int sliceId, const QString& profileId, const QString& mode, int filterLowHz, int filterHighHz, const QString& panId, - const QString& bandName) + const QString& bandName, + int cwPitchHz) { if (sliceId < 0 || !hasProfile(profileId)) { clearSliceAssignment(sliceId); @@ -597,10 +599,11 @@ void KiwiSdrManager::assignSliceToProfile(int sliceId, const QString& profileId, const bool connected = KiwiSdrClient::stateHasReceiveAudio(state(profileId)); invokeClient(profileId, [sliceId, frequencyMhz, mode, filterLowHz, - filterHighHz, panId, bandName, connected]( + filterHighHz, panId, bandName, connected, + cwPitchHz]( KiwiSdrClient* client) { client->setTrackedSlice(sliceId, frequencyMhz, mode, filterLowHz, - filterHighHz, panId, bandName); + filterHighHz, panId, bandName, cwPitchHz); client->setAudioActive(connected); }); } @@ -639,7 +642,8 @@ void KiwiSdrManager::updateSliceTracking(int sliceId, double frequencyMhz, const QString& mode, int filterLowHz, int filterHighHz, const QString& panId, - const QString& bandName) + const QString& bandName, + int cwPitchHz) { const QString profileId = m_sliceAssignments.value(sliceId); if (profileId.isEmpty()) { @@ -650,10 +654,10 @@ void KiwiSdrManager::updateSliceTracking(int sliceId, double frequencyMhz, m_clientHasTrackedSlice.insert(profileId, sliceId >= 0 && frequencyMhz > 0.0); invokeClient(profileId, [sliceId, frequencyMhz, mode, filterLowHz, filterHighHz, panId, - bandName]( + bandName, cwPitchHz]( KiwiSdrClient* client) { client->setTrackedSlice(sliceId, frequencyMhz, mode, filterLowHz, - filterHighHz, panId, bandName); + filterHighHz, panId, bandName, cwPitchHz); }); } } diff --git a/src/core/KiwiSdrManager.h b/src/core/KiwiSdrManager.h index 90fc2c0b6..0e0f26d35 100644 --- a/src/core/KiwiSdrManager.h +++ b/src/core/KiwiSdrManager.h @@ -97,17 +97,17 @@ public slots: int filterLowHz, int filterHighHz, const QString& panId, double centerMhz, double bandwidthMhz, int lineDurationMs, - const QString& bandName); + const QString& bandName, int cwPitchHz); void assignSliceToProfile(int sliceId, const QString& profileId, double frequencyMhz, const QString& mode, int filterLowHz, int filterHighHz, const QString& panId, - const QString& bandName); + const QString& bandName, int cwPitchHz); void clearSliceAssignment(int sliceId); void updateSliceTracking(int sliceId, double frequencyMhz, const QString& mode, int filterLowHz, int filterHighHz, const QString& panId, - const QString& bandName); + const QString& bandName, int cwPitchHz); void updateWaterfallView(int sliceId, const QString& panId, double centerMhz, double bandwidthMhz, int lineDurationMs); diff --git a/src/core/KiwiSdrProtocol.cpp b/src/core/KiwiSdrProtocol.cpp index 96936a273..ab4166281 100644 --- a/src/core/KiwiSdrProtocol.cpp +++ b/src/core/KiwiSdrProtocol.cpp @@ -1586,25 +1586,31 @@ QString formatAgcCommand(bool enabled, bool hang, int thresholdDb, } QString formatSoundTuneCommand(const QString& mode, int lowCutHz, int highCutHz, - double freqKhz) + double freqKhz, int cwPitchHz) { + int tunedLowCutHz = lowCutHz; + int tunedHighCutHz = highCutHz; double tunedFreqKhz = freqKhz; - if (mode == QStringLiteral("cw")) { - // The Kiwi's 'freq' is the BFO/mixdown point, not a dial frequency: - // low_cut/high_cut are applied as an audio passband relative to it. - // Our CW passband is already centered on the sidetone pitch above - // (or, for CWL, below) the carrier, so sending freq=carrier puts the - // carrier at 0 Hz — outside the passband — and the tone is filtered - // out (#4423). Shift the BFO by the passband center so the carrier - // lands inside the passband and beats to the pitch tone, matching - // what the operator hears from the Flex sidetone. - const double pitchHz = (lowCutHz + highCutHz) / 2.0; - tunedFreqKhz -= pitchHz / 1000.0; + if (mode == QStringLiteral("cw") && cwPitchHz != 0) { + // Flex reports the CW passband symmetric about the carrier (e.g. + // low_cut=-400 high_cut=400) — the sidetone pitch is a DSP shift + // Flex applies AFTER that filter, not something baked into it. The + // Kiwi has no such post-filter shift: 'freq' is the BFO/mixdown + // point and low_cut/high_cut are an audio passband relative to it. + // Sending freq=carrier with the passband as reported puts the + // carrier at 0 Hz (audible only as a DC thump, not a tone) — #4423. + // Reproduce the Flex behavior by shifting the whole receive chain + // by the pitch: move the BFO down so the carrier demodulates to + // +cwPitchHz, and slide the passband up by the same amount so that + // frequency is still inside it. + tunedLowCutHz += cwPitchHz; + tunedHighCutHz += cwPitchHz; + tunedFreqKhz -= cwPitchHz / 1000.0; } return QStringLiteral("SET mod=%1 low_cut=%2 high_cut=%3 freq=%4") .arg(mode) - .arg(lowCutHz) - .arg(highCutHz) + .arg(tunedLowCutHz) + .arg(tunedHighCutHz) .arg(tunedFreqKhz, 0, 'f', 3); } diff --git a/src/core/KiwiSdrProtocol.h b/src/core/KiwiSdrProtocol.h index d0b1e0b85..6689716b7 100644 --- a/src/core/KiwiSdrProtocol.h +++ b/src/core/KiwiSdrProtocol.h @@ -326,7 +326,7 @@ int agcDecayMsForMode(const QString& mode); QString formatAgcCommand(bool enabled, bool hang, int thresholdDb, int manualGainDb, int decayMs); QString formatSoundTuneCommand(const QString& mode, int lowCutHz, int highCutHz, - double freqKhz); + double freqKhz, int cwPitchHz); MeterReading meterUnavailable(MeterSource source, const QString& notes = {}); MeterReading extractMeterFromSndVerifiedLayout(const QByteArray& frame, const MeterContext& context); diff --git a/src/gui/MainWindow_KiwiSdr.cpp b/src/gui/MainWindow_KiwiSdr.cpp index f73b80615..b6aded9e2 100644 --- a/src/gui/MainWindow_KiwiSdr.cpp +++ b/src/gui/MainWindow_KiwiSdr.cpp @@ -602,7 +602,8 @@ void MainWindow::setKiwiSdrVirtualAntennaForSliceInternal(int sliceId, m_kiwiSdrManager->assignSliceToProfile( sliceId, profileId, slice->frequency(), slice->mode(), slice->filterLow(), slice->filterHigh(), slice->panId(), - BandSettings::bandForFrequency(slice->frequency())); + BandSettings::bandForFrequency(slice->frequency()), + m_radioModel.transmitModel().cwPitch()); updateKiwiSdrVirtualTrackingForSlice(slice); updateKiwiSdrVirtualAudioControlsForSlice(slice); updateKiwiSdrVirtualReceiverControlsForSlice(slice); @@ -1035,7 +1036,8 @@ void MainWindow::updateKiwiSdrVirtualTrackingForSlice(SliceModel* slice) m_kiwiSdrManager->updateSliceTracking( slice->sliceId(), slice->frequency(), slice->mode(), slice->filterLow(), slice->filterHigh(), slice->panId(), - BandSettings::bandForFrequency(slice->frequency())); + BandSettings::bandForFrequency(slice->frequency()), + m_radioModel.transmitModel().cwPitch()); if (SpectrumWidget* spectrum = spectrumForSlice(slice)) { m_kiwiSdrManager->updateWaterfallView( slice->sliceId(), slice->panId(), spectrum->centerMhz(), @@ -1674,7 +1676,8 @@ void MainWindow::wireKiwiSdr() spectrum ? spectrum->centerMhz() : slice->frequency(), spectrum ? spectrum->bandwidthMhz() : 0.2, spectrum ? spectrum->wfLineDuration() : 100, - BandSettings::bandForFrequency(slice->frequency())); + BandSettings::bandForFrequency(slice->frequency()), + m_radioModel.transmitModel().cwPitch()); }); if (m_audio) { connect(m_kiwiSdrManager, &KiwiSdrManager::decodedAudioReady, diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index 6d6039286..abb277be2 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -4688,6 +4688,11 @@ void MainWindow::wireVfoWidget(VfoWidget* w, SliceModel* s) connect(s, &SliceModel::panIdChanged, this, [this, s](const QString&) { updateKiwiSdrVirtualTrackingForSlice(s); }); + // Re-send the tracked-slice command when the radio's CW pitch changes so + // an already-active KiwiSDR CW session's BFO offset stays in sync (#4423) + // instead of going stale until the next frequency/mode/filter edit. + connect(&m_radioModel.transmitModel(), &TransmitModel::phoneStateChanged, + this, [this, s]() { updateKiwiSdrVirtualTrackingForSlice(s); }); connect(s, &SliceModel::audioGainChanged, this, [this, s](float) { updateKiwiSdrVirtualAudioControlsForSlice(s); updateAetherDspModePolicy(); diff --git a/tests/kiwi_sdr_protocol_test.cpp b/tests/kiwi_sdr_protocol_test.cpp index da88b9f85..78112e444 100644 --- a/tests/kiwi_sdr_protocol_test.cpp +++ b/tests/kiwi_sdr_protocol_test.cpp @@ -294,16 +294,20 @@ int main() return fail("AGC command formatting is wrong"); } - // #4423: Kiwi 'freq' is the BFO, so CW must shift it by the passband - // center (the sidetone pitch) or the carrier demodulates to DC and gets - // filtered out, leaving no audible tone even though the pip overlays it. - if (formatSoundTuneCommand(QStringLiteral("cw"), 400, 800, 7000.0) - != QStringLiteral("SET mod=cw low_cut=400 high_cut=800 freq=6999.400") - || formatSoundTuneCommand(QStringLiteral("cw"), -800, -400, 7000.0) - != QStringLiteral("SET mod=cw low_cut=-800 high_cut=-400 freq=7000.600") - || formatSoundTuneCommand(QStringLiteral("usb"), 100, 2900, 7000.0) + // #4423: Flex reports the CW passband symmetric about the carrier (e.g. + // -400..400, confirmed from a real session log) and applies the sidetone + // pitch as a DSP shift AFTER that filter. The Kiwi has no such shift: + // 'freq' is the BFO and low_cut/high_cut are relative to it, so sending + // freq=carrier with an unshifted passband puts the carrier at 0 Hz (a DC + // thump, not a tone). Both the BFO and the passband must move by the + // pitch so the carrier lands audibly inside it. + if (formatSoundTuneCommand(QStringLiteral("cw"), -400, 400, 7000.0, 600) + != QStringLiteral("SET mod=cw low_cut=200 high_cut=1000 freq=6999.400") + || formatSoundTuneCommand(QStringLiteral("cw"), -400, 400, 7000.0, 0) + != QStringLiteral("SET mod=cw low_cut=-400 high_cut=400 freq=7000.000") + || formatSoundTuneCommand(QStringLiteral("usb"), 100, 2900, 7000.0, 600) != QStringLiteral("SET mod=usb low_cut=100 high_cut=2900 freq=7000.000") - || formatSoundTuneCommand(QStringLiteral("lsb"), -2900, -100, 7000.0) + || formatSoundTuneCommand(QStringLiteral("lsb"), -2900, -100, 7000.0, 600) != QStringLiteral("SET mod=lsb low_cut=-2900 high_cut=-100 freq=7000.000")) { return fail("sound tune command formatting is wrong"); }