From bf9e03385d63bde95d95eef71f9984e07f5cea40 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:09:34 -0700 Subject: [PATCH 1/2] fix(cw): lower DeepFist's activity-gate threshold to 3 so weak off-air CW is not gated out (#5950). Principle XI. The stream default (12) comes from n9bc/DeepFist tools/squelch.py, calibrated between dead air and a tuned-in signal; weak off-air CW scores in that gap and the gate dropped most of it. The app's parameters now set 3; carriers stay held off by the completed-mark guard (the carrier regression now also runs at the app's parameters). Co-Authored-By: Claude Opus 5.5 --- docs/deepfist-cw-backend.md | 4 +- src/models/DeepFistCwModel.cpp | 16 +++++-- src/models/DeepFistCwModel.h | 2 + tests/deepfist_cw_model_test.cpp | 82 ++++++++++++++++++++++++++++++-- tests/tests.cmake | 3 +- 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index e84a3892e9..aaca554734 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -13,7 +13,9 @@ queue holds at most two seconds of PCM. Model loading and inference run off the UI thread; stopping joins the current inference before destroying state. The single DeepFist choice enables activity normalization, completed-mark -admission and bounded pending-character carry. These are developer settings; +admission and bounded pending-character carry, and lowers the activity-gate +threshold from the stream default 12 to 3 so weak off-air CW is not gated out +(#5950). These are developer settings; there is no second normalized decoder in the UI. Output has no calibrated ggmorse cost and is displayed without inventing confidence, pitch or speed. DeepFist output does not feed automatic callsign spotting. Other monitored diff --git a/src/models/DeepFistCwModel.cpp b/src/models/DeepFistCwModel.cpp index 2c954a0ac8..27ddb103ed 100644 --- a/src/models/DeepFistCwModel.cpp +++ b/src/models/DeepFistCwModel.cpp @@ -22,6 +22,18 @@ bool matches(const QString& path, qint64 size, const QByteArray& digest) return hash.addData(&file) && hash.result().toHex() == digest; } } +DeepFistStream::Parameters DeepFistCwModel::appParameters() +{ + DeepFistStream::Parameters parameters; + parameters.requireCompletedMark = true; + parameters.carryPending = true; + parameters.normalizeActivity = true; + // The stream default (12) sits between dead air and a tuned-in signal (n9bc/DeepFist + // tools/squelch.py); weak off-air CW scores in that gap and was gated out (#5950). + // Steady carriers are held off by the completed-mark guard above, not by this value. + parameters.activityThreshold = 3.f; + return parameters; +} DeepFistCwModel::DeepFistCwModel(QObject* parent) : DeepFistCwModel(modelDirectory(), DeepFistModelAssets::releaseBaseUrl(), nullptr, parent) {} DeepFistCwModel::DeepFistCwModel(QString directory, QString baseUrl, @@ -29,9 +41,7 @@ DeepFistCwModel::DeepFistCwModel(QString directory, QString baseUrl, : QObject(parent), m_assets(std::make_unique(directory, baseUrl, DeepFistModelAssets::manifest(), network)), m_directory(std::move(directory)) { - m_parameters.requireCompletedMark = true; - m_parameters.carryPending = true; - m_parameters.normalizeActivity = true; + m_parameters = appParameters(); connect(m_assets.get(), &DeepFistModelAssets::checking, this, [this] { setStatus(tr("Checking model…")); }); diff --git a/src/models/DeepFistCwModel.h b/src/models/DeepFistCwModel.h index 5ff98a489b..f47a79acc6 100644 --- a/src/models/DeepFistCwModel.h +++ b/src/models/DeepFistCwModel.h @@ -38,6 +38,8 @@ class DeepFistCwModel final : public QObject { bool isRunning() const { return m_running; } QString status() const { return m_status; } static QString modelDirectory(); + // The parameters the application runs DeepFist with (the default constructors use them). + static DeepFistStream::Parameters appParameters(); signals: void textDecoded(const QString& text); void statusChanged(const QString& status); diff --git a/tests/deepfist_cw_model_test.cpp b/tests/deepfist_cw_model_test.cpp index 7f8ae19906..3d6a11ade2 100644 --- a/tests/deepfist_cw_model_test.cpp +++ b/tests/deepfist_cw_model_test.cpp @@ -18,6 +18,7 @@ #include #include #include +#include using namespace AetherSDR; namespace { @@ -232,9 +233,80 @@ bool carrierRegression(const QByteArray& directory) if (stream.failed()) { return false; } } } - std::fprintf(stderr, "carrier guard disabled: '%s'; enabled: '%s'\n", - qPrintable(outputs[0].simplified()), qPrintable(outputs[1].simplified())); - return outputs[0].simplified() == "9" && outputs[1].simplified().isEmpty(); + // The application's parameters (activityThreshold 3, #5950) must still hold the carrier off. + QString appOutput; + { + DeepFistStream stream(DeepFistCwModel::appParameters()); + Resampler resampler(24000, 3200, 4096); + for (int offset = 0; offset < 240000; offset += 240) { + float audio[240]; + for (int i = 0; i < 240; ++i) { + audio[i] = 0.3f * std::sin(2.0 * 3.141592653589793 * 680.0 * (offset+i) / 24000.0); + } + const QByteArray bytes = resampler.process(audio, 240); + std::vector mono(bytes.size()/4); + std::memcpy(mono.data(), bytes.constData(), bytes.size()); + appOutput += stream.process(mono.data(), mono.size(), model); + if (stream.failed()) { return false; } + } + } + std::fprintf(stderr, "carrier guard disabled: '%s'; enabled: '%s'; app parameters: '%s'\n", + qPrintable(outputs[0].simplified()), qPrintable(outputs[1].simplified()), + qPrintable(appOutput.simplified())); + return outputs[0].simplified() == "9" && outputs[1].simplified().isEmpty() + && appOutput.simplified().isEmpty(); +} +// #5950: off-air CW whose keying ratio falls between dead air and a strong signal was gated +// out at activityThreshold 12. CONSTRUCTED fixture: the CQ TEST CQ tone plus in-band noise +// (40 fixed random-phase tones, 550-650 Hz, seed 5950) — it exercises the gate only and does +// not stand for any measured signal; the field evidence is the recordings attached to #5950. +QString weakSignalDecode(lyra::dsp::DeepFistModel& model, DeepFistStream::Parameters parameters) +{ + constexpr int rate = 3200; + // In-band noise RMS: from 0.06 to 0.13 the final CQ decodes only with the app's parameters + // (0.05: both thresholds decode it all); 0.09 sits in the middle. + constexpr double noiseRms = 0.09; + const QVector clean = testAudio(rate); + // std::mt19937's output sequence is fixed by the standard; the distribution classes are + // not, so the uniform values come straight from the engine to keep this fixture identical + // on every standard library. + std::mt19937 generator(5950); + const auto unit = [&generator] { return static_cast(generator()) / 4294967296.0; }; + double frequency[40]; + double phase[40]; + for (int k = 0; k < 40; ++k) { + frequency[k] = 550.0 + 100.0 * unit(); + phase[k] = 2.0 * 3.141592653589793 * unit(); + } + std::vector audio(clean.size()); + for (qsizetype i = 0; i < clean.size(); ++i) { + double noise = 0.0; + for (int k = 0; k < 40; ++k) { + noise += std::sin(2.0 * 3.141592653589793 * frequency[k] * i / rate + phase[k]); + } + audio[i] = static_cast(clean[i] + noiseRms * noise / std::sqrt(20.0)); + } + DeepFistStream stream(parameters); + QString text; + for (size_t offset = 0; offset < audio.size(); offset += 320) { + const int count = static_cast(std::min(320, audio.size() - offset)); + text += stream.process(audio.data() + offset, count, model); + if (stream.failed()) { return QStringLiteral(""); } + } + return text.simplified(); +} +bool weakSignal(const QByteArray& directory) +{ + lyra::dsp::DeepFistModel model; + if (!model.load(directory.toStdString())) { return false; } + const QString app = weakSignalDecode(model, DeepFistCwModel::appParameters()); + DeepFistStream::Parameters strict = DeepFistCwModel::appParameters(); + strict.activityThreshold = 12.f; + const QString previous = weakSignalDecode(model, strict); + std::fprintf(stderr, "weak signal: app parameters '%s'; threshold 12 '%s'\n", + qPrintable(app), qPrintable(previous)); + // The gate effect: the fading final CQ survives only with the app's parameters. + return app.endsWith(QStringLiteral(" CQ")) && !previous.endsWith(QStringLiteral("CQ")); } bool churn() { @@ -337,6 +409,10 @@ int main(int argc, char** argv) if (directory.isEmpty()) { return 77; } return carrierRegression(directory) ? 0 : 1; } + if (argc > 1 && QString::fromLocal8Bit(argv[1]) == "--weak") { + if (directory.isEmpty()) { return 77; } + return weakSignal(directory) ? 0 : 1; + } if (argc > 1 && QString::fromLocal8Bit(argv[1]) == "--churn") { if (directory.isEmpty()) { return 77; } return churn() ? 0 : 1; diff --git a/tests/tests.cmake b/tests/tests.cmake index 9fbe367178..accb198edf 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -6944,7 +6944,8 @@ if(ENABLE_DEEPFIST_EXPERIMENT) add_test(NAME deepfist_cw_model_test COMMAND deepfist_cw_model_test) add_test(NAME deepfist_carrier_regression_test COMMAND deepfist_cw_model_test --carrier) add_test(NAME deepfist_cw_churn_test COMMAND deepfist_cw_model_test --churn) - set_tests_properties(deepfist_cw_churn_test deepfist_carrier_regression_test PROPERTIES + add_test(NAME deepfist_weak_signal_test COMMAND deepfist_cw_model_test --weak) + set_tests_properties(deepfist_cw_churn_test deepfist_carrier_regression_test deepfist_weak_signal_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 120) add_test(NAME deepfist_cw_model_inference_test COMMAND deepfist_cw_model_test --infer) add_test(NAME deepfist_cw_model_download_inference_test COMMAND deepfist_cw_model_test --download-infer) From c0df8d389e3317cbfb5a62ecb23cd06f052f53d2 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:30:06 -0700 Subject: [PATCH 2/2] test(cw): fail DeepFist's weak-signal test on a failed decode, add a noise-only arm (#5950). Principle VIII. A failed decode in the threshold-12 control arm read as "the gate closed" and passed. The same constructed noise with the keying removed must not print more with the app's parameters than at 12. The carrier arm's comment names the completed-mark guard, which holds a carrier at any threshold. The Parameters comment no longer says the app uses the defaults unchanged. docs: the threshold is the value every user runs, not a developer setting. Co-Authored-By: Claude Opus 5.5 --- docs/deepfist-cw-backend.md | 9 +++++---- src/core/deepfist/DeepFistStream.h | 2 +- tests/deepfist_cw_model_test.cpp | 22 +++++++++++++++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index aaca554734..09b63f4ef2 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -13,10 +13,11 @@ queue holds at most two seconds of PCM. Model loading and inference run off the UI thread; stopping joins the current inference before destroying state. The single DeepFist choice enables activity normalization, completed-mark -admission and bounded pending-character carry, and lowers the activity-gate -threshold from the stream default 12 to 3 so weak off-air CW is not gated out -(#5950). These are developer settings; -there is no second normalized decoder in the UI. Output has no calibrated +admission and bounded pending-character carry. These are developer settings; +there is no second normalized decoder in the UI. It also lowers the +activity-gate threshold from the stream default 12 to 3 so weak off-air CW is +not gated out (#5950); that value is what every user of this backend runs. +Output has no calibrated ggmorse cost and is displayed without inventing confidence, pitch or speed. DeepFist output does not feed automatic callsign spotting. Other monitored slices and speaker gain/mute do not alter the selected decoder input. This is diff --git a/src/core/deepfist/DeepFistStream.h b/src/core/deepfist/DeepFistStream.h index 0c0ed5d0a2..fc357b03ba 100644 --- a/src/core/deepfist/DeepFistStream.h +++ b/src/core/deepfist/DeepFistStream.h @@ -7,7 +7,7 @@ namespace AetherSDR { // One worker owns this fixed-memory 3.2 kHz streaming context. class DeepFistStream { public: - // Explicit developer replay configuration; the application uses these defaults. + // Stream defaults for developer replay; the app overrides some in DeepFistCwModel::appParameters(). struct Parameters { int tickSamples = 1280; double guardSeconds = 1.3; diff --git a/tests/deepfist_cw_model_test.cpp b/tests/deepfist_cw_model_test.cpp index 3d6a11ade2..97543b9bc0 100644 --- a/tests/deepfist_cw_model_test.cpp +++ b/tests/deepfist_cw_model_test.cpp @@ -233,7 +233,8 @@ bool carrierRegression(const QByteArray& directory) if (stream.failed()) { return false; } } } - // The application's parameters (activityThreshold 3, #5950) must still hold the carrier off. + // The app's parameters hold the carrier off too. The completed-mark guard does that at any + // activityThreshold (a carrier has no key-up), so this arm says nothing about the value 3. QString appOutput; { DeepFistStream stream(DeepFistCwModel::appParameters()); @@ -260,7 +261,9 @@ bool carrierRegression(const QByteArray& directory) // out at activityThreshold 12. CONSTRUCTED fixture: the CQ TEST CQ tone plus in-band noise // (40 fixed random-phase tones, 550-650 Hz, seed 5950) — it exercises the gate only and does // not stand for any measured signal; the field evidence is the recordings attached to #5950. -QString weakSignalDecode(lyra::dsp::DeepFistModel& model, DeepFistStream::Parameters parameters) +// keyed = false leaves the noise alone. +QString weakSignalDecode(lyra::dsp::DeepFistModel& model, DeepFistStream::Parameters parameters, + bool keyed = true) { constexpr int rate = 3200; // In-band noise RMS: from 0.06 to 0.13 the final CQ decodes only with the app's parameters @@ -284,7 +287,7 @@ QString weakSignalDecode(lyra::dsp::DeepFistModel& model, DeepFistStream::Parame for (int k = 0; k < 40; ++k) { noise += std::sin(2.0 * 3.141592653589793 * frequency[k] * i / rate + phase[k]); } - audio[i] = static_cast(clean[i] + noiseRms * noise / std::sqrt(20.0)); + audio[i] = static_cast((keyed ? clean[i] : 0.f) + noiseRms * noise / std::sqrt(20.0)); } DeepFistStream stream(parameters); QString text; @@ -305,8 +308,17 @@ bool weakSignal(const QByteArray& directory) const QString previous = weakSignalDecode(model, strict); std::fprintf(stderr, "weak signal: app parameters '%s'; threshold 12 '%s'\n", qPrintable(app), qPrintable(previous)); - // The gate effect: the fading final CQ survives only with the app's parameters. - return app.endsWith(QStringLiteral(" CQ")) && !previous.endsWith(QStringLiteral("CQ")); + // Noise alone must not print more with the app's parameters than at 12. + const QString appNoise = weakSignalDecode(model, DeepFistCwModel::appParameters(), false); + const QString previousNoise = weakSignalDecode(model, strict, false); + std::fprintf(stderr, "noise only: app parameters '%s'; threshold 12 '%s'\n", + qPrintable(appNoise), qPrintable(previousNoise)); + const QString failed = QStringLiteral(""); + if (previous == failed || appNoise == failed || previousNoise == failed) { return false; } + // The gate effect: the final CQ survives only with the app's parameters. The trailing + // noise-only tail dilutes the 6 s window's keying ratio, which is what closes the gate at 12. + return app.endsWith(QStringLiteral(" CQ")) && !previous.endsWith(QStringLiteral("CQ")) + && appNoise.size() <= previousNoise.size(); } bool churn() {