diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index e84a3892e9..09b63f4ef2 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -14,7 +14,10 @@ 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; -there is no second normalized decoder in the UI. Output has no calibrated +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/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..97543b9bc0 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,92 @@ 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 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()); + 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. +// 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 + // (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((keyed ? clean[i] : 0.f) + 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)); + // 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() { @@ -337,6 +421,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)