From 4d29f41b42d15602f132374e59d28a3d9cebadbb Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:34:12 -0700 Subject: [PATCH 01/11] feat(cw): DeepCW engine, streaming committer and replay harness from the RFC #4817 prototype. Principle VIII. DeepCwEngine is K5PTB's C++ port of e04/deepcw-engine's decode_morse.py (spectrogram + ONNX session + greedy CTC; inert without HAVE_ONNX). DeepCwCommitter is the sliding-window, time-anchored commit from K5PTB/AetherSDR#1, and tools/deepcw_replay the offline harness that re-runs the recorded takes (not built by default). The AGPL-3.0 attribution for the ported code and the downloaded weights is THIRD_PARTY_LICENSES entry 29. Nothing is compiled into the app yet; the backend follows. Refs #4817 Co-authored-by: K5PTB <274291579+K5PTB@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 11 ++ THIRD_PARTY_LICENSES | 27 ++- src/core/DeepCwCommitter.cpp | 103 +++++++++++ src/core/DeepCwCommitter.h | 59 ++++++ src/core/DeepCwEngine.cpp | 341 +++++++++++++++++++++++++++++++++++ src/core/DeepCwEngine.h | 113 ++++++++++++ tools/deepcw_replay.cpp | 183 +++++++++++++++++++ 7 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 src/core/DeepCwCommitter.cpp create mode 100644 src/core/DeepCwCommitter.h create mode 100644 src/core/DeepCwEngine.cpp create mode 100644 src/core/DeepCwEngine.h create mode 100644 tools/deepcw_replay.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b7e6ef29d8..9fea5ee31e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3540,6 +3540,17 @@ endforeach() # it keep resolving against the repository root. The header of that file explains # why that matters and why it should not be "tidied up" into a subdirectory. enable_testing() +# DeepCW offline replay harness (RFC #4817 regression take): not built by default. +# Declared above the tests include: executables declared after it fail configure. +if(ORT_FOUND) + add_executable(deepcw_replay EXCLUDE_FROM_ALL + tools/deepcw_replay.cpp src/core/DeepCwEngine.cpp src/core/DeepCwCommitter.cpp + src/core/Resampler.cpp) + target_compile_definitions(deepcw_replay PRIVATE HAVE_ONNX) + target_include_directories(deepcw_replay PRIVATE src + ${CMAKE_SOURCE_DIR}/third_party/r8brain ${ORT_INCLUDE_DIRS}) + target_link_libraries(deepcw_replay PRIVATE Qt6::Widgets ${ORT_LIBRARIES}) +endif() include(tests/tests.cmake) diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 56fc72b99a..811d6e4ca8 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -1,5 +1,6 @@ AetherSDR Third-Party Software Licenses -======================================== +================================= + AetherSDR incorporates or links against the following third-party software. Each component retains its original license. Full license texts are included @@ -766,3 +767,27 @@ license text is in third_party/littlefs/LICENSE.md. Copyright (c) 2017, Arm Limited. License: BSD-3-Clause Source: https://github.com/littlefs-project/littlefs + + +32. DeepCW — Neural CW Decoder (ported code in-binary + model download-on-demand) +--------------------------------------------------------------------------------- +The optional neural CW decode backend (DeepCwEngine, RFC #4817). Unlike the +download-only ASR entries above, this has TWO distinct AGPL-3.0 obligations: + + a) Ported code (compiled into the binary). src/core/DeepCwEngine.{h,cpp} is a + C++ port of decode_morse.py from e04/deepcw-engine — the spectrogram build + (256-pt FFT, hop 48, periodic Hann, 400-1200 Hz -> 65 bins, log1p) and the + greedy-CTC decode. A faithful port of AGPL-3.0 source is a derivative of it, + so this code ships AGPL-3.0 in every binary regardless of whether a user + ever downloads the weights. AetherSDR is GPL-3.0-or-later; GPLv3 §13 + permits the combination (its only added obligation, AGPL's network clause, + affects hosting the combined work as a network service — desktop use is + unaffected, and AetherSDR source is already public). + + b) Model weights (NOT bundled). Downloaded on first enable of the DeepCW + backend (source: the e04/deepcw-engine repository), SHA-256-verified, + cached under the user's data dir. Run by ONNX Runtime (entry 11). + + Attribution: "deepcw-engine" by e04. + License: AGPL-3.0-or-later (both the ported code and the model) + Source: https://github.com/e04/deepcw-engine diff --git a/src/core/DeepCwCommitter.cpp b/src/core/DeepCwCommitter.cpp new file mode 100644 index 0000000000..bb8ea18a0d --- /dev/null +++ b/src/core/DeepCwCommitter.cpp @@ -0,0 +1,103 @@ +#include "DeepCwCommitter.h" + +#include + +namespace AetherSDR { + +namespace { +constexpr int kRate = DeepCwEngine::kModelSampleRate; +constexpr double kFrameSec = static_cast(DeepCwEngine::kHopLength) / kRate; +constexpr int kSnapBlankFrames = 3; +} // namespace + +DeepCwCommitter::DeepCwCommitter(double holdSec, double leftContextSec, double hopSec) + : m_hold(holdSec) + , m_left(leftContextSec) + , m_hop(hopSec) + // Stay inside the model's trained 5-20 s window range. + , m_window(std::min(DeepCwEngine::kMaxWindowSec, + std::max(15.0, holdSec + leftContextSec + hopSec))) + , m_keep(std::min(m_window - hopSec, holdSec + leftContextSec)) +{ +} + +void DeepCwCommitter::reset() +{ + m_buf.clear(); + m_bufStart = 0; + m_lastDecodeEnd = 0; + m_tCommit = -1.0; + m_tLastChar = -1.0; + m_lastOut = 0; +} + +DeepCwCommitter::Result DeepCwCommitter::push(const float* audio3200, std::size_t n, + const DeepCwEngine& eng) +{ + Result r; + if (n > 0) m_buf.insert(m_buf.end(), audio3200, audio3200 + n); + const std::size_t absEnd = m_bufStart + m_buf.size(); + if (m_buf.size() >= static_cast(kRate * DeepCwEngine::kMinWindowSec) + && absEnd >= m_lastDecodeEnd + static_cast(m_hop * kRate)) { + m_lastDecodeEnd = absEnd; + r = decodeAndCommit(eng, false); + } + if (m_buf.size() >= static_cast(m_window * kRate)) { + const std::size_t drop = m_buf.size() - static_cast(m_keep * kRate); + m_buf.erase(m_buf.begin(), m_buf.begin() + static_cast(drop)); + m_bufStart += drop; + } + return r; +} + +DeepCwCommitter::Result DeepCwCommitter::flush(const DeepCwEngine& eng) +{ + return decodeAndCommit(eng, true); +} + +DeepCwCommitter::Result DeepCwCommitter::decodeAndCommit(const DeepCwEngine& eng, bool flushAll) +{ + Result r; + int frames = 0; + const std::vector lp = eng.inferLogProbs(m_buf, &frames, &r.pitchHz); + if (frames == 0) return r; + r.decoded = true; + + std::vector blank; + const std::vector em = eng.greedyEmissions(lp.data(), frames, &blank); + + const double t0 = static_cast(m_bufStart) / kRate; + const double nowT = static_cast(m_bufStart + m_buf.size()) / kRate; + double cutoff = 1e18; + if (!flushAll) { + // Snap back to the latest frame <= (now - hold) that ends a blank run. + int f = std::min(frames - 1, static_cast((nowT - m_hold - t0) / kFrameSec)); + while (f >= kSnapBlankFrames + && !(blank[f] && blank[f - 1] && blank[f - 2])) --f; + cutoff = t0 + f * kFrameSec; + } + + double confSum = 0.0; + int confN = 0; + for (const auto& e : em) { + const double t = t0 + e.frame * kFrameSec; + // Word spaces are emitted inside the silent gap the cutoff snaps into, + // so a space may land just behind tCommit on the next decode: accept it + // anywhere after the last committed letter. + const double from = (e.ch == ' ') ? m_tLastChar : m_tCommit; + if (t <= from || t > cutoff) continue; + if (e.ch == ' ' && (m_lastOut == 0 || m_lastOut == ' ')) continue; + r.text.push_back(e.ch); + m_lastOut = e.ch; + if (e.ch != ' ') { + m_tLastChar = t; + confSum += e.conf; + ++confN; + } + } + if (confN > 0) r.meanConf = static_cast(confSum / confN); + if (cutoff > m_tCommit) m_tCommit = cutoff; + return r; +} + +} // namespace AetherSDR diff --git a/src/core/DeepCwCommitter.h b/src/core/DeepCwCommitter.h new file mode 100644 index 0000000000..3f82318e98 --- /dev/null +++ b/src/core/DeepCwCommitter.h @@ -0,0 +1,59 @@ +#pragma once + +// DeepCW streaming front: sliding analysis window + time-anchored commit. +// +// The model is a whole-window CTC decoder, and its reading of a character +// keeps improving while more audio arrives after it. So each hop the whole +// window is re-decoded, but a character is committed (shown) only once it was +// emitted more than holdSec behind the live edge; everything newer stays +// provisional and is re-read on the next hop. The commit point is snapped back +// into a run of blank frames so no character straddles it. When the window +// reaches its maximum, the oldest audio is dropped but holdSec + leftContextSec +// is kept, so there is no hard reset and no character or word is cut at a seam. +// +// Qt-free (std + DeepCwEngine) so the offline replay tool runs exactly this code. + +#include "DeepCwEngine.h" + +#include +#include +#include + +namespace AetherSDR { + +class DeepCwCommitter { +public: + explicit DeepCwCommitter(double holdSec = 5.0, double leftContextSec = 3.0, + double hopSec = 2.0); + + struct Result { + bool decoded{false}; // a model decode ran on this push + std::string text; // newly committed characters (may be empty) + float meanConf{1.0f}; // mean posterior of the committed characters + float pitchHz{0.0f}; // dominant tone of the decoded window + }; + + // Append model-rate (3200 Hz) mono audio; decodes when a hop has elapsed. + Result push(const float* audio3200, std::size_t n, const DeepCwEngine& eng); + + // Commit everything still provisional (offline end-of-file use). + Result flush(const DeepCwEngine& eng); + + void reset(); + + double holdSec() const { return m_hold; } + double windowSec() const { return m_window; } + +private: + Result decodeAndCommit(const DeepCwEngine& eng, bool flushAll); + + double m_hold, m_left, m_hop, m_window, m_keep; + std::vector m_buf; + std::size_t m_bufStart{0}; // absolute sample index of m_buf[0] + std::size_t m_lastDecodeEnd{0}; + double m_tCommit{-1.0}; // committed up to this time (s) + double m_tLastChar{-1.0}; // emission time of the last committed letter + char m_lastOut{0}; +}; + +} // namespace AetherSDR diff --git a/src/core/DeepCwEngine.cpp b/src/core/DeepCwEngine.cpp new file mode 100644 index 0000000000..7bc5316a54 --- /dev/null +++ b/src/core/DeepCwEngine.cpp @@ -0,0 +1,341 @@ +// Provenance: ported from decode_morse.py in e04/deepcw-engine (AGPL-3.0). +// See the header and THIRD_PARTY_LICENSES for the attribution. +#include "DeepCwEngine.h" + +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { + +// The model alphabet, indices 0-40 (blank is 41, outside this string). +// Pinned to model.onnx.json "chars": , . / 0-9 ? A-Z space. +static constexpr const char* kChars = ",./0123456789?ABCDEFGHIJKLMNOPQRSTUVWXYZ "; + +// M_PI is not guaranteed in without _USE_MATH_DEFINES (MSVC); use our own. +static constexpr double kPi = 3.14159265358979323846; + +// Self-contained iterative radix-2 FFT (N a power of two), in-place on re/im. +// Deliberately NOT FFTW: FFTW's planner (fftwf_plan_*/fftwf_destroy_plan) is not +// thread-safe and this runs on the CW decode worker thread concurrently with the +// app's other FFTW users (WDSP, spectral NR) — that race corrupts FFTW's global +// plan state and aborts (crash in hinsert). This routine holds no global state. +static void fftRadix2(float* re, float* im, int n) +{ + // Bit-reversal permutation. + for (int i = 1, j = 0; i < n; ++i) { + int bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { std::swap(re[i], re[j]); std::swap(im[i], im[j]); } + } + // Butterflies (decimation-in-time), twiddles by incremental rotation. + for (int len = 2; len <= n; len <<= 1) { + const double ang = -2.0 * kPi / len; + const float wr = static_cast(std::cos(ang)); + const float wi = static_cast(std::sin(ang)); + for (int i = 0; i < n; i += len) { + float cur = 1.0f, cui = 0.0f; // current twiddle + for (int k = 0; k < len / 2; ++k) { + const int a = i + k, b = i + k + len / 2; + const float xr = re[b] * cur - im[b] * cui; + const float xi = re[b] * cui + im[b] * cur; + re[b] = re[a] - xr; im[b] = im[a] - xi; + re[a] += xr; im[a] += xi; + const float ncur = cur * wr - cui * wi; + cui = cur * wi + cui * wr; + cur = ncur; + } + } + } +} + +DeepCwEngine::DeepCwEngine() +#ifdef HAVE_ONNX + : m_env(ORT_LOGGING_LEVEL_WARNING, "AetherSDR-DeepCW") + , m_memInfo(Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault)) +#endif +{ +#ifdef HAVE_ONNX + // CW audio is slow and windows are short; one intra-op thread keeps it off + // the hot path and deterministic (mirrors SignalClassifier). + m_sessionOpts.SetIntraOpNumThreads(1); + m_sessionOpts.SetGraphOptimizationLevel(ORT_ENABLE_BASIC); +#endif +} + +DeepCwEngine::~DeepCwEngine() = default; + +bool DeepCwEngine::loadModel(const std::string& path) +{ +#ifdef HAVE_ONNX + try { +#ifdef _WIN32 + const std::wstring wpath(path.begin(), path.end()); + m_session = std::make_unique(m_env, wpath.c_str(), m_sessionOpts); +#else + m_session = std::make_unique(m_env, path.c_str(), m_sessionOpts); +#endif + m_loaded = true; + return true; + } catch (const Ort::Exception& ex) { + std::fprintf(stderr, "DeepCwEngine: failed to load model '%s': %s\n", + path.c_str(), ex.what()); + return false; + } +#else + (void)path; + std::fprintf(stderr, "DeepCwEngine: built without HAVE_ONNX; cannot load %s\n", + path.c_str()); + return false; +#endif +} + +// Replicates decode_morse.py audio_to_spectrogram(): reflect-pad by fft/2, +// periodic Hann (np.hanning(N+1)[:-1]), rFFT magnitude over bins [32,97), +// then log1p. Row-major [frames * kFreqBins]. +std::vector DeepCwEngine::spectrogram(const std::vector& audio3200, int* frames) const +{ + *frames = 0; + if (static_cast(audio3200.size()) < kFftLength) { + return {}; + } + + // Frequency bin range: bin_hz = sr/fft; start=ceil(min/bin), stop=floor(max/bin)+1. + const double binHz = static_cast(kModelSampleRate) / kFftLength; + const int startBin = static_cast(std::ceil(kMinFreqHz / binHz)); + const int stopBin = static_cast(std::floor(kMaxFreqHz / binHz)) + 1; + if (stopBin - startBin != kFreqBins) { + std::fprintf(stderr, "DeepCwEngine: bin range %d..%d != %d bins\n", + startBin, stopBin, kFreqBins); + return {}; + } + + const int pad = kFftLength / 2; + + // Reflect-pad the signal by `pad` on each side (numpy mode="reflect": + // mirror without repeating the edge sample). + std::vector a; + a.reserve(audio3200.size() + 2 * pad); + for (int i = 0; i < pad; ++i) a.push_back(audio3200[pad - i]); + a.insert(a.end(), audio3200.begin(), audio3200.end()); + const int last = static_cast(audio3200.size()) - 1; + for (int i = 0; i < pad; ++i) a.push_back(audio3200[last - 1 - i]); + + // Periodic Hann of length fft: np.hanning(fft+1)[:-1] -> 0.5-0.5cos(2*pi*n/fft). + std::vector window(kFftLength); + for (int n = 0; n < kFftLength; ++n) { + window[n] = 0.5f - 0.5f * std::cos(2.0 * kPi * n / kFftLength); + } + + const int nFrames = 1 + (static_cast(a.size()) - kFftLength) / kHopLength; + + // Full N-point complex FFT of the windowed real frame (imag = 0); bins + // 0..N/2 hold the one-sided spectrum, so bin[startBin..stopBin) are the + // 400-1200 Hz magnitudes we keep. Matches numpy.fft.rfft (unnormalized). + std::vector spec(static_cast(nFrames) * kFreqBins); + std::vector re(kFftLength), im(kFftLength); + for (int f = 0; f < nFrames; ++f) { + const int start = f * kHopLength; + for (int n = 0; n < kFftLength; ++n) { + re[n] = a[start + n] * window[n]; + im[n] = 0.0f; + } + fftRadix2(re.data(), im.data(), kFftLength); + float* row = &spec[static_cast(f) * kFreqBins]; + for (int k = 0; k < kFreqBins; ++k) { + const int bin = startBin + k; + const float mag = std::sqrt(re[bin] * re[bin] + im[bin] * im[bin]); + row[k] = std::log1p(mag); // normalization == "log1p" + } + } + + *frames = nFrames; + return spec; +} + +std::string DeepCwEngine::ctcDecode(const float* logProbs, int frames, float* avgConfidence) const +{ + std::string text; + int previous = -1; + double confSum = 0.0; + int confCount = 0; + for (int t = 0; t < frames; ++t) { + const float* row = logProbs + static_cast(t) * kNumClasses; + int best = 0; + float bestVal = row[0]; + for (int c = 1; c < kNumClasses; ++c) { + if (row[c] > bestVal) { bestVal = row[c]; best = c; } + } + if (best == kBlankIndex) { previous = -1; continue; } + if (best != previous) { + text.push_back(kChars[best]); + // Confidence = softmax(row)[best], computed stably via logsumexp so + // it is a valid [0,1] probability whether the model emits logits or + // log-probabilities. + double sumExp = 0.0; + for (int c = 0; c < kNumClasses; ++c) + sumExp += std::exp(static_cast(row[c]) - bestVal); + confSum += 1.0 / sumExp; // exp(bestVal-bestVal)/sumExp = 1/sumExp + ++confCount; + } + previous = best; + } + if (avgConfidence) + *avgConfidence = confCount > 0 ? static_cast(confSum / confCount) : 1.0f; + return text; +} + +std::vector DeepCwEngine::inferLogProbs(const std::vector& audio3200, int* frames, + float* pitchHz) const +{ + *frames = 0; + if (pitchHz) *pitchHz = 0.0f; +#ifdef HAVE_ONNX + if (!m_loaded || !m_session) { return {}; } + int specFrames = 0; + const std::vector spec = spectrogram(audio3200, &specFrames); + if (specFrames == 0 || spec.empty()) { return {}; } + if (pitchHz) { // same peak-energy-bin estimate as decode() + const double binHz = static_cast(kModelSampleRate) / kFftLength; + const int startBin = static_cast(std::ceil(kMinFreqHz / binHz)); + int peak = 0; double peakE = -1.0; + for (int k = 0; k < kFreqBins; ++k) { + double e = 0.0; + for (int f = 0; f < specFrames; ++f) e += spec[static_cast(f) * kFreqBins + k]; + if (e > peakE) { peakE = e; peak = k; } + } + *pitchHz = static_cast((startBin + peak) * binHz); + } + try { + const std::array shape{1, 1, specFrames, kFreqBins}; + Ort::Value input = Ort::Value::CreateTensor( + m_memInfo, const_cast(spec.data()), spec.size(), + shape.data(), shape.size()); + const char* inputNames[] = {"spectrogram"}; + const char* outputNames[] = {"log_probs"}; + auto outputs = m_session->Run(Ort::RunOptions{nullptr}, + inputNames, &input, 1, outputNames, 1); + const float* lp = outputs[0].GetTensorData(); + const auto outShape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); + const int outFrames = outShape.size() >= 2 + ? static_cast(outShape[outShape.size() - 2]) : specFrames; + *frames = outFrames; + return std::vector(lp, lp + static_cast(outFrames) * kNumClasses); + } catch (const Ort::Exception& ex) { + std::fprintf(stderr, "DeepCwEngine: inference error: %s\n", ex.what()); + return {}; + } +#else + (void)audio3200; + return {}; +#endif +} + +std::vector DeepCwEngine::greedyEmissions( + const float* logProbs, int frames, std::vector* blankFrame) const +{ + // Same greedy rule as ctcDecode(): argmax per frame, blank resets, a new + // character on each change of non-blank argmax. Adds the frame index and + // the softmax posterior of the chosen class. + std::vector out; + if (blankFrame) blankFrame->assign(static_cast(std::max(frames, 0)), 0); + int previous = -1; + for (int t = 0; t < frames; ++t) { + const float* row = logProbs + static_cast(t) * kNumClasses; + int best = 0; + float bestVal = row[0]; + for (int c = 1; c < kNumClasses; ++c) { + if (row[c] > bestVal) { bestVal = row[c]; best = c; } + } + if (best == kBlankIndex) { + if (blankFrame) (*blankFrame)[static_cast(t)] = 1; + previous = -1; + continue; + } + if (best != previous) { + double sumExp = 0.0; + for (int c = 0; c < kNumClasses; ++c) + sumExp += std::exp(static_cast(row[c]) - bestVal); + out.push_back({kChars[best], t, static_cast(1.0 / sumExp)}); + } + previous = best; + } + return out; +} + +std::string DeepCwEngine::decode(const std::vector& audio, int sampleRateHz, + float* avgConfidence, float* pitchHz) const +{ + if (avgConfidence) *avgConfidence = 1.0f; + if (pitchHz) *pitchHz = 0.0f; +#ifdef HAVE_ONNX + if (!m_loaded || !m_session) { return {}; } + + // The engine does not resample: the caller must deliver model-rate audio + // through an anti-aliased SRC (CwDecoder uses r8brain). A wrong rate here + // would silently alias into the 400-1200 Hz band, so refuse it outright. + if (sampleRateHz != kModelSampleRate) { + static bool warned = false; + if (!warned) { + warned = true; + std::fprintf(stderr, "DeepCwEngine: expected %d Hz input, got %d — " + "resample before calling decode()\n", + kModelSampleRate, sampleRateHz); + } + return {}; + } + + int frames = 0; + const std::vector spec = spectrogram(audio, &frames); + if (frames == 0 || spec.empty()) { return {}; } + + // Dominant tone = peak-energy frequency bin summed across frames. Used for + // zero-beat and the pitch readout (the model itself takes no pitch input). + if (pitchHz) { + const double binHz = static_cast(kModelSampleRate) / kFftLength; + const int startBin = static_cast(std::ceil(kMinFreqHz / binHz)); + int peak = 0; double peakE = -1.0; + for (int k = 0; k < kFreqBins; ++k) { + double e = 0.0; + for (int f = 0; f < frames; ++f) e += spec[static_cast(f) * kFreqBins + k]; + if (e > peakE) { peakE = e; peak = k; } + } + *pitchHz = static_cast((startBin + peak) * binHz); + } + + try { + const std::array shape{1, 1, frames, kFreqBins}; + Ort::Value input = Ort::Value::CreateTensor( + m_memInfo, + const_cast(spec.data()), spec.size(), + shape.data(), shape.size()); + + const char* inputNames[] = {"spectrogram"}; + const char* outputNames[] = {"log_probs"}; + + auto outputs = m_session->Run(Ort::RunOptions{nullptr}, + inputNames, &input, 1, + outputNames, 1); + + const float* logProbs = outputs[0].GetTensorData(); + // Output shape [1, frames, kNumClasses]; frames may be re-derived from + // the tensor, but the model's time dim tracks our input frame count. + const auto info = outputs[0].GetTensorTypeAndShapeInfo(); + const auto outShape = info.GetShape(); + const int outFrames = outShape.size() >= 2 ? static_cast(outShape[outShape.size() - 2]) : frames; + return ctcDecode(logProbs, outFrames, avgConfidence); + } catch (const Ort::Exception& ex) { + std::fprintf(stderr, "DeepCwEngine: inference error: %s\n", ex.what()); + return {}; + } +#else + (void)audio; (void)sampleRateHz; + return {}; +#endif +} + +} // namespace AetherSDR diff --git a/src/core/DeepCwEngine.h b/src/core/DeepCwEngine.h new file mode 100644 index 0000000000..6dd99ce4bf --- /dev/null +++ b/src/core/DeepCwEngine.h @@ -0,0 +1,113 @@ +#pragma once + +// Provenance: the spectrogram + greedy-CTC pipeline in this engine is a C++ port +// of decode_morse.py from e04/deepcw-engine (https://github.com/e04/deepcw-engine), +// licensed AGPL-3.0. AetherSDR is GPL-3.0; GPLv3 §13 permits the combination. +// The ported code ships in the binary; the trained weights do not (download-on- +// demand, SHA-256 pinned). See THIRD_PARTY_LICENSES for the AGPL-3.0 entry. + +#include +#include +#include +#include + +#ifdef HAVE_ONNX +#include +#endif + +namespace AetherSDR { + +// Neural CW (Morse) decoder backend — the e04/deepcw-engine ONNX model +// (AGPL-3.0; download-on-demand, not shipped). A small CTC CNN over a +// log-magnitude spectrogram. Given a mono audio window already at the model's +// 3200 Hz rate, the engine builds the exact spectrogram the model was trained +// on, runs one ONNX Runtime inference, and greedy-CTC-decodes the log-probs to +// text. Resampling to 3200 Hz is the caller's job (CwDecoder does it with an +// anti-aliased r8brain SRC) — a non-integer decimation without a filter folds +// energy straight into the 400-1200 Hz analysis band. +// +// Deliberately Qt-free (pure std + ORT, self-contained radix-2 FFT) so it can be +// unit-tested and prototyped standalone; the CwDecoder wrapper adapts +// std::string -> QString. ORT calls compile out without HAVE_ONNX (decode() +// then returns empty). +// +// Model contract — pinned to model.onnx.json (verbatim), do not drift: +// input "spectrogram" float32 [1, 1, T, 65] (NCHW; T = time frames) +// output "log_probs" float32 [1, T, 42] (42 = 41 chars + CTC blank) +// sample_rate 3200, fft_length 256, hop_length 48, Hann window, +// band 400-1200 Hz -> 65 bins, normalization log1p, blank_index 41. +// Trained on 5-20 s windows (the time dim is dynamic, so other lengths run, +// but stay in range for in-distribution accuracy). +class DeepCwEngine { +public: + DeepCwEngine(); + ~DeepCwEngine(); + + // Load model.onnx from disk. Returns false (and logs to stderr) on error. + bool loadModel(const std::string& path); + bool isLoaded() const { return m_loaded; } + + // Decode a mono audio window. `audio` is float PCM in [-1, 1] and MUST already + // be at the model's rate — pass sampleRateHz == kModelSampleRate (3200). The + // engine does not resample (that needs an anti-aliased filter it deliberately + // leaves to the caller); a mismatched rate logs once and returns empty. + // Returns the decoded CW text (may be empty). Feed ~5-20 s of audio for best + // accuracy. + // When avgConfidence is non-null, it receives the mean per-emitted-character + // softmax confidence in [0,1] (1.0 when nothing decoded) — the caller can map + // this to a cost for the sensitivity filter (cost = 1 - confidence). + // When pitchHz is non-null, it receives the dominant CW tone frequency in the + // model's 400-1200 Hz band (peak-energy spectrogram bin), 0 if indeterminate + // — lets the client zero-beat and show a pitch readout even in neural mode. + std::string decode(const std::vector& audio, int sampleRateHz, + float* avgConfidence = nullptr, float* pitchHz = nullptr) const; + + // Run the same spectrogram + inference as decode() on a 3200 Hz window and + // return the raw log_probs, row-major [*frames x kNumClasses]. Empty on error. + // Used by DeepCwCommitter, which needs per-character timing. + // pitchHz (optional) gets the same dominant-tone estimate decode() gives. + std::vector inferLogProbs(const std::vector& audio3200, int* frames, + float* pitchHz = nullptr) const; + + // One greedy-CTC emission: the character, the frame it was emitted at + // (frame f ~ f * kHopLength / kModelSampleRate seconds into the window) and + // the softmax posterior of its class at that frame. + struct Emission { char ch; int frame; float conf; }; + // Greedy CTC over log_probs [frames x kNumClasses], same rule as + // ctcDecode(), keeping per-character timing. blankFrame (optional) gets 1 + // for every frame whose argmax is the CTC blank. + std::vector greedyEmissions(const float* logProbs, int frames, + std::vector* blankFrame = nullptr) const; + + // Greedy CTC decode of log_probs [frames, kNumClasses] -> text. When + // avgConfidence is non-null, sets it to the mean softmax probability of the + // argmax class across the timesteps that emitted a character. + std::string ctcDecode(const float* logProbs, int frames, float* avgConfidence) const; + + // Model contract constants (from model.onnx.json). + static constexpr int kModelSampleRate = 3200; + static constexpr int kFftLength = 256; + static constexpr int kHopLength = 48; + static constexpr double kMinFreqHz = 400.0; + static constexpr double kMaxFreqHz = 1200.0; + static constexpr int kFreqBins = 65; // stop_bin(97) - start_bin(32) + static constexpr int kNumClasses = 42; + static constexpr int kBlankIndex = 41; + static constexpr double kMinWindowSec = 5.0; + static constexpr double kMaxWindowSec = 20.0; + +private: + // Build the model spectrogram, flat row-major [frames * kFreqBins] + // (time-major, freq inner), from 3200 Hz mono audio. *frames set on return. + std::vector spectrogram(const std::vector& audio3200, int* frames) const; + + bool m_loaded{false}; +#ifdef HAVE_ONNX + Ort::Env m_env; + Ort::SessionOptions m_sessionOpts; + std::unique_ptr m_session; + Ort::MemoryInfo m_memInfo; +#endif +}; + +} // namespace AetherSDR diff --git a/tools/deepcw_replay.cpp b/tools/deepcw_replay.cpp new file mode 100644 index 0000000000..b59ee673b1 --- /dev/null +++ b/tools/deepcw_replay.cpp @@ -0,0 +1,183 @@ +// deepcw_replay — replay a recorded CW WAV through the DeepCW engine offline. +// +// The regression harness asked for on RFC #4817: run a recorded take through +// the decode paths and compare the text they produce on identical audio. +// +// deepcw_replay +// -window.txt each 15 s window decoded once, whole (what the +// model reads with full context; no streaming) +// -grow.txt the pre-DeepCwCommitter loop (grow to 15 s, +// prefix-extension emission, hard reset) as the +// panel showed it via QTextEdit::insertHtml +// deepcw_replay commit [holdSec] +// -commit-H.txt the shipped path: DeepCwCommitter fed +// in 200 ms drains, exactly as decodeLoopDeep() +// +// Input: PCM16 WAV at 24 kHz, mono or stereo (the QSO recorder's format). + +#include "core/DeepCwCommitter.h" +#include "core/DeepCwEngine.h" +#include "core/Resampler.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using AetherSDR::DeepCwCommitter; +using AetherSDR::DeepCwEngine; +using AetherSDR::Resampler; + +namespace { + +constexpr int kRate = DeepCwEngine::kModelSampleRate; +constexpr size_t kDrain = 4800; // 200 ms of 24 kHz, the worker's drain cadence + +bool readWav(const char* path, std::vector& mono24k) +{ + std::ifstream f(path, std::ios::binary); + char riff[12]; + if (!f.read(riff, 12) || std::memcmp(riff, "RIFF", 4) || std::memcmp(riff + 8, "WAVE", 4)) return false; + uint16_t channels = 0, bits = 0, fmt = 0; + uint32_t rate = 0; + while (f) { + char id[4]; + uint32_t len = 0; + f.read(id, 4); + f.read(reinterpret_cast(&len), 4); + if (!f) break; + if (!std::memcmp(id, "fmt ", 4)) { + std::vector b(len); + f.read(b.data(), len); + std::memcpy(&fmt, b.data(), 2); + std::memcpy(&channels, b.data() + 2, 2); + std::memcpy(&rate, b.data() + 4, 4); + std::memcpy(&bits, b.data() + 14, 2); + } else if (!std::memcmp(id, "data", 4)) { + if (fmt != 1 || bits != 16 || rate != 24000 || channels < 1 || channels > 2) { + std::fprintf(stderr, "need PCM16 24 kHz mono/stereo\n"); + return false; + } + std::vector s(len / 2); + f.read(reinterpret_cast(s.data()), static_cast(s.size() * 2)); + const size_t n = s.size() / channels; + mono24k.resize(n); + for (size_t i = 0; i < n; ++i) { // same downmix as CwDecoder::feedAudio + const float l = s[i * channels] / 32768.0f; + const float r = channels == 2 ? s[i * channels + 1] / 32768.0f : l; + mono24k[i] = (l + r) * 0.5f; + } + return true; + } else { + f.seekg(len + (len & 1), std::ios::cur); + } + } + return false; +} + +// 24 kHz -> 3200 Hz in drain-sized blocks, as the worker does. +std::vector> resampleInDrains(const std::vector& in24k) +{ + Resampler rs(24000.0, kRate); + std::vector> out; + for (size_t off = 0; off < in24k.size(); off += kDrain) { + const int n = static_cast(std::min(kDrain, in24k.size() - off)); + const QByteArray o = rs.process(in24k.data() + off, n); + const auto* r = reinterpret_cast(o.constData()); + out.emplace_back(r, r + o.size() / int(sizeof(float))); + } + return out; +} + +bool writeText(const std::string& path, const std::string& header, const std::string& body) +{ + std::FILE* f = std::fopen(path.c_str(), "w"); + if (!f) return false; + std::fprintf(f, "%s\n%s\n", header.c_str(), body.c_str()); + std::fclose(f); + return true; +} + +} // namespace + +int main(int argc, char** argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + if (argc < 4) { + std::fprintf(stderr, "usage: %s [commit [holdSec]]\n", argv[0]); + return 2; + } + const std::string out = argv[3]; + DeepCwEngine eng; + if (!eng.loadModel(argv[1])) return 1; + std::vector in24k; + if (!readWav(argv[2], in24k)) { std::fprintf(stderr, "cannot read %s\n", argv[2]); return 1; } + const auto drains = resampleInDrains(in24k); + + if (argc > 4 && std::string(argv[4]) == "commit") { + const double hold = argc > 5 ? std::atof(argv[5]) : 5.0; + DeepCwCommitter c(hold); + std::string text; + for (const auto& d : drains) text += c.push(d.data(), d.size(), eng).text; + text += c.flush(eng).text; + char name[48]; + std::snprintf(name, sizeof name, "-commit-H%.0f.txt", hold); + writeText(out + name, "hold=" + std::to_string(hold), text); + std::printf("commit hold=%.1f s: %zu chars\n", hold, text.size()); + return 0; + } + + // Whole 15 s windows, each decoded once. + std::vector all; + for (const auto& d : drains) all.insert(all.end(), d.begin(), d.end()); + const size_t win = static_cast(kRate) * 15; + std::string windows; + for (size_t s0 = 0; s0 + static_cast(kRate * DeepCwEngine::kMinWindowSec) <= all.size(); s0 += win) { + const std::vector seg(all.begin() + s0, all.begin() + std::min(all.size(), s0 + win)); + windows += eng.decode(seg, kRate) + " | "; + } + writeText(out + "-window.txt", "15 s windows, each decoded once ( | = window boundary)", windows); + + // The pre-DeepCwCommitter loop, rendered the way the panel rendered it. + const size_t minDecode = kRate * 5, hop = kRate * 2, maxSeg = kRate * 15; + std::vector seg; + std::string emitted; + size_t lastDecode = 0; + int dropped = 0; + QTextEdit panel; + for (const auto& d : drains) { + seg.insert(seg.end(), d.begin(), d.end()); + if (seg.size() >= minDecode && seg.size() >= lastDecode + hop) { + const std::string text = eng.decode(seg, kRate); + lastDecode = seg.size(); + if (text.size() >= emitted.size() && text.compare(0, emitted.size(), emitted) == 0) { + const std::string delta = text.substr(emitted.size()); + if (!delta.empty()) { + panel.moveCursor(QTextCursor::End); + panel.insertHtml(QString("%1") + .arg(QString::fromStdString(delta).toHtmlEscaped())); + emitted = text; + } + } else { + ++dropped; // divergent revision: adopted silently, never shown + emitted = text; + } + } + if (seg.size() >= maxSeg) { seg.clear(); emitted.clear(); lastDecode = 0; } + } + writeText(out + "-grow.txt", + "pre-commit loop as shown via insertHtml; divergent revisions dropped: " + std::to_string(dropped), + panel.toPlainText().toStdString()); + std::printf("window + grow views written; %d divergent revisions dropped\n", dropped); + return 0; +} From b5af57aabfaf0ecaaa8515d494dc06809f294c40 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:34:12 -0700 Subject: [PATCH 02/11] feat(cw): DeepCW joins the CW receive decoders after ggmorse and DeepFist. Principle VIII. RFC #4817 (approved 2026-09-14 as a pluggable backend framework, DeepCW first): DeepCwRxBackend enters CwRxModel's catalog as "deepcw" whenever ONNX Runtime is found, through the same interface DeepFist uses. Only the selected backend runs; the TX decoder stays ggmorse. - Model: e04/deepcw-engine model.onnx at its pinned commit (upstream source only, no AetherSDR mirror), size + SHA-256 checked, cached under the app data dir; prepared by the existing DeepFistModelAssets downloader, so the download no longer depends on AETHER_ASR_ENABLED. Loaded on the backend's worker, never the GUI thread. - Output: coloredTextDecoded, one colour per committed chunk (1 - mean CTC confidence), like DeepFist's; the dominant-tone pitch feeds Zero Beat and is cleared when the decoder stops or resets. - UI: the CW panel's decoder selector, status label and Cancel/Retry now appear whenever a second backend exists (HAVE_CW_RX_BACKENDS), not only in DeepFist experiment builds. The CW Neural compare applet and the second decoder instance stay on the prototype branch. Refs #4817 Co-authored-by: K5PTB <274291579+K5PTB@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 11 +- src/gui/MainWindow.cpp | 6 +- src/gui/MainWindow.h | 2 +- src/gui/MainWindow_DigitalModes.cpp | 2 +- src/gui/PanadapterApplet.cpp | 27 +-- src/gui/PanadapterApplet.h | 8 +- src/gui/VfoWidget.cpp | 6 +- src/gui/VfoWidget.h | 2 +- src/models/CwRxModel.cpp | 9 + src/models/DeepCwRxBackend.cpp | 249 ++++++++++++++++++++++++++++ src/models/DeepCwRxBackend.h | 80 +++++++++ 11 files changed, 375 insertions(+), 27 deletions(-) create mode 100644 src/models/DeepCwRxBackend.cpp create mode 100644 src/models/DeepCwRxBackend.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9fea5ee31e..13c0fa64a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3175,16 +3175,15 @@ target_sources(aethercore PRIVATE src/models/CwRxModel.cpp src/models/CwRxModel. option(ENABLE_DEEPFIST_EXPERIMENT "Build the local DeepFist CW prototype" OFF) set(DEEPFIST_MODEL_BASE_URL "" CACHE STRING "Published, versioned HTTPS DeepFist asset directory (empty before publication)") +target_compile_definitions(aethercore PRIVATE DEEPFIST_MODEL_BASE_URL="${DEEPFIST_MODEL_BASE_URL}") if(ENABLE_DEEPFIST_EXPERIMENT) if(NOT ORT_FOUND) message(FATAL_ERROR "The DeepFist prototype requires the packaged ONNX Runtime") endif() target_compile_definitions(aethercore PUBLIC HAVE_DEEPFIST) - target_compile_definitions(aethercore PRIVATE DEEPFIST_MODEL_BASE_URL="${DEEPFIST_MODEL_BASE_URL}") target_sources(aethercore PRIVATE src/models/DeepFistCwModel.cpp src/models/DeepFistCwModel.h src/core/deepfist/DeepFistStream.cpp - src/core/deepfist/DeepFistModelAssets.cpp src/core/deepfist/DeepFistModelAssets.h third_party/deepfist/DeepFistConditioner.cpp third_party/deepfist/DeepFistSpectrogram.cpp third_party/deepfist/DeepFistCtc.cpp @@ -3194,6 +3193,14 @@ endif() if(ORT_FOUND) target_compile_definitions(aethercore PUBLIC HAVE_ONNX) + # Every CW receive backend beyond ggmorse needs ONNX Runtime, so this one + # gate decides whether the CW panel offers a decoder selector (RFC #4817). + target_compile_definitions(aethercore PUBLIC HAVE_DEEPCW HAVE_CW_RX_BACKENDS) + target_sources(aethercore PRIVATE + src/core/DeepCwEngine.cpp src/core/DeepCwEngine.h + src/core/DeepCwCommitter.cpp src/core/DeepCwCommitter.h + src/models/DeepCwRxBackend.cpp src/models/DeepCwRxBackend.h + src/core/deepfist/DeepFistModelAssets.cpp src/core/deepfist/DeepFistModelAssets.h) # PUBLIC include dir: core/SignalClassifier.h includes onnxruntime under # HAVE_ONNX (PUBLIC) and is pulled in by gui/MainWindow.h. Same transitive # leak as FFTW3 above. Library link stays PRIVATE (transitive via the lib). diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 08a3c6a12e..a290c53ada 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2627,7 +2627,7 @@ MainWindow::MainWindow(QWidget* parent) MainWindow::~MainWindow() { -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS m_cwDecoder.stop(); #endif ShutdownTrace destructorTrace("main_window.destructor_body"); @@ -9430,7 +9430,7 @@ void MainWindow::routeCwDecoderOutput() // decoder target. Hide it before dropping ownership so a later refresh // cannot leave an orphaned CW dock on the old pan (#4409). m_cwDecoderApplet->setCwPanelVisible(false); -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS disconnect(m_cwDecoderApplet, &PanadapterApplet::cwEngineChanged, this, &MainWindow::selectCwRxBackend); disconnect(m_cwDecoderApplet, &PanadapterApplet::cwModelActionRequested, @@ -9549,7 +9549,7 @@ void MainWindow::refreshCwDecodeState() // RX decoder runs only when RX-decode is on and the operator is // listening to a CW slice. Non-CW slices feed unrelated audio, // and the panel is hidden anyway. -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS refreshCwRxBackend(); #endif const bool shouldRunRx = isCw && rxOn; diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 190458c8df..460880e93c 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -1323,7 +1323,7 @@ private slots: Kpa500Connection m_kpa500Conn; // Elecraft KPA500 amplifier, serial or raw TCP Kpa1500Connection m_kpa1500Conn; // Elecraft KPA1500 amplifier, TCP control/status on port 1500 (#4097) BandPlanManager* m_bandPlanMgr{nullptr}; -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS void selectCwRxBackend(const QString& backend); void cwRxModelAction(); void refreshCwRxStatus(); diff --git a/src/gui/MainWindow_DigitalModes.cpp b/src/gui/MainWindow_DigitalModes.cpp index f316af61a4..5bc917a237 100644 --- a/src/gui/MainWindow_DigitalModes.cpp +++ b/src/gui/MainWindow_DigitalModes.cpp @@ -102,7 +102,7 @@ void MainWindow::stopCwRx() } -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS void MainWindow::selectCwRxBackend(const QString& backend) { if (!m_cwDecoder.selectBackend(backend)) { return; } diff --git a/src/gui/PanadapterApplet.cpp b/src/gui/PanadapterApplet.cpp index 1ef4ca1967..c04d5cc86c 100644 --- a/src/gui/PanadapterApplet.cpp +++ b/src/gui/PanadapterApplet.cpp @@ -203,13 +203,15 @@ PanadapterApplet::PanadapterApplet(QWidget* parent) }); cwBar->addWidget(m_cwSensSlider); -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS m_cwEngineCombo = new GuardedComboBox(this); m_cwEngineCombo->setObjectName("cwRxEngine"); m_cwEngineCombo->setAccessibleName(tr("CW receive decoder")); - m_cwEngineCombo->setAccessibleDescription(tr("Select ggmorse or the experimental DeepFist decoder for the selected slice")); + m_cwEngineCombo->setAccessibleDescription(tr("Select ggmorse or a neural decoder for the selected slice")); for (const QString& key : CwRxModel::availableBackends()) { - m_cwEngineCombo->addItem(key == "deepfist" ? tr("DeepFist") : key, key); + const QString label = key == "deepfist" ? tr("DeepFist") + : key == "deepcw" ? tr("DeepCW") : key; + m_cwEngineCombo->addItem(label, key); } m_cwEngineCombo->setToolTip(tr("Receive decoder; transmit sidetone continues to use ggmorse")); cwBar->addWidget(m_cwEngineCombo); @@ -820,8 +822,8 @@ QString PanadapterApplet::cwCostColor(float cost) void PanadapterApplet::appendCwText(const QString& text, float cost) { -#ifdef HAVE_DEEPFIST - if (deepFistEngineSelected()) { return; } +#ifdef HAVE_CW_RX_BACKENDS + if (neuralEngineSelected()) { return; } #endif // Filter by sensitivity threshold — drop low-confidence decodes if (cost >= m_cwCostThreshold) return; @@ -846,14 +848,15 @@ void PanadapterApplet::appendCwText(const QString& text, float cost) emit cwRxTextDisplayed(clean); } -#ifdef HAVE_DEEPFIST -bool PanadapterApplet::deepFistEngineSelected() const +#ifdef HAVE_CW_RX_BACKENDS +bool PanadapterApplet::neuralEngineSelected() const { + // Every backend other than ggmorse is a neural one with its own text path. // Compare the stored key, never the row: findData() returns -1 for a key // this build does not offer, and the catalog is meant to grow. - return m_cwEngineCombo - && m_cwEngineCombo->itemData(m_cwEngineCombo->currentIndex()).toString() - == QLatin1String("deepfist"); + if (!m_cwEngineCombo || m_cwEngineCombo->currentIndex() < 0) { return false; } + return m_cwEngineCombo->itemData(m_cwEngineCombo->currentIndex()).toString() + != QLatin1String("ggmorse"); } void PanadapterApplet::setCwBackendState(const QString& key, bool tuning, const QString& status, bool preparing, bool canRetry, const QString& detail) @@ -955,9 +958,9 @@ void PanadapterApplet::setCwInputHint(const QString& hint, const QString& reason void PanadapterApplet::setCwStats(float pitchHz, float speedWpm) { -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS // ggmorse may still have queued deliveries after the engine selector changes. - if (deepFistEngineSelected()) { return; } + if (neuralEngineSelected()) { return; } #endif if (pitchHz > 0 && speedWpm > 0) m_cwStatsLabel->setText(QString("%1 Hz %2 WPM").arg(pitchHz, 0, 'f', 0).arg(speedWpm, 0, 'f', 0)); diff --git a/src/gui/PanadapterApplet.h b/src/gui/PanadapterApplet.h index ed2f58cbe2..d659c2adcd 100644 --- a/src/gui/PanadapterApplet.h +++ b/src/gui/PanadapterApplet.h @@ -78,8 +78,8 @@ class PanadapterApplet : public CanonPan { void setCwStats(float pitchHz, float speedWpm); void setCwInputHint(const QString& hint, const QString& reason); void clearCwText(); -#ifdef HAVE_DEEPFIST - bool deepFistEngineSelected() const; +#ifdef HAVE_CW_RX_BACKENDS + bool neuralEngineSelected() const; void setCwBackendState(const QString& key, bool tuning, const QString& status, bool preparing, bool canRetry, const QString& detail); void appendUnscoredCwText(const QString& text); @@ -112,7 +112,7 @@ class PanadapterApplet : public CanonPan { QSize sizeHint() const override { return {800, 316}; } signals: -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS void cwEngineChanged(const QString& backend); void cwModelActionRequested(); #endif @@ -190,7 +190,7 @@ class PanadapterApplet : public CanonPan { #endif // CW decode -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS QComboBox* m_cwEngineCombo{nullptr}; QPushButton* m_cwModelAction{nullptr}; #endif diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index b7e1536607..4a0cc4a371 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -4,7 +4,7 @@ #include "AntennaChoiceGate.h" #include "SplitAudioProfile.h" #include "VfoDisplayDefaults.h" -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS #include "models/CwDecodeSettings.h" #endif #include "ScopedChildWidget.h" @@ -6229,7 +6229,7 @@ void VfoWidget::rebuildFilterButtons() } else { m_zeroBeatBtn = new QPushButton("Zero Beat"); m_zeroBeatBtn->setFixedHeight(26); -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS refreshCwDecoderControls(); #endif m_zeroBeatBtn->setStyleSheet(btnStyle); @@ -6249,7 +6249,7 @@ void VfoWidget::rebuildFilterButtons() updateFilterHighlight(); } -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS void VfoWidget::refreshCwDecoderControls() { if (!m_zeroBeatBtn) { return; } diff --git a/src/gui/VfoWidget.h b/src/gui/VfoWidget.h index 598e1cc767..108cfb56c9 100644 --- a/src/gui/VfoWidget.h +++ b/src/gui/VfoWidget.h @@ -55,7 +55,7 @@ class VfoWidget : public QWidget { public: explicit VfoWidget(QWidget* parent = nullptr); ~VfoWidget() override; -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS void refreshCwDecoderControls(); #endif diff --git a/src/models/CwRxModel.cpp b/src/models/CwRxModel.cpp index a6a3bd8eb7..d6cbcb2170 100644 --- a/src/models/CwRxModel.cpp +++ b/src/models/CwRxModel.cpp @@ -4,6 +4,9 @@ #ifdef HAVE_DEEPFIST #include "DeepFistCwModel.h" #endif +#ifdef HAVE_DEEPCW +#include "DeepCwRxBackend.h" +#endif namespace AetherSDR { namespace { // Not advertised; see StubRxBackend below. @@ -88,6 +91,9 @@ std::shared_ptr makeBackend(const QString& key) if (key == kTestBackendKey) { return std::make_shared(); } #ifdef HAVE_DEEPFIST if (key == "deepfist") { return std::make_shared(); } +#endif +#ifdef HAVE_DEEPCW + if (key == "deepcw") { return std::make_shared(); } #endif return {}; } @@ -108,6 +114,9 @@ QStringList CwRxModel::availableBackends() QStringList result{QStringLiteral("ggmorse")}; #ifdef HAVE_DEEPFIST result.append(QStringLiteral("deepfist")); +#endif +#ifdef HAVE_DEEPCW + result.append(QStringLiteral("deepcw")); #endif return result; } diff --git a/src/models/DeepCwRxBackend.cpp b/src/models/DeepCwRxBackend.cpp new file mode 100644 index 0000000000..566047e558 --- /dev/null +++ b/src/models/DeepCwRxBackend.cpp @@ -0,0 +1,249 @@ +#include "DeepCwRxBackend.h" +#include "core/DeepCwCommitter.h" +#include "core/DeepCwEngine.h" +#include "core/LogManager.h" +#include "core/Resampler.h" +#include "core/deepfist/DeepFistModelAssets.h" +#include +#include +#include +#include + +namespace AetherSDR { +namespace { +// e04/deepcw-engine's only model revision (commit "init", 2026-06-15). The +// commit-pinned URL cannot move under us; size and SHA-256 are checked anyway. +constexpr auto kModelCommit = "9185d5da7d2344393d4e28352cd66c21ed83cad6"; +const QString kModelFile = QStringLiteral("model.onnx"); + +QVector deepCwManifest() +{ + return {{kModelFile, 15139839, + "ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02"}}; +} +} + +QString DeepCwRxBackend::modelDirectory() +{ + const QString overridePath = qEnvironmentVariable("AETHER_DEEPCW_MODEL_DIR"); + return overridePath.isEmpty() + ? QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/models/deepcw-9185d5da") : overridePath; +} + +QString DeepCwRxBackend::modelBaseUrl() +{ + return QStringLiteral("https://raw.githubusercontent.com/e04/deepcw-engine/%1/") + .arg(QLatin1String(kModelCommit)); +} + +DeepCwRxBackend::DeepCwRxBackend(QObject* parent) + : DeepCwRxBackend(modelDirectory(), modelBaseUrl(), nullptr, parent) {} + +DeepCwRxBackend::DeepCwRxBackend(QString directory, QString baseUrl, + QNetworkAccessManager* network, QObject* parent) + : CwRxBackend(parent), + m_assets(std::make_unique(directory, std::move(baseUrl), + deepCwManifest(), network)), + m_directory(std::move(directory)) +{ + connect(m_assets.get(), &DeepFistModelAssets::checking, this, [this] { + setStatus(tr("Checking model…")); + }); + connect(m_assets.get(), &DeepFistModelAssets::progress, this, [this](qint64 got, qint64 total) { + setStatus(tr("Download %1%").arg(total > 0 ? got * 100 / total : 0)); + }); + connect(m_assets.get(), &DeepFistModelAssets::failed, this, [this](const QString& reason) { + m_preparing = false; + m_canRetry = true; + m_detail = reason; + setStatus(tr("Model unavailable")); + }); + connect(m_assets.get(), &DeepFistModelAssets::ready, this, [this] { + if (!m_started || !m_preparing) { return; } + m_preparing = false; + m_detail.clear(); + setStatus(tr("Loading DeepCW…")); + launchWorker(); + }); +} + +DeepCwRxBackend::~DeepCwRxBackend() +{ + // Destruction must not publish status into a still-connected owner. + disconnect(); + stop(); +} + +void DeepCwRxBackend::setStatus(const QString& status) +{ + if (status == m_status) { return; } + m_status = status; + emit statusChanged(); +} + +void DeepCwRxBackend::postStatus(quint64 runId, const QString& status, bool failure) +{ + QMetaObject::invokeMethod(this, [this, runId, status, failure] { + if (runId != m_runId || !m_started) { return; } + m_canRetry = failure; + setStatus(status); + }, Qt::QueuedConnection); +} + +void DeepCwRxBackend::start() +{ + if (m_started) { return; } + m_started = true; + ++m_runId; + if (m_loaded) { + launchWorker(); + setStatus(tr("DeepCW ready")); + return; + } + prepare(); +} + +void DeepCwRxBackend::prepare() +{ + m_preparing = true; + m_canRetry = false; + m_detail.clear(); + setStatus(tr("Checking model…")); + m_assets->ensure(); +} + +void DeepCwRxBackend::stop() +{ + m_started = false; + if (m_preparing) { + m_assets->cancel(); + m_preparing = false; + } + stopWorker(); +} + +void DeepCwRxBackend::cancelPreparation() +{ + if (!m_preparing) { return; } + m_assets->cancel(); + m_preparing = false; + m_canRetry = true; + setStatus(tr("Download canceled")); +} + +void DeepCwRxBackend::retry() +{ + if (!m_started || m_preparing || m_workerRun) { return; } + stopWorker(); // joins a worker that exited on a failed load + prepare(); +} + +void DeepCwRxBackend::launchWorker() +{ + if (m_worker) { return; } + { + QMutexLocker lock(&m_ringMutex); + m_ring.clear(); + } + m_resetRequested = false; + m_workerRun = true; + const quint64 runId = m_runId; + const QString modelPath = QDir(m_directory).filePath(kModelFile); + m_worker = QThread::create([this, runId, modelPath] { decodeLoop(runId, modelPath); }); + m_worker->setObjectName("DeepCwRx"); + m_worker->start(); +} + +void DeepCwRxBackend::stopWorker() +{ + if (!m_worker) { return; } + m_workerRun = false; + m_worker->wait(); + delete m_worker; + m_worker = nullptr; + m_pitch = 0.0f; // no estimate outlives the decoder (#5213) + QMutexLocker lock(&m_ringMutex); + m_ring.clear(); +} + +void DeepCwRxBackend::reset() +{ + m_pitch = 0.0f; + QMutexLocker lock(&m_ringMutex); + m_ring.clear(); + m_resetRequested = true; +} + +void DeepCwRxBackend::feedFixed24(const DecoderPcmBlock& block) +{ + if (!m_workerRun || !m_loaded || !block.current()) { return; } + QMutexLocker lock(&m_ringMutex); + if (block.discontinuity) { + m_ring.clear(); + m_resetRequested = true; + } + m_ring.insert(m_ring.end(), block.samples.cbegin(), block.samples.cend()); + if (m_ring.size() > kRingCapacity) { + m_ring.erase(m_ring.begin(), + m_ring.begin() + static_cast(m_ring.size() - kRingCapacity)); + } +} + +// K5PTB's DeepCW worker loop (prototype CwDecoder::decodeLoopDeep), fed by the +// 24 kHz mono ring: resample to the model's 3200 Hz with an anti-aliased +// r8brain SRC (a 7.5x decimation; a naive resample folds energy into the +// 400-1200 Hz analysis band), then DeepCwCommitter: a sliding window re-decoded +// every 2 s whose characters are shown once they are holdSec behind the live edge. +// AETHER_DEEPCW_HOLD_S overrides the 5 s hold (local bench knob). +void DeepCwRxBackend::decodeLoop(quint64 runId, const QString& modelPath) +{ + if (!m_loaded) { + if (!m_engine) { m_engine = std::make_unique(); } + m_loaded = m_engine->loadModel(modelPath.toStdString()); + qCInfo(lcDsp) << "DeepCwRxBackend: model load" << (m_loaded ? "ok" : "FAILED") << modelPath; + if (!m_loaded) { + m_workerRun = false; + postStatus(runId, tr("Model load failed"), true); + return; + } + } + postStatus(runId, tr("DeepCW ready"), false); + + constexpr int kRate = DeepCwEngine::kModelSampleRate; + double holdSec = 5.0; + bool holdOk = false; + const double envHold = qEnvironmentVariable("AETHER_DEEPCW_HOLD_S").toDouble(&holdOk); + if (holdOk && envHold >= 1.0 && envHold <= 14.0) { holdSec = envHold; } + + // Worker-local: neither the resampler nor the committer is thread-safe. + auto resampler = std::make_unique(24000.0, static_cast(kRate)); + auto committer = std::make_unique(holdSec); + + while (m_workerRun) { + if (m_resetRequested.exchange(false)) { + resampler = std::make_unique(24000.0, static_cast(kRate)); + committer = std::make_unique(holdSec); + } + std::vector in24k; + { + QMutexLocker lock(&m_ringMutex); + in24k.swap(m_ring); + } + if (!in24k.empty()) { + const QByteArray out = resampler->process(in24k.data(), static_cast(in24k.size())); + const auto* r = reinterpret_cast(out.constData()); + const auto m = static_cast(out.size() / static_cast(sizeof(float))); + const DeepCwCommitter::Result res = committer->push(r, m, *m_engine); + // Dominant-tone pitch feeds Zero Beat; a CTC model has no speed estimate. + if (res.decoded && res.pitchHz > 0.0f) { m_pitch = res.pitchHz; } + // One colour per committed chunk: 1 - mean CTC confidence (lower is better). + if (!res.text.empty()) { + emit coloredTextDecoded(QString::fromStdString(res.text), 1.0f - res.meanConf); + } + } + QThread::msleep(200); + } +} + +} // namespace AetherSDR diff --git a/src/models/DeepCwRxBackend.h b/src/models/DeepCwRxBackend.h new file mode 100644 index 0000000000..705bc31aab --- /dev/null +++ b/src/models/DeepCwRxBackend.h @@ -0,0 +1,80 @@ +#pragma once +#include "CwRxModel.h" +#include +#include +#include +#include +#include + +class QNetworkAccessManager; +class QThread; + +namespace AetherSDR { + +class DeepCwEngine; +class DeepFistModelAssets; + +// DeepCW (RFC #4817, K5PTB's port of e04/deepcw-engine) behind CwRxBackend. +// Consumes the converted mono 24 kHz feed (feedFixed24), resamples it to the +// model's 3200 Hz on its own worker, and commits text through DeepCwCommitter. +// start() first prepares the model: cached file verified, else downloaded from +// e04's repository at a pinned commit (upstream source only, no mirror). +class DeepCwRxBackend final : public CwRxBackend { + Q_OBJECT +public: + explicit DeepCwRxBackend(QObject* parent = nullptr); + // Directory, source and network are injectable so tests need no real cache. + DeepCwRxBackend(QString directory, QString baseUrl, QNetworkAccessManager* network, + QObject* parent = nullptr); + ~DeepCwRxBackend() override; + + static QString modelDirectory(); + static QString modelBaseUrl(); + + void start() override; + void stop() override; + void reset() override; + void feedFixed24(const DecoderPcmBlock& block) override; + bool isRunning() const override { return m_started; } + float estimatedPitch() const override { return m_pitch.load(); } + QString status() const override { return m_status; } + QString detail() const override { return m_detail; } + bool preparing() const override { return m_preparing; } + bool canRetry() const override { return m_canRetry; } + void cancelPreparation() override; + void retry() override; + +private: + void prepare(); + void launchWorker(); + void stopWorker(); + void decodeLoop(quint64 runId, const QString& modelPath); + void postStatus(quint64 runId, const QString& status, bool failure); + void setStatus(const QString& status); + + std::unique_ptr m_assets; + QString m_directory; + + // Owner thread. + bool m_started{false}; + bool m_preparing{false}; + bool m_canRetry{false}; + QString m_status; + QString m_detail; + quint64 m_runId{0}; + + // Worker-owned while it runs; the join in stopWorker() hands it back. + std::unique_ptr m_engine; + std::atomic m_loaded{false}; + std::atomic m_workerRun{false}; + std::atomic m_resetRequested{false}; + std::atomic m_pitch{0.0f}; + QThread* m_worker{nullptr}; + + // Mono float32 @24 kHz handoff ring, capped at kRingCapacity samples. + QMutex m_ringMutex; + std::vector m_ring; + static constexpr std::size_t kRingCapacity = 24000 * 4; +}; + +} // namespace AetherSDR From 35e4fd744675f29cd02676c70736c784ab5b474e Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:27:35 -0700 Subject: [PATCH 03/11] feat(cw): DeepFist and DeepCW build wherever ONNX Runtime is found, models downloaded from upstream. Principle VIII. Both neural CW receive decoders now build in every configuration that finds ONNX Runtime (Apple Silicon, Linux, Windows; the pinned runtime has no Intel-macOS build), so ENABLE_DEEPFIST_EXPERIMENT is retired. Neither model ships with the app: each downloads on first use at a pinned size and SHA-256. - DEEPFIST_MODEL_BASE_URL defaults to N9BC's exp27_bt-champion release, whose deepfist.onnx and deepfist.onnx.json match the pinned sizes and hashes. - That release publishes no LICENSE. An asset may now carry its own HTTPS source, and the manifest's LICENSE (the same 1068 pinned bytes) is fetched from the DeepFist repository's first commit, 061fc1d7. - The DeepFist model tests register whenever ONNX Runtime is found. Refs #4817 Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 31 ++++++++++------------- src/core/deepfist/DeepFistModelAssets.cpp | 16 +++++++----- src/core/deepfist/DeepFistModelAssets.h | 3 ++- tests/tests.cmake | 2 +- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 13c0fa64a4..2d55476686 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3173,34 +3173,31 @@ endif() target_sources(aethercore PRIVATE src/models/CwRxModel.cpp src/models/CwRxModel.h) -option(ENABLE_DEEPFIST_EXPERIMENT "Build the local DeepFist CW prototype" OFF) -set(DEEPFIST_MODEL_BASE_URL "" CACHE STRING "Published, versioned HTTPS DeepFist asset directory (empty before publication)") +# Neural CW receive decoders (RFC #4817): built wherever ONNX Runtime is found +# (Apple Silicon, Linux, Windows; the pinned runtime has no Intel-macOS build). +# Their models are never shipped: each downloads on first use from its upstream +# release at a pinned size and SHA-256. +set(DEEPFIST_MODEL_BASE_URL "https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/" + CACHE STRING "Published, versioned HTTPS DeepFist asset directory") target_compile_definitions(aethercore PRIVATE DEEPFIST_MODEL_BASE_URL="${DEEPFIST_MODEL_BASE_URL}") -if(ENABLE_DEEPFIST_EXPERIMENT) - if(NOT ORT_FOUND) - message(FATAL_ERROR "The DeepFist prototype requires the packaged ONNX Runtime") - endif() - target_compile_definitions(aethercore PUBLIC HAVE_DEEPFIST) - target_sources(aethercore PRIVATE - src/models/DeepFistCwModel.cpp src/models/DeepFistCwModel.h - src/core/deepfist/DeepFistStream.cpp - third_party/deepfist/DeepFistConditioner.cpp - third_party/deepfist/DeepFistSpectrogram.cpp - third_party/deepfist/DeepFistCtc.cpp - third_party/deepfist/DeepFistModel.cpp) - target_include_directories(aethercore PRIVATE "${CMAKE_SOURCE_DIR}/third_party/deepfist") -endif() if(ORT_FOUND) target_compile_definitions(aethercore PUBLIC HAVE_ONNX) # Every CW receive backend beyond ggmorse needs ONNX Runtime, so this one # gate decides whether the CW panel offers a decoder selector (RFC #4817). - target_compile_definitions(aethercore PUBLIC HAVE_DEEPCW HAVE_CW_RX_BACKENDS) + target_compile_definitions(aethercore PUBLIC HAVE_DEEPFIST HAVE_DEEPCW HAVE_CW_RX_BACKENDS) target_sources(aethercore PRIVATE + src/models/DeepFistCwModel.cpp src/models/DeepFistCwModel.h + src/core/deepfist/DeepFistStream.cpp + third_party/deepfist/DeepFistConditioner.cpp + third_party/deepfist/DeepFistSpectrogram.cpp + third_party/deepfist/DeepFistCtc.cpp + third_party/deepfist/DeepFistModel.cpp src/core/DeepCwEngine.cpp src/core/DeepCwEngine.h src/core/DeepCwCommitter.cpp src/core/DeepCwCommitter.h src/models/DeepCwRxBackend.cpp src/models/DeepCwRxBackend.h src/core/deepfist/DeepFistModelAssets.cpp src/core/deepfist/DeepFistModelAssets.h) + target_include_directories(aethercore PRIVATE "${CMAKE_SOURCE_DIR}/third_party/deepfist") # PUBLIC include dir: core/SignalClassifier.h includes onnxruntime under # HAVE_ONNX (PUBLIC) and is pulled in by gui/MainWindow.h. Same transitive # leak as FFTW3 above. Library link stays PRIVATE (transitive via the lib). diff --git a/src/core/deepfist/DeepFistModelAssets.cpp b/src/core/deepfist/DeepFistModelAssets.cpp index 6e15da8780..3081207789 100644 --- a/src/core/deepfist/DeepFistModelAssets.cpp +++ b/src/core/deepfist/DeepFistModelAssets.cpp @@ -30,7 +30,10 @@ QVector DeepFistModelAssets::manifest() return { {"deepfist.onnx", 13051998, "6d2d4e3d66f9001d15e21a1b38b79150eae19ead86a310202900ee69d672b94d"}, {"deepfist.onnx.json", 1257, "840ceb8dba9d46d04495547a8a3789968b1acd2f8ac3a3a5c631f84008ac2217"}, - {"LICENSE", 1068, "9ad70a9ed30d58502e29f9e691a008ee7bccb6eba49d4384f2b7e675d68dc4f3"} + // The release publishes no LICENSE; these are the pinned bytes from the + // repository's first commit, fetched from that commit. + {"LICENSE", 1068, "9ad70a9ed30d58502e29f9e691a008ee7bccb6eba49d4384f2b7e675d68dc4f3", + "https://raw.githubusercontent.com/n9bc/DeepFist/061fc1d71f2b137b7b145b76cbc89c7e3d7a0d68/LICENSE"} }; } QString DeepFistModelAssets::releaseBaseUrl() @@ -128,19 +131,20 @@ void DeepFistModelAssets::next() } void DeepFistModelAssets::download() { - const QUrl base(m_baseUrl); - if (base.scheme() != "https" || base.host().isEmpty() || base.hasQuery() || base.hasFragment()) { + const Asset& asset = m_assets[m_index]; + const QString prefix = m_baseUrl.endsWith('/') ? m_baseUrl : m_baseUrl + '/'; + const QUrl source(asset.url.isEmpty() ? prefix + asset.name : asset.url); + if (source.scheme() != "https" || source.host().isEmpty() || source.hasQuery() || source.hasFragment() + || (asset.url.isEmpty() && m_baseUrl.isEmpty())) { fail(tr("Model unavailable: the download release has not been configured.")); return; } - const Asset& asset = m_assets[m_index]; m_received = 0; m_file = std::make_unique(QDir(m_directory).filePath(asset.name)); m_file->setDirectWriteFallback(false); if (!m_file->open(QIODevice::WriteOnly)) { fail(tr("Cannot write the model cache")); return; } m_hash = std::make_unique(QCryptographicHash::Sha256); - const QString prefix = m_baseUrl.endsWith('/') ? m_baseUrl : m_baseUrl + '/'; - QNetworkRequest request(QUrl(prefix + asset.name)); + QNetworkRequest request(source); request.setTransferTimeout(30000); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("AetherSDR-DeepFist")); diff --git a/src/core/deepfist/DeepFistModelAssets.h b/src/core/deepfist/DeepFistModelAssets.h index dedf1accc2..a8e44ef8c9 100644 --- a/src/core/deepfist/DeepFistModelAssets.h +++ b/src/core/deepfist/DeepFistModelAssets.h @@ -19,7 +19,8 @@ namespace AetherSDR { class DeepFistModelAssets final : public QObject { Q_OBJECT public: - struct Asset { QString name; qint64 bytes; QByteArray sha256; }; + // url, when set, is the asset's absolute HTTPS source instead of baseUrl + name. + struct Asset { QString name; qint64 bytes; QByteArray sha256; QString url = {}; }; static QVector manifest(); static QString releaseBaseUrl(); // Network injection and an explicit catalog make socket-free tests possible. diff --git a/tests/tests.cmake b/tests/tests.cmake index 3caa989876..25637b5403 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -9571,7 +9571,7 @@ add_test(NAME deepfist_model_assets_test COMMAND deepfist_model_assets_test) set_tests_properties(deepfist_model_assets_test PROPERTIES TIMEOUT 20) # Opt-in real backend: file/PCM tests only; no sockets or sound devices. -if(ENABLE_DEEPFIST_EXPERIMENT) +if(ORT_FOUND) add_executable(deepfist_cw_model_test tests/deepfist_cw_model_test.cpp) target_include_directories(deepfist_cw_model_test PRIVATE src third_party/deepfist) target_link_libraries(deepfist_cw_model_test PRIVATE aethercore Qt6::Core) From 98ce5f78895fafc51294886d5e69f0ac84e17261 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:14:20 -0700 Subject: [PATCH 04/11] test(cw): keep the DeepFist unavailable-status case offline. Principle VIII. With DEEPFIST_MODEL_BASE_URL now defaulting to the published release, the default-constructed model in deepfist_cw_model_test started a real download and timed out waiting for "unavailable". The case now passes an empty source, as the file's decode() helper already does, so it never reaches the network. Refs #4817 Co-Authored-By: Claude Opus 5.5 --- tests/deepfist_cw_model_test.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/deepfist_cw_model_test.cpp b/tests/deepfist_cw_model_test.cpp index 97543b9bc0..484366b4c2 100644 --- a/tests/deepfist_cw_model_test.cpp +++ b/tests/deepfist_cw_model_test.cpp @@ -102,7 +102,9 @@ bool contract(const QByteArray& validDirectory) QTemporaryDir directory; qputenv("AETHER_DEEPFIST_MODEL_DIR", directory.path().toUtf8()); - DeepFistCwModel model; + // No download source: a missing or corrupt bundle reports unavailable + // without the test reaching the network. + DeepFistCwModel model(directory.path(), QString{}, nullptr); QString text; QObject::connect(&model, &DeepFistCwModel::textDecoded, &model, [&](const QString& s) { text += s; }); model.start(); From e4f9c9d611dce666cf319aa030193f895479a66d Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:14:20 -0700 Subject: [PATCH 05/11] docs(cw): the three CW decoders in the manual, build options and guides; DeepCW modification notice. Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User manual (cw-decoder.md): choosing ggmorse, DeepFist or DeepCW, when text appears (measured on the demo radio at 2d7ce9ef, tone-off to last character, n=5 each: ggmorse 0.52 s, DeepFist 1.82 s, DeepCW 7.57 s median), first-use model downloads with Cancel/Retry, what is unavailable with a neural decoder, Intel Macs keep ggmorse only. - BUILD-OPTIONS.md and docs/deepfist-cw-backend.md: no experiment option; DeepFist's release URL and LICENSE source; both model-directory overrides. - THIRD_PARTY_LICENSES: the DeepFist helpers are no longer experimental. - DeepCwEngine.{h,cpp}: notice that the AGPL-3.0 port was modified (per-character emissions, inferLogProbs/greedyEmissions), AGPL-3.0 §5(a). Refs #4817 Co-Authored-By: Claude Opus 5.5 --- BUILD-OPTIONS.md | 17 ++++++++-------- THIRD_PARTY_LICENSES | 4 ++-- docs/deepfist-cw-backend.md | 36 +++++++++++++--------------------- docs/user/docs/cw-decoder.md | 31 +++++++++++++++++++++++++++-- src/core/DeepCwEngine.cpp | 2 ++ src/core/DeepCwEngine.h | 2 ++ src/models/DeepFistCwModel.cpp | 2 +- 7 files changed, 59 insertions(+), 35 deletions(-) diff --git a/BUILD-OPTIONS.md b/BUILD-OPTIONS.md index d68162dc5a..803ea48f01 100644 --- a/BUILD-OPTIONS.md +++ b/BUILD-OPTIONS.md @@ -58,15 +58,16 @@ alone. MSVC flags and `Release` builds are unaffected. | Option | Default | Purpose and prerequisites | |---|---|---| -| `ENABLE_DEEPFIST_EXPERIMENT` | OFF | Build the experimental DeepFist CW receive decoder. Requires ONNX Runtime at configure time (configuration fails without it) and a separate verified model bundle at runtime; see below. | | `ENABLE_RTL` | ON | Build the experimental receive-only RTL-SDR USB backend when both `librtlsdr` and single-precision FFTW (`fftw3f`) are found. Missing either disables the backend unless `REQUIRE_RTL=ON`. | | `AETHER_HL2_TX_TXA` | ON | Select WDSP's TXA chain as the Hermes-Lite 2 transmit modulator: SSB, AM, DSB and FM. OFF builds the in-tree phasing modulator, which is SSB only; AM, DSB and FM then stay receive-only. This choice has no runtime toggle. | -**DeepFist:** enabling `-DENABLE_DEEPFIST_EXPERIMENT=ON` alone does not supply its -model. The configured model-download URL defaults to empty. For developer -qualification, the runtime environment variable `AETHER_DEEPFIST_MODEL_DIR` -can point to the exact verified bundle; it is not a CMake switch. The required -assets and distribution prerequisite are described in +**Neural CW decoders:** DeepFist and DeepCW build whenever ONNX Runtime is +found at configure time (Apple Silicon macOS, Linux and Windows; the pinned +runtime has no Intel-macOS build), with no separate option. Neither model is +bundled: each downloads on first use from its upstream source at a pinned size +and SHA-256. For developer qualification, the runtime environment variables +`AETHER_DEEPFIST_MODEL_DIR` and `AETHER_DEEPCW_MODEL_DIR` point at a local +copy instead; they are not CMake switches. See [the DeepFist guide](docs/deepfist-cw-backend.md). ggmorse remains the default CW receive decoder. @@ -149,7 +150,7 @@ the ASR library (`aetherasr`) sees. | `HAVE_NVIDIA_AFX` | `ENABLE_NVIDIA_AFX` on x86-64 Linux or Windows. | | `HAVE_MQTT` | `ENABLE_MQTT`. | | `HAVE_MQTT_TLS` | `MQTT_TLS`, and for the bundled library OpenSSL is found. | -| `HAVE_DEEPFIST` | `ENABLE_DEEPFIST_EXPERIMENT`. | +| `HAVE_DEEPFIST`, `HAVE_DEEPCW`, `HAVE_CW_RX_BACKENDS` | ONNX Runtime is found. | | `HAVE_MIDI` | Always; RtMidi is bundled. | | `HAVE_SERIALPORT`, `HAVE_WEBSOCKETS`, `HAVE_KEYCHAIN` | The Qt SerialPort, Qt WebSockets or QtKeychain package is found. | | `HAVE_DBUS` | Qt DBus is found on Linux or another non-Apple Unix; it is never looked for on macOS or Windows. | @@ -178,7 +179,7 @@ Additional cache values accept a value rather than ON/OFF: | Setting | Default | Values and purpose | |---|---|---| | `AETHERSDR_SANITIZER` | `none` | `none`, `address`, `undefined`, `address,undefined`, or `thread`. Instruments the main CMake tree with a GNU-driver GCC/Clang build; MSVC and clang-cl are rejected, and so is combining `address` with `thread`. Adds `-g3 -fno-omit-frame-pointer` to every configuration, Release included. ExternalProject children need separate sanitizer flags. | -| `DEEPFIST_MODEL_BASE_URL` | Empty | Published, versioned HTTPS directory for the exact DeepFist assets. See the distribution prerequisite in [the DeepFist guide](docs/deepfist-cw-backend.md). | +| `DEEPFIST_MODEL_BASE_URL` | N9BC's `exp27_bt-champion` release | Versioned HTTPS directory holding the exact DeepFist model assets. The LICENSE asset carries its own pinned source. See [the DeepFist guide](docs/deepfist-cw-backend.md). | | `RADE_TAP_DIR` | `/rade_taps` | Directory for RADE WAV diagnostics; available when RADE and its taps are enabled. | | `AETHER_TEST_FFTW_TIMELIMIT` | `0.001` | Seconds FFTW may spend measuring each plan under test; an empty value allows unbounded measurement. | | `AETHER_SANITIZER_TIMEOUT_SCALE` | `4` | Positive integer. Multiplier applied to every test `TIMEOUT` when the build is sanitizer-instrumented (`AETHERSDR_SANITIZER` set, or `-fsanitize=` in the global C/C++ flags or in any configuration's `CMAKE__FLAGS_`). An uninstrumented build keeps every limit exactly as written. | diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 811d6e4ca8..d17e64ff2f 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -622,8 +622,8 @@ report PortAudio/portaudio#1176. Source: https://github.com/PortAudio/portaudio (tag v19.7.0, sha256 5af29ba58bbdbb7bbcefaaecc77ec8fc413f0db6f4c4e286c40c3e1b83174fa0) -DeepFist native helpers (experimental) --------------------------------------- +DeepFist native helpers +----------------------- MIT License Copyright (c) 2026 Brent Crier diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index 7359d2284f..c9e331c8f0 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -3,8 +3,7 @@ This implementation supplies a shared `CwRxModel` receive-backend interface and an optional DeepFist backend. ggmorse remains the default receive decoder and continues to decode transmit sidetone. Only the selected receive backend runs. -The RFC is #4817; DeepCW remains outside this change. The operator authorized -preparing this PR before DeepCW, overriding the earlier implementation order. +The RFC is #4817. DeepCW is the third backend, behind the same interface. DeepFist consumes selected-slice pre-monitor PCM through `PcmFrame`, resamples on its worker, and uses the pinned native Lyra frontend. Source changes, discontinuities, @@ -25,26 +24,19 @@ DeepFist output does not feed automatic callsign spotting. Other monitored slices and speaker gain/mute do not alter the selected decoder input. This is an audio slice tap, not an RF separation claim. -## Distribution prerequisite +## Distribution -**Not ready for general release:** no upstream standalone download directory -has been established for this exact model bundle. As checked during PR -preparation, n9bc/DeepFist publishes no release assets or committed weights; -Lyra's releases expose Windows installers, not the three standalone files. -The application must not download or execute an installer to obtain a model. - -`ENABLE_DEEPFIST_EXPERIMENT` is therefore default OFF and requires ONNX Runtime -when enabled. `DEEPFIST_MODEL_BASE_URL` remains empty. A missing model produces -an unavailable status, not a request to an invented endpoint. A developer can -point `AETHER_DEEPFIST_MODEL_DIR` at the exact verified bundle for qualification. -This override is not the intended end-user installation workflow. - -Before enabling the feature in released builds, the upstream author must -publish the exact assets at a versioned HTTPS directory and confirm model -redistribution provenance. Then configure that directory, exercise the real -public download, and test fresh-cache, cancellation, retry and offline reuse -on each supported platform. The RFC's upstream-only hosting decision remains -in effect; this change does not publish an AetherSDR mirror. +DeepFist builds whenever ONNX Runtime is found (Apple Silicon macOS, Linux and +Windows); there is no separate option. The model is never bundled with the +application. `DEEPFIST_MODEL_BASE_URL` defaults to N9BC's versioned +`exp27_bt-champion` release +(`https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/`), whose +`deepfist.onnx` and `deepfist.onnx.json` match the pinned lengths and hashes +below. That release publishes no LICENSE; the manifest's LICENSE asset carries +its own source, the same pinned bytes from the DeepFist repository's first +commit (`061fc1d7`). The RFC's upstream-only hosting decision remains in effect; +there is no AetherSDR mirror. A developer can point `AETHER_DEEPFIST_MODEL_DIR` +at a local verified bundle instead. The downloader verifies exact lengths and SHA-256 hashes, takes a cache lock, uses atomic file replacement, and checks the complete bundle before loading. @@ -72,7 +64,7 @@ is disabled. The committer and injected model-assets tests also run in the default build, without ONNX Runtime or weights. Optional worker tests use injected PCM and download replies. Real inference tests require the pinned local bundle and return skip code 77 when absent; a skipped test is not model proof. -Tests behind the default-OFF option do not run in the default CI graph. +Tests that need ONNX Runtime do not run in the default CI graph, which installs none. Synthetic replay has shown useful gains from normalization and emission protection. Those measurements are not an accuracy estimate for arbitrary diff --git a/docs/user/docs/cw-decoder.md b/docs/user/docs/cw-decoder.md index 9a38eece6c..526258f585 100644 --- a/docs/user/docs/cw-decoder.md +++ b/docs/user/docs/cw-decoder.md @@ -1,7 +1,7 @@ --- title: "CW Decoder" slug: "/cw-decoder" -description: "AetherSDR includes a built-in CW (Morse code) decoder powered by ggmorse (MIT license)." +description: "AetherSDR includes a built-in CW (Morse code) decoder: ggmorse by default, with two optional neural decoders, DeepFist and DeepCW." status: "Supported" applies_to: ["FlexRadio", "Hermes-Lite 2 (experimental)", "Networked Icom (early; IC-7300MK2 supported)"] --- @@ -14,6 +14,8 @@ applies_to: ["FlexRadio", "Hermes-Lite 2 (experimental)", "Networked Icom (early AetherSDR includes a built-in CW (Morse code) decoder powered by [ggmorse](https://github.com/ggerganov/ggmorse) (MIT license). It automatically detects the CW tone pitch and keying speed, and displays decoded text in real time in a panel below the waterfall. It can decode what you receive, what you send, or both. +On Apple Silicon Macs, Linux and Windows you can also choose one of two neural decoders, **DeepFist** or **DeepCW**, which can copy weak or irregular signals that ggmorse misses. See [Choosing a decoder](#choosing-a-decoder). + The decoder works on every radio family that has a CW mode: FlexRadio, the [Hermes-Lite 2](./hermes-lite-2.md), and [Networked Icom](./networked-icom.md) radios (it opens when an Icom slice is in CW). ## Requirements @@ -46,6 +48,29 @@ Drag the grip on the panel's edge to resize it; the height is remembered. Right- *The CW decoder pane under the waterfall, with its sensitivity, pitch and speed controls.* +### Choosing a decoder + +The decoder selector in the panel's toolbar picks which decoder reads the received CW. Only the selected decoder runs. Your own sending (the **TX** toggle) is always decoded by ggmorse. + +| Decoder | Text appears | Typical use | Model | +|---|---|---|---| +| **ggmorse** (default) | About half a second after each character | Clean, steady signals | None; built in | +| **DeepFist** | About 2 seconds behind the signal | Weak and hand-sent CW | Downloaded on first use, about 13 MB | +| **DeepCW** | About 6–8 seconds behind the signal, a few characters at a time; it holds text back so later audio can correct it | Weak signals | Downloaded on first use, about 15 MB | + +No decoder is best on every signal. Try each on the signals you work. + +The neural decoders' models are not part of the AetherSDR download. The first time you select one, AetherSDR downloads its model from the model's own published source and checks it before use; the status beside the selector shows the progress. **Cancel** stops a download, and **Retry** appears if it fails. Once downloaded, the model is kept and works offline. If a model cannot be downloaded, the decoder shows **Model unavailable** and you can switch back to ggmorse. + +With a neural decoder selected: + +- **Sens**, the lock buttons and the Pitch and WPM ranges are unavailable; they apply to ggmorse only. +- Characters are coloured by the model's own confidence, on the same green-to-red scale, and are never hidden. +- Callsign contact cards come from ggmorse only. +- **Zero Beat** is unavailable with DeepFist, which reports no pitch. + +The neural decoders are not available on Intel Macs; there the selector does not appear and ggmorse decodes as before. + ### How it works The decoder processes the received audio on a separate worker thread. It detects the dominant tone frequency, then uses a Goertzel filter and timing analysis to decode Morse characters. @@ -91,7 +116,8 @@ Decoded text is published to the MQTT topic `aethersdr/cw/decode`, one JSON mess | Control | What it does | |---|---| -| **Stats** | Detected pitch (Hz) and speed (WPM) | +| **Decoder** | ggmorse, DeepFist or DeepCW (Apple Silicon, Linux and Windows); see [Choosing a decoder](#choosing-a-decoder) | +| **Stats** | Detected pitch (Hz) and speed (WPM); with a neural decoder, its status | | **Sens** | 0–100 (default 30). Hides low-confidence characters: 0 shows everything, higher values show only confident decodes. | | **🔒P** | Lock the decoder's pitch at the current value | | **🔒S** | Lock the decoder's speed at the current WPM | @@ -116,6 +142,7 @@ Colors are based on ggmorse's cost function: ### Technical details - Library: [ggmorse](https://github.com/ggerganov/ggmorse) by Georgi Gerganov (MIT license) +- Neural decoders: DeepFist ([n9bc/DeepFist](https://github.com/n9bc/DeepFist)) and DeepCW ([e04/deepcw-engine](https://github.com/e04/deepcw-engine), AGPL-3.0); both run with ONNX Runtime, and their models are downloaded, not bundled - Bundled directly — no external dependency - Runs on a dedicated worker thread — does not block audio playback - CPU usage: negligible diff --git a/src/core/DeepCwEngine.cpp b/src/core/DeepCwEngine.cpp index 7bc5316a54..788dab69d6 100644 --- a/src/core/DeepCwEngine.cpp +++ b/src/core/DeepCwEngine.cpp @@ -1,5 +1,7 @@ // Provenance: ported from decode_morse.py in e04/deepcw-engine (AGPL-3.0). // See the header and THIRD_PARTY_LICENSES for the attribution. +// Modified for AetherSDR, 2026-09: per-character emissions with frame timing +// (inferLogProbs, greedyEmissions); decode() is unchanged. #include "DeepCwEngine.h" #include diff --git a/src/core/DeepCwEngine.h b/src/core/DeepCwEngine.h index 6dd99ce4bf..c08869f442 100644 --- a/src/core/DeepCwEngine.h +++ b/src/core/DeepCwEngine.h @@ -5,6 +5,8 @@ // licensed AGPL-3.0. AetherSDR is GPL-3.0; GPLv3 §13 permits the combination. // The ported code ships in the binary; the trained weights do not (download-on- // demand, SHA-256 pinned). See THIRD_PARTY_LICENSES for the AGPL-3.0 entry. +// Modified for AetherSDR, 2026-09: per-character emissions with frame timing +// (inferLogProbs, greedyEmissions); decode() is unchanged. #include #include diff --git a/src/models/DeepFistCwModel.cpp b/src/models/DeepFistCwModel.cpp index e194b8be76..3aee8d67ce 100644 --- a/src/models/DeepFistCwModel.cpp +++ b/src/models/DeepFistCwModel.cpp @@ -77,7 +77,7 @@ DeepFistCwModel::~DeepFistCwModel() } QString DeepFistCwModel::modelDirectory() { - // Development-only override in this opt-in prototype. No file picker or installer. + // Development override: a local verified bundle instead of the download. const QString overridePath = qEnvironmentVariable("AETHER_DEEPFIST_MODEL_DIR"); return overridePath.isEmpty() ? QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) From 447790f2fac42822589e18c94b8f86e1bf3d1d82 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:32:00 -0700 Subject: [PATCH 06/11] fix(cw): DeepCW stop clears its state, Zero Beat stays ggmorse-only, and older build dirs keep the DeepFist download. Principle VIII. - DeepCwRxBackend::stop() clears Retry, detail and status, as DeepFistCwModel::stop() does; a stopped backend no longer shows a Retry button that cannot act. - DeepCW reports no pitch, and Zero Beat is disabled for every neural decoder (CwDecodeSettings::neuralSelected()). Its window-wide peak bin is not a usable pitch estimate. A FlexRadio's own Autotune is unaffected. - The DeepCW commit hold is a fixed 5 s; the environment override is gone. - DEEPFIST_MODEL_BASE_URL is an override whose empty value selects the published release, so a build directory that cached the old empty value still downloads. - Manual: received MQTT text and contact cards come from ggmorse only. - Tests: deepcw_rx_backend_test (missing model -> unavailable -> Retry -> stop clears; cancel -> Retry -> stop clears) and a per-asset source case in deepfist_model_assets_test, both on injected HTTP replies. Mutation: with the stop() clearing removed, 2 failures (first "stop clears Retry, detail and status"); with the per-asset URL ignored, 2 failures ("asset with its own source is fetched from that source", "non-HTTPS asset source is refused"); both restored and passing. Refs #4817 Co-Authored-By: Claude Opus 5.5 --- BUILD-OPTIONS.md | 2 +- CMakeLists.txt | 11 +++-- docs/deepfist-cw-backend.md | 5 +-- docs/user/docs/cw-decoder.md | 6 +-- src/gui/MainWindow_Wiring.cpp | 4 +- src/gui/VfoWidget.cpp | 4 +- src/models/CwDecodeSettings.h | 2 +- src/models/DeepCwRxBackend.cpp | 15 +++---- src/models/DeepCwRxBackend.h | 2 - tests/DeepFistDownloadTransport.h | 2 + tests/deepcw_rx_backend_test.cpp | 61 ++++++++++++++++++++++++++++ tests/deepfist_model_assets_test.cpp | 27 ++++++++++++ tests/tests.cmake | 7 +++- 13 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 tests/deepcw_rx_backend_test.cpp diff --git a/BUILD-OPTIONS.md b/BUILD-OPTIONS.md index 803ea48f01..f5c59dc3f6 100644 --- a/BUILD-OPTIONS.md +++ b/BUILD-OPTIONS.md @@ -179,7 +179,7 @@ Additional cache values accept a value rather than ON/OFF: | Setting | Default | Values and purpose | |---|---|---| | `AETHERSDR_SANITIZER` | `none` | `none`, `address`, `undefined`, `address,undefined`, or `thread`. Instruments the main CMake tree with a GNU-driver GCC/Clang build; MSVC and clang-cl are rejected, and so is combining `address` with `thread`. Adds `-g3 -fno-omit-frame-pointer` to every configuration, Release included. ExternalProject children need separate sanitizer flags. | -| `DEEPFIST_MODEL_BASE_URL` | N9BC's `exp27_bt-champion` release | Versioned HTTPS directory holding the exact DeepFist model assets. The LICENSE asset carries its own pinned source. See [the DeepFist guide](docs/deepfist-cw-backend.md). | +| `DEEPFIST_MODEL_BASE_URL` | Empty (N9BC's `exp27_bt-champion` release) | Replaces the HTTPS directory holding the exact DeepFist model assets; empty uses the published release. The LICENSE asset carries its own pinned source. See [the DeepFist guide](docs/deepfist-cw-backend.md). | | `RADE_TAP_DIR` | `/rade_taps` | Directory for RADE WAV diagnostics; available when RADE and its taps are enabled. | | `AETHER_TEST_FFTW_TIMELIMIT` | `0.001` | Seconds FFTW may spend measuring each plan under test; an empty value allows unbounded measurement. | | `AETHER_SANITIZER_TIMEOUT_SCALE` | `4` | Positive integer. Multiplier applied to every test `TIMEOUT` when the build is sanitizer-instrumented (`AETHERSDR_SANITIZER` set, or `-fsanitize=` in the global C/C++ flags or in any configuration's `CMAKE__FLAGS_`). An uninstrumented build keeps every limit exactly as written. | diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d55476686..2ef2f71d50 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3177,9 +3177,14 @@ target_sources(aethercore PRIVATE src/models/CwRxModel.cpp src/models/CwRxModel. # (Apple Silicon, Linux, Windows; the pinned runtime has no Intel-macOS build). # Their models are never shipped: each downloads on first use from its upstream # release at a pinned size and SHA-256. -set(DEEPFIST_MODEL_BASE_URL "https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/" - CACHE STRING "Published, versioned HTTPS DeepFist asset directory") -target_compile_definitions(aethercore PRIVATE DEEPFIST_MODEL_BASE_URL="${DEEPFIST_MODEL_BASE_URL}") +# Empty selects the published release; a value replaces it. Not a cache default, +# so build directories configured before the release keep downloading. +set(DEEPFIST_MODEL_BASE_URL "" CACHE STRING "DeepFist asset directory override (empty = the published release)") +set(_deepfist_model_url "https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/") +if(DEEPFIST_MODEL_BASE_URL) + set(_deepfist_model_url "${DEEPFIST_MODEL_BASE_URL}") +endif() +target_compile_definitions(aethercore PRIVATE DEEPFIST_MODEL_BASE_URL="${_deepfist_model_url}") if(ORT_FOUND) target_compile_definitions(aethercore PUBLIC HAVE_ONNX) diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index c9e331c8f0..0c6101b65a 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -28,11 +28,10 @@ an audio slice tap, not an RF separation claim. DeepFist builds whenever ONNX Runtime is found (Apple Silicon macOS, Linux and Windows); there is no separate option. The model is never bundled with the -application. `DEEPFIST_MODEL_BASE_URL` defaults to N9BC's versioned -`exp27_bt-champion` release +application. It downloads from N9BC's versioned `exp27_bt-champion` release (`https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/`), whose `deepfist.onnx` and `deepfist.onnx.json` match the pinned lengths and hashes -below. That release publishes no LICENSE; the manifest's LICENSE asset carries +below; a non-empty `DEEPFIST_MODEL_BASE_URL` replaces that directory. That release publishes no LICENSE; the manifest's LICENSE asset carries its own source, the same pinned bytes from the DeepFist repository's first commit (`061fc1d7`). The RFC's upstream-only hosting decision remains in effect; there is no AetherSDR mirror. A developer can point `AETHER_DEEPFIST_MODEL_DIR` diff --git a/docs/user/docs/cw-decoder.md b/docs/user/docs/cw-decoder.md index 526258f585..7144ba8155 100644 --- a/docs/user/docs/cw-decoder.md +++ b/docs/user/docs/cw-decoder.md @@ -66,8 +66,8 @@ With a neural decoder selected: - **Sens**, the lock buttons and the Pitch and WPM ranges are unavailable; they apply to ggmorse only. - Characters are coloured by the model's own confidence, on the same green-to-red scale, and are never hidden. -- Callsign contact cards come from ggmorse only. -- **Zero Beat** is unavailable with DeepFist, which reports no pitch. +- Callsign contact cards and the MQTT text stream come from ggmorse only. +- AetherSDR's **Zero Beat** button is unavailable; it needs ggmorse's pitch estimate. A FlexRadio's own **Autotune** (on radios with SmartSDR+) works with any decoder. The neural decoders are not available on Intel Macs; there the selector does not appear and ggmorse decodes as before. @@ -98,7 +98,7 @@ Turn on **View → Show live CW contacts** to see the station the decoder copies ### MQTT -Decoded text is published to the MQTT topic `aethersdr/cw/decode`, one JSON message per character, with `"rx": false` marking text from the TX decoder. See [MQTT Station Automation](./mqtt-station-automation.md). +Decoded text is published to the MQTT topic `aethersdr/cw/decode`, one JSON message per character, with `"rx": false` marking text from the TX decoder. Received text is published only while ggmorse is the selected decoder. See [MQTT Station Automation](./mqtt-station-automation.md). ### Tips for best results diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index ba153805eb..b78da0997d 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -6215,8 +6215,8 @@ void MainWindow::wireVfoWidget(VfoWidget* w, SliceModel* s) m_radioModel.cwAutoTuneOnce(sliceId); }); connect(w, &VfoWidget::zeroBeatRequested, this, [this, sliceId]() { -#ifdef HAVE_DEEPFIST - if (CwDecodeSettings::deepFistSelected()) { return; } +#ifdef HAVE_CW_RX_BACKENDS + if (CwDecodeSettings::neuralSelected()) { return; } #endif // #2516: act on the slice that owns the clicked VfoWidget, NOT the // active slice — otherwise pressing Zero Beat on slice A while slice diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index 4a0cc4a371..db4106cb1c 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -6253,10 +6253,10 @@ void VfoWidget::rebuildFilterButtons() void VfoWidget::refreshCwDecoderControls() { if (!m_zeroBeatBtn) { return; } - const bool selected = CwDecodeSettings::deepFistSelected(); + const bool selected = CwDecodeSettings::neuralSelected(); m_zeroBeatBtn->setEnabled(!selected); const QString reason = selected - ? tr("DeepFist does not provide a pitch estimate for Zero Beat") : QString{}; + ? tr("Zero Beat needs ggmorse's pitch estimate; the neural CW decoders provide none") : QString{}; m_zeroBeatBtn->setToolTip(reason); m_zeroBeatBtn->setAccessibleDescription(reason); } diff --git a/src/models/CwDecodeSettings.h b/src/models/CwDecodeSettings.h index 9ee9f4ebf8..bc1ab02aa9 100644 --- a/src/models/CwDecodeSettings.h +++ b/src/models/CwDecodeSettings.h @@ -19,7 +19,7 @@ namespace AetherSDR { class CwDecodeSettings { public: static QString backend() { return readObj().value("backend").toString("ggmorse"); } - static bool deepFistSelected() { return backend() == "deepfist"; } + static bool neuralSelected() { return backend() != "ggmorse"; } static void setBackend(const QString& backend) { QJsonObject o = readObj(); diff --git a/src/models/DeepCwRxBackend.cpp b/src/models/DeepCwRxBackend.cpp index 566047e558..09ac7d6400 100644 --- a/src/models/DeepCwRxBackend.cpp +++ b/src/models/DeepCwRxBackend.cpp @@ -120,7 +120,10 @@ void DeepCwRxBackend::stop() m_assets->cancel(); m_preparing = false; } + m_canRetry = false; + m_detail.clear(); stopWorker(); + setStatus({}); } void DeepCwRxBackend::cancelPreparation() @@ -162,14 +165,12 @@ void DeepCwRxBackend::stopWorker() m_worker->wait(); delete m_worker; m_worker = nullptr; - m_pitch = 0.0f; // no estimate outlives the decoder (#5213) QMutexLocker lock(&m_ringMutex); m_ring.clear(); } void DeepCwRxBackend::reset() { - m_pitch = 0.0f; QMutexLocker lock(&m_ringMutex); m_ring.clear(); m_resetRequested = true; @@ -194,8 +195,7 @@ void DeepCwRxBackend::feedFixed24(const DecoderPcmBlock& block) // 24 kHz mono ring: resample to the model's 3200 Hz with an anti-aliased // r8brain SRC (a 7.5x decimation; a naive resample folds energy into the // 400-1200 Hz analysis band), then DeepCwCommitter: a sliding window re-decoded -// every 2 s whose characters are shown once they are holdSec behind the live edge. -// AETHER_DEEPCW_HOLD_S overrides the 5 s hold (local bench knob). +// every 2 s whose characters are shown once they are 5 s behind the live edge. void DeepCwRxBackend::decodeLoop(quint64 runId, const QString& modelPath) { if (!m_loaded) { @@ -211,10 +211,7 @@ void DeepCwRxBackend::decodeLoop(quint64 runId, const QString& modelPath) postStatus(runId, tr("DeepCW ready"), false); constexpr int kRate = DeepCwEngine::kModelSampleRate; - double holdSec = 5.0; - bool holdOk = false; - const double envHold = qEnvironmentVariable("AETHER_DEEPCW_HOLD_S").toDouble(&holdOk); - if (holdOk && envHold >= 1.0 && envHold <= 14.0) { holdSec = envHold; } + constexpr double holdSec = 5.0; // Worker-local: neither the resampler nor the committer is thread-safe. auto resampler = std::make_unique(24000.0, static_cast(kRate)); @@ -235,8 +232,6 @@ void DeepCwRxBackend::decodeLoop(quint64 runId, const QString& modelPath) const auto* r = reinterpret_cast(out.constData()); const auto m = static_cast(out.size() / static_cast(sizeof(float))); const DeepCwCommitter::Result res = committer->push(r, m, *m_engine); - // Dominant-tone pitch feeds Zero Beat; a CTC model has no speed estimate. - if (res.decoded && res.pitchHz > 0.0f) { m_pitch = res.pitchHz; } // One colour per committed chunk: 1 - mean CTC confidence (lower is better). if (!res.text.empty()) { emit coloredTextDecoded(QString::fromStdString(res.text), 1.0f - res.meanConf); diff --git a/src/models/DeepCwRxBackend.h b/src/models/DeepCwRxBackend.h index 705bc31aab..44126c7858 100644 --- a/src/models/DeepCwRxBackend.h +++ b/src/models/DeepCwRxBackend.h @@ -36,7 +36,6 @@ class DeepCwRxBackend final : public CwRxBackend { void reset() override; void feedFixed24(const DecoderPcmBlock& block) override; bool isRunning() const override { return m_started; } - float estimatedPitch() const override { return m_pitch.load(); } QString status() const override { return m_status; } QString detail() const override { return m_detail; } bool preparing() const override { return m_preparing; } @@ -68,7 +67,6 @@ class DeepCwRxBackend final : public CwRxBackend { std::atomic m_loaded{false}; std::atomic m_workerRun{false}; std::atomic m_resetRequested{false}; - std::atomic m_pitch{0.0f}; QThread* m_worker{nullptr}; // Mono float32 @24 kHz handoff ring, capped at kRingCapacity samples. diff --git a/tests/DeepFistDownloadTransport.h b/tests/DeepFistDownloadTransport.h index 90513150a6..58ff65e5b8 100644 --- a/tests/DeepFistDownloadTransport.h +++ b/tests/DeepFistDownloadTransport.h @@ -59,11 +59,13 @@ class DeepFistTestReply final : public QNetworkReply { class DeepFistTestNetwork final : public QNetworkAccessManager { public: QHash files; + QStringList urls; int requests = 0; protected: QNetworkReply* createRequest(Operation, const QNetworkRequest& request, QIODevice*) override { ++requests; + urls.append(request.url().toString()); const QString name = request.url().fileName(); return new DeepFistTestReply(request, files.value(name, {{}, 404, QNetworkReply::ContentNotFoundError}), this); } diff --git a/tests/deepcw_rx_backend_test.cpp b/tests/deepcw_rx_backend_test.cpp new file mode 100644 index 0000000000..4cee9e8862 --- /dev/null +++ b/tests/deepcw_rx_backend_test.cpp @@ -0,0 +1,61 @@ +#include "models/DeepCwRxBackend.h" +#include "DeepFistDownloadTransport.h" +#include +#include +#include +#include +#include +#include +#include + +using namespace AetherSDR; + +namespace { +int failures = 0; +void expect(bool good, const char* name) +{ + std::fprintf(stderr, "%s %s\n", good ? "PASS" : "FAIL", name); + if (!good) { ++failures; } +} +void pump(int ms) +{ + QEventLoop loop; + QTimer::singleShot(ms, &loop, &QEventLoop::quit); + loop.exec(); +} +bool wait(const std::function& done) +{ + QElapsedTimer time; time.start(); + while (!done() && time.elapsed() < 5000) { pump(5); } + return done(); +} +} + +// Model preparation lifecycle on injected HTTP replies: no sockets, weights or +// inference. Every request answers 404, so the model never becomes available. +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + QTemporaryDir dir; + DeepFistTestNetwork network; + DeepCwRxBackend backend(dir.path(), QStringLiteral("https://fixture.invalid/v1"), &network); + + backend.start(); + expect(wait([&] { return backend.status().contains("unavailable"); }), "missing model reports unavailable"); + expect(backend.canRetry() && !backend.preparing() && backend.isRunning(), "failed download offers Retry"); + const int before = network.requests; + backend.retry(); + expect(wait([&] { return network.requests > before && backend.canRetry(); }), "Retry asks the source again"); + backend.stop(); + expect(!backend.canRetry() && backend.status().isEmpty() && backend.detail().isEmpty() && !backend.isRunning(), + "stop clears Retry, detail and status"); + + backend.start(); + backend.cancelPreparation(); + expect(backend.status().contains("canceled") && backend.canRetry() && !backend.preparing(), + "cancel during preparation offers Retry"); + backend.stop(); + expect(!backend.canRetry() && backend.status().isEmpty(), "stop after cancel clears Retry"); + pump(50); + return failures ? 1 : 0; +} diff --git a/tests/deepfist_model_assets_test.cpp b/tests/deepfist_model_assets_test.cpp index 605ecaf7c3..d5a8f853f9 100644 --- a/tests/deepfist_model_assets_test.cpp +++ b/tests/deepfist_model_assets_test.cpp @@ -118,6 +118,32 @@ void lifecycle() QObject::connect(&unavailable, &DeepFistModelAssets::failed, &unavailable, [&](const QString&) { failed = true; }); unavailable.ensure(); expect(wait([&] { return failed; }), "unpublished source fails explicitly"); } +void perAssetSource() +{ + QTemporaryDir dir; + DeepFistTestNetwork network; + auto assets = catalog(network); + assets.last().url = QStringLiteral("https://pinned.invalid/commit/license"); + DeepFistModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); + bool ready = false, failed = false; + QObject::connect(&manager, &DeepFistModelAssets::ready, &manager, [&] { ready = true; }); + QObject::connect(&manager, &DeepFistModelAssets::failed, &manager, [&](const QString&) { failed = true; }); + manager.ensure(); + expect(wait([&] { return ready || failed; }) && ready && !failed, "bundle with a per-asset source completes"); + expect(network.urls.contains("https://pinned.invalid/commit/license") + && !network.urls.contains("https://fixture.invalid/v1/license"), + "asset with its own source is fetched from that source"); + + QTemporaryDir other; + DeepFistTestNetwork plain; + auto insecure = catalog(plain); + insecure.first().url = QStringLiteral("http://pinned.invalid/model"); + DeepFistModelAssets refused(other.path(), "https://fixture.invalid/v1", insecure, &plain); + bool refusedFailed = false; + QObject::connect(&refused, &DeepFistModelAssets::failed, &refused, [&](const QString&) { refusedFailed = true; }); + refused.ensure(); + expect(wait([&] { return refusedFailed; }) && plain.requests == 0, "non-HTTPS asset source is refused"); +} void progressLifetime(bool finishOnly, bool destroy) { QTemporaryDir dir; @@ -206,6 +232,7 @@ int main(int argc, char** argv) successAndCancel(); for (int i = 0; i < 5; ++i) { rejection(i); } lifecycle(); + perAssetSource(); oversizeIsRefusedMidStream(); for (bool finishOnly : {false, true}) { progressLifetime(finishOnly, true); diff --git a/tests/tests.cmake b/tests/tests.cmake index 25637b5403..209cd83bff 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -9570,7 +9570,7 @@ target_link_libraries(deepfist_model_assets_test PRIVATE Qt6::Core Qt6::Network add_test(NAME deepfist_model_assets_test COMMAND deepfist_model_assets_test) set_tests_properties(deepfist_model_assets_test PROPERTIES TIMEOUT 20) -# Opt-in real backend: file/PCM tests only; no sockets or sound devices. +# Real backends (built with ONNX Runtime): file/PCM tests only; no sockets or sound devices. if(ORT_FOUND) add_executable(deepfist_cw_model_test tests/deepfist_cw_model_test.cpp) target_include_directories(deepfist_cw_model_test PRIVATE src third_party/deepfist) @@ -9586,6 +9586,11 @@ if(ORT_FOUND) set_tests_properties(deepfist_cw_model_test PROPERTIES TIMEOUT 15) set_tests_properties(deepfist_cw_model_inference_test deepfist_cw_model_download_inference_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) + add_executable(deepcw_rx_backend_test tests/deepcw_rx_backend_test.cpp) + target_include_directories(deepcw_rx_backend_test PRIVATE src tests) + target_link_libraries(deepcw_rx_backend_test PRIVATE aethercore Qt6::Core Qt6::Network) + add_test(NAME deepcw_rx_backend_test COMMAND deepcw_rx_backend_test) + set_tests_properties(deepcw_rx_backend_test PROPERTIES TIMEOUT 20) endif() From 5eec4ca79618a2d0fa16e05b02e0023a97cbe7e8 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:17:06 -0700 Subject: [PATCH 07/11] fix(cw): DeepCW loads its model from a non-ASCII path on Windows; the manual no longer calls every decoder bundled. Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepCwEngine widened the UTF-8 model path byte by byte, so ONNX Runtime got a mangled name ("Modèle" arrived as "Modᅢᄄle") and "Model load failed" on every Retry. The path is now converted as UTF-8; non-Windows code is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/user/docs/cw-decoder.md | 2 +- src/core/DeepCwEngine.cpp | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/user/docs/cw-decoder.md b/docs/user/docs/cw-decoder.md index 7144ba8155..95717e593b 100644 --- a/docs/user/docs/cw-decoder.md +++ b/docs/user/docs/cw-decoder.md @@ -143,7 +143,7 @@ Colors are based on ggmorse's cost function: - Library: [ggmorse](https://github.com/ggerganov/ggmorse) by Georgi Gerganov (MIT license) - Neural decoders: DeepFist ([n9bc/DeepFist](https://github.com/n9bc/DeepFist)) and DeepCW ([e04/deepcw-engine](https://github.com/e04/deepcw-engine), AGPL-3.0); both run with ONNX Runtime, and their models are downloaded, not bundled -- Bundled directly — no external dependency +- ggmorse is built in — no external dependency - Runs on a dedicated worker thread — does not block audio playback - CPU usage: negligible diff --git a/src/core/DeepCwEngine.cpp b/src/core/DeepCwEngine.cpp index 788dab69d6..00265a995f 100644 --- a/src/core/DeepCwEngine.cpp +++ b/src/core/DeepCwEngine.cpp @@ -10,6 +10,9 @@ #include #include #include +#ifdef _WIN32 +#include +#endif namespace AetherSDR { @@ -76,7 +79,9 @@ bool DeepCwEngine::loadModel(const std::string& path) #ifdef HAVE_ONNX try { #ifdef _WIN32 - const std::wstring wpath(path.begin(), path.end()); + // path is UTF-8 (QString::toStdString); widen it as UTF-8, not byte by byte. + const std::wstring wpath = + std::filesystem::path(std::u8string(path.begin(), path.end())).wstring(); m_session = std::make_unique(m_env, wpath.c_str(), m_sessionOpts); #else m_session = std::make_unique(m_env, path.c_str(), m_sessionOpts); From d8d23e00565f045aab155add6be91f5664817288 Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:45:57 -0700 Subject: [PATCH 08/11] fix(cw): DeepCW checks the model's output tensor, and deepcw_replay bounds-checks WAV chunks, before reading them. Principle VII. Both inference paths read frames x 42 floats from the ONNX output without checking it; a [1,10000,1] output overran the tensor (AddressSanitizer). They now require float32 [1, frames > 0, 42] with a matching element count, else log one line and return empty. readWav() rejects a fmt chunk under 16 bytes, any chunk longer than the rest of the file, and short reads. Co-Authored-By: Claude Opus 5.5 --- src/core/DeepCwEngine.cpp | 41 ++++++++++++++++++++++++++++----------- src/core/DeepCwEngine.h | 3 ++- tools/deepcw_replay.cpp | 21 +++++++++++--------- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/core/DeepCwEngine.cpp b/src/core/DeepCwEngine.cpp index 00265a995f..4488ad2592 100644 --- a/src/core/DeepCwEngine.cpp +++ b/src/core/DeepCwEngine.cpp @@ -1,7 +1,8 @@ // Provenance: ported from decode_morse.py in e04/deepcw-engine (AGPL-3.0). // See the header and THIRD_PARTY_LICENSES for the attribution. // Modified for AetherSDR, 2026-09: per-character emissions with frame timing -// (inferLogProbs, greedyEmissions); decode() is unchanged. +// (inferLogProbs, greedyEmissions); 2026-10: both inference paths validate the +// output tensor before reading it. #include "DeepCwEngine.h" #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #ifdef _WIN32 #include @@ -58,6 +60,28 @@ static void fftRadix2(float* re, float* im, int n) } } +#ifdef HAVE_ONNX +// The model contract's output is float32 [1, frames > 0, kNumClasses], fully +// populated. Returns frames, or 0 after one stderr line for any other tensor, +// before a single element is read. +static int checkedOutputFrames(const Ort::Value& out) +{ + if (out.IsTensor()) { + const auto info = out.GetTensorTypeAndShapeInfo(); + const auto shape = info.GetShape(); + if (info.GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT + && shape.size() == 3 && shape[0] == 1 && shape[2] == DeepCwEngine::kNumClasses + && shape[1] > 0 && shape[1] <= std::numeric_limits::max() / DeepCwEngine::kNumClasses + && info.GetElementCount() == static_cast(shape[1]) * DeepCwEngine::kNumClasses) { + return static_cast(shape[1]); + } + } + std::fprintf(stderr, "DeepCwEngine: inference error: log_probs is not float32 [1, frames, %d]\n", + DeepCwEngine::kNumClasses); + return 0; +} +#endif + DeepCwEngine::DeepCwEngine() #ifdef HAVE_ONNX : m_env(ORT_LOGGING_LEVEL_WARNING, "AetherSDR-DeepCW") @@ -226,10 +250,9 @@ std::vector DeepCwEngine::inferLogProbs(const std::vector& audio32 const char* outputNames[] = {"log_probs"}; auto outputs = m_session->Run(Ort::RunOptions{nullptr}, inputNames, &input, 1, outputNames, 1); + const int outFrames = checkedOutputFrames(outputs[0]); + if (outFrames == 0) { return {}; } const float* lp = outputs[0].GetTensorData(); - const auto outShape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); - const int outFrames = outShape.size() >= 2 - ? static_cast(outShape[outShape.size() - 2]) : specFrames; *frames = outFrames; return std::vector(lp, lp + static_cast(outFrames) * kNumClasses); } catch (const Ort::Exception& ex) { @@ -328,13 +351,9 @@ std::string DeepCwEngine::decode(const std::vector& audio, int sampleRate inputNames, &input, 1, outputNames, 1); - const float* logProbs = outputs[0].GetTensorData(); - // Output shape [1, frames, kNumClasses]; frames may be re-derived from - // the tensor, but the model's time dim tracks our input frame count. - const auto info = outputs[0].GetTensorTypeAndShapeInfo(); - const auto outShape = info.GetShape(); - const int outFrames = outShape.size() >= 2 ? static_cast(outShape[outShape.size() - 2]) : frames; - return ctcDecode(logProbs, outFrames, avgConfidence); + const int outFrames = checkedOutputFrames(outputs[0]); + if (outFrames == 0) { return {}; } + return ctcDecode(outputs[0].GetTensorData(), outFrames, avgConfidence); } catch (const Ort::Exception& ex) { std::fprintf(stderr, "DeepCwEngine: inference error: %s\n", ex.what()); return {}; diff --git a/src/core/DeepCwEngine.h b/src/core/DeepCwEngine.h index c08869f442..2bc4a4a571 100644 --- a/src/core/DeepCwEngine.h +++ b/src/core/DeepCwEngine.h @@ -6,7 +6,8 @@ // The ported code ships in the binary; the trained weights do not (download-on- // demand, SHA-256 pinned). See THIRD_PARTY_LICENSES for the AGPL-3.0 entry. // Modified for AetherSDR, 2026-09: per-character emissions with frame timing -// (inferLogProbs, greedyEmissions); decode() is unchanged. +// (inferLogProbs, greedyEmissions); 2026-10: both inference paths validate the +// output tensor before reading it. #include #include diff --git a/tools/deepcw_replay.cpp b/tools/deepcw_replay.cpp index b59ee673b1..ef6c907f94 100644 --- a/tools/deepcw_replay.cpp +++ b/tools/deepcw_replay.cpp @@ -44,20 +44,23 @@ constexpr size_t kDrain = 4800; // 200 ms of 24 kHz, the worker's drain cadenc bool readWav(const char* path, std::vector& mono24k) { - std::ifstream f(path, std::ios::binary); + std::ifstream f(path, std::ios::binary | std::ios::ate); + const std::streamoff fileSize = f ? static_cast(f.tellg()) : -1; + if (fileSize < 12) return false; + f.seekg(0); char riff[12]; if (!f.read(riff, 12) || std::memcmp(riff, "RIFF", 4) || std::memcmp(riff + 8, "WAVE", 4)) return false; uint16_t channels = 0, bits = 0, fmt = 0; uint32_t rate = 0; - while (f) { - char id[4]; - uint32_t len = 0; - f.read(id, 4); - f.read(reinterpret_cast(&len), 4); - if (!f) break; + char id[4]; + uint32_t len = 0; + while (f.read(id, 4) && f.read(reinterpret_cast(&len), 4)) { + // A chunk can claim no more than the file still holds: fail closed. + if (static_cast(len) > fileSize - static_cast(f.tellg())) return false; if (!std::memcmp(id, "fmt ", 4)) { + if (len < 16) return false; std::vector b(len); - f.read(b.data(), len); + if (!f.read(b.data(), len)) return false; std::memcpy(&fmt, b.data(), 2); std::memcpy(&channels, b.data() + 2, 2); std::memcpy(&rate, b.data() + 4, 4); @@ -68,7 +71,7 @@ bool readWav(const char* path, std::vector& mono24k) return false; } std::vector s(len / 2); - f.read(reinterpret_cast(s.data()), static_cast(s.size() * 2)); + if (!f.read(reinterpret_cast(s.data()), static_cast(s.size() * 2))) return false; const size_t n = s.size() / channels; mono24k.resize(n); for (size_t i = 0; i < n; ++i) { // same downmix as CwDecoder::feedAudio From c27d2e9c73342118640e77e4cb53e0f53c15ec6d Mon Sep 17 00:00:00 2001 From: Jeff Skerker <7691216+skerker@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:47:06 -0700 Subject: [PATCH 09/11] test(cw): DeepCW's greedy CTC decode is pinned by a model-free test. Principle VIII. deepcw_ctc_test drives ctcDecode() and greedyEmissions() with constructed log-prob rows: repeats collapse, a blank separates a repeated letter, both paths emit the same letters with the same frames and confidence, a constant row offset changes nothing, and zero frames decode to nothing. No ONNX Runtime, weights or sockets, so it compiles in every build. Co-Authored-By: Claude Opus 5.5 --- tests/deepcw_ctc_test.cpp | 64 +++++++++++++++++++++++++++++++++++++++ tests/tests.cmake | 4 +++ 2 files changed, 68 insertions(+) create mode 100644 tests/deepcw_ctc_test.cpp diff --git a/tests/deepcw_ctc_test.cpp b/tests/deepcw_ctc_test.cpp new file mode 100644 index 0000000000..6c1ecaf142 --- /dev/null +++ b/tests/deepcw_ctc_test.cpp @@ -0,0 +1,64 @@ +// DeepCW's greedy CTC decode, model-free: no ONNX Runtime, weights or sockets. +// Every log-prob row below is CONSTRUCTED; it exercises the decode rule only. +#include "core/DeepCwEngine.h" + +#include +#include +#include +#include + +using AetherSDR::DeepCwEngine; + +namespace { + +constexpr int kC = DeepCwEngine::kNumClasses; +constexpr int kBlank = DeepCwEngine::kBlankIndex; +constexpr int kA = 14, kB = 15, kSpace = 40; // model.onnx.json "chars" order + +// One row per frame: the named class gets `top`, every other class `top - 10`. +std::vector rows(const std::vector& argmax, float top = 0.0f) +{ + std::vector lp(argmax.size() * kC, top - 10.0f); + for (size_t t = 0; t < argmax.size(); ++t) { lp[t * kC + argmax[t]] = top; } + return lp; +} + +bool near(double a, double b) { return std::fabs(a - b) < 1e-5; } + +} // namespace + +int main() +{ + const DeepCwEngine eng; + const double expectConf = 1.0 / (1.0 + (kC - 1) * std::exp(-10.0)); + + // Repeats collapse, a blank separates a repeated letter, space is a letter. + const std::vector seq{kA, kA, kBlank, kA, kB, kB, kBlank, kSpace, kBlank}; + const std::vector lp = rows(seq); + const int n = static_cast(seq.size()); + float avg = 0.0f; + const std::string text = eng.ctcDecode(lp.data(), n, &avg); + if (text != "AAB ") { std::fprintf(stderr, "ctcDecode: got '%s'\n", text.c_str()); return 1; } + if (!near(avg, expectConf)) { std::fprintf(stderr, "ctcDecode confidence %f\n", avg); return 2; } + + // The committer's path emits the same letters, at the frame each appeared. + std::vector blank; + const auto em = eng.greedyEmissions(lp.data(), n, &blank); + const std::vector expectFrame{0, 3, 4, 7}; + if (em.size() != expectFrame.size()) { return 3; } + for (size_t i = 0; i < em.size(); ++i) { + if (em[i].ch != text[i] || em[i].frame != expectFrame[i] || !near(em[i].conf, expectConf)) { return 4; } + } + if (blank != std::vector{0, 0, 1, 0, 0, 0, 1, 0, 1}) { return 5; } + + // Logits or log-probabilities: a constant offset per row changes nothing. + const std::vector shifted = rows(seq, 7.5f); + float avgShifted = 0.0f; + if (eng.ctcDecode(shifted.data(), n, &avgShifted) != text || !near(avgShifted, avg)) { return 6; } + + // No frames: no text, and confidence reads 1.0 (nothing to doubt). + float avgEmpty = 0.0f; + if (!eng.ctcDecode(nullptr, 0, &avgEmpty).empty() || avgEmpty != 1.0f) { return 7; } + if (!eng.greedyEmissions(nullptr, 0, &blank).empty() || !blank.empty()) { return 8; } + return 0; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 209cd83bff..dbf3272e5e 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -9561,6 +9561,10 @@ target_include_directories(deepfist_committer_test PRIVATE src) target_link_libraries(deepfist_committer_test PRIVATE Qt6::Core) add_test(NAME deepfist_committer_test COMMAND deepfist_committer_test) set_tests_properties(deepfist_committer_test PROPERTIES TIMEOUT 20) +add_executable(deepcw_ctc_test tests/deepcw_ctc_test.cpp src/core/DeepCwEngine.cpp) +target_include_directories(deepcw_ctc_test PRIVATE src) +add_test(NAME deepcw_ctc_test COMMAND deepcw_ctc_test) +set_tests_properties(deepcw_ctc_test PROPERTIES TIMEOUT 20) add_executable(deepfist_model_assets_test tests/deepfist_model_assets_test.cpp src/core/deepfist/DeepFistModelAssets.cpp src/core/deepfist/DeepFistModelAssets.h src/core/NetworkDiagnostics.cpp) From 8275f36dbd8cacf4049f0b1e57f3cfd194ec3cb2 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 17 Oct 2026 09:21:30 -0700 Subject: [PATCH 10/11] fix(cw): bound trimming and preserve fallback controls. Principle VII. --- CMakeLists.txt | 1 - THIRD_PARTY_LICENSES | 3 +-- docs/deepfist-cw-backend.md | 3 ++- docs/user/docs/cw-decoder.md | 4 +++- src/core/DeepCwCommitter.cpp | 2 +- src/core/DeepCwCommitter.h | 16 ++++------------ src/core/DeepCwEngine.cpp | 2 +- src/core/DeepCwEngine.h | 4 ++-- src/core/deepfist/DeepFistModelAssets.cpp | 2 +- src/gui/VfoWidget.cpp | 2 +- src/models/CwDecodeSettings.h | 7 ++++++- src/models/DeepCwRxBackend.cpp | 10 +++++----- tests/cw_rx_model_test.cpp | 13 +++++++++++++ tests/deepcw_ctc_test.cpp | 6 ++++++ tests/tests.cmake | 3 ++- tools/deepcw_replay.cpp | 7 ++++++- 16 files changed, 54 insertions(+), 31 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2ef2f71d50..a522ba46c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3550,7 +3550,6 @@ endforeach() # why that matters and why it should not be "tidied up" into a subdirectory. enable_testing() # DeepCW offline replay harness (RFC #4817 regression take): not built by default. -# Declared above the tests include: executables declared after it fail configure. if(ORT_FOUND) add_executable(deepcw_replay EXCLUDE_FROM_ALL tools/deepcw_replay.cpp src/core/DeepCwEngine.cpp src/core/DeepCwCommitter.cpp diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index d17e64ff2f..51857e6029 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -1,6 +1,5 @@ AetherSDR Third-Party Software Licenses -================================= - +======================================== AetherSDR incorporates or links against the following third-party software. Each component retains its original license. Full license texts are included diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index 0c6101b65a..c6f10fac8f 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -31,7 +31,8 @@ Windows); there is no separate option. The model is never bundled with the application. It downloads from N9BC's versioned `exp27_bt-champion` release (`https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/`), whose `deepfist.onnx` and `deepfist.onnx.json` match the pinned lengths and hashes -below; a non-empty `DEEPFIST_MODEL_BASE_URL` replaces that directory. That release publishes no LICENSE; the manifest's LICENSE asset carries +below; a non-empty `DEEPFIST_MODEL_BASE_URL` replaces that directory. +That release publishes no LICENSE; the manifest's LICENSE asset carries its own source, the same pinned bytes from the DeepFist repository's first commit (`061fc1d7`). The RFC's upstream-only hosting decision remains in effect; there is no AetherSDR mirror. A developer can point `AETHER_DEEPFIST_MODEL_DIR` diff --git a/docs/user/docs/cw-decoder.md b/docs/user/docs/cw-decoder.md index 95717e593b..c003c33d67 100644 --- a/docs/user/docs/cw-decoder.md +++ b/docs/user/docs/cw-decoder.md @@ -58,7 +58,7 @@ The decoder selector in the panel's toolbar picks which decoder reads the receiv | **DeepFist** | About 2 seconds behind the signal | Weak and hand-sent CW | Downloaded on first use, about 13 MB | | **DeepCW** | About 6–8 seconds behind the signal, a few characters at a time; it holds text back so later audio can correct it | Weak signals | Downloaded on first use, about 15 MB | -No decoder is best on every signal. Try each on the signals you work. +No decoder is best on every signal. Try each on the signals you work. DeepCW analyzes tones from **400 to 1200 Hz**; keep the received CW tone in that range. The neural decoders' models are not part of the AetherSDR download. The first time you select one, AetherSDR downloads its model from the model's own published source and checks it before use; the status beside the selector shows the progress. **Cancel** stops a download, and **Retry** appears if it fails. Once downloaded, the model is kept and works offline. If a model cannot be downloaded, the decoder shows **Model unavailable** and you can switch back to ggmorse. @@ -69,6 +69,8 @@ With a neural decoder selected: - Callsign contact cards and the MQTT text stream come from ggmorse only. - AetherSDR's **Zero Beat** button is unavailable; it needs ggmorse's pitch estimate. A FlexRadio's own **Autotune** (on radios with SmartSDR+) works with any decoder. +DeepCW discards its provisional text (about 5–7 seconds) when the source changes, audio is interrupted, or decoding stops. Its worker finishes the current inference or model load before stopping, so switching decoders can pause the interface until that work finishes. + The neural decoders are not available on Intel Macs; there the selector does not appear and ggmorse decodes as before. ### How it works diff --git a/src/core/DeepCwCommitter.cpp b/src/core/DeepCwCommitter.cpp index bb8ea18a0d..7facb21b96 100644 --- a/src/core/DeepCwCommitter.cpp +++ b/src/core/DeepCwCommitter.cpp @@ -17,7 +17,7 @@ DeepCwCommitter::DeepCwCommitter(double holdSec, double leftContextSec, double h // Stay inside the model's trained 5-20 s window range. , m_window(std::min(DeepCwEngine::kMaxWindowSec, std::max(15.0, holdSec + leftContextSec + hopSec))) - , m_keep(std::min(m_window - hopSec, holdSec + leftContextSec)) + , m_keep(std::max(0.0, std::min(m_window - hopSec, holdSec + leftContextSec))) { } diff --git a/src/core/DeepCwCommitter.h b/src/core/DeepCwCommitter.h index 3f82318e98..f24264393f 100644 --- a/src/core/DeepCwCommitter.h +++ b/src/core/DeepCwCommitter.h @@ -1,17 +1,9 @@ #pragma once -// DeepCW streaming front: sliding analysis window + time-anchored commit. -// -// The model is a whole-window CTC decoder, and its reading of a character -// keeps improving while more audio arrives after it. So each hop the whole -// window is re-decoded, but a character is committed (shown) only once it was -// emitted more than holdSec behind the live edge; everything newer stays -// provisional and is re-read on the next hop. The commit point is snapped back -// into a run of blank frames so no character straddles it. When the window -// reaches its maximum, the oldest audio is dropped but holdSec + leftContextSec -// is kept, so there is no hard reset and no character or word is cut at a seam. -// -// Qt-free (std + DeepCwEngine) so the offline replay tool runs exactly this code. +// Sliding-window CTC decode with a time-anchored commit point. Text stays +// provisional until holdSec behind the live edge, then commits at a blank run. +// Trimming keeps holdSec + leftContextSec so letters can span window edges. +// Qt-free so the offline replay tool uses the same streaming path. #include "DeepCwEngine.h" diff --git a/src/core/DeepCwEngine.cpp b/src/core/DeepCwEngine.cpp index 4488ad2592..00c69bead3 100644 --- a/src/core/DeepCwEngine.cpp +++ b/src/core/DeepCwEngine.cpp @@ -306,7 +306,7 @@ std::string DeepCwEngine::decode(const std::vector& audio, int sampleRate if (!m_loaded || !m_session) { return {}; } // The engine does not resample: the caller must deliver model-rate audio - // through an anti-aliased SRC (CwDecoder uses r8brain). A wrong rate here + // through an anti-aliased SRC (DeepCwRxBackend uses r8brain). A wrong rate here // would silently alias into the 400-1200 Hz band, so refuse it outright. if (sampleRateHz != kModelSampleRate) { static bool warned = false; diff --git a/src/core/DeepCwEngine.h b/src/core/DeepCwEngine.h index 2bc4a4a571..414336491e 100644 --- a/src/core/DeepCwEngine.h +++ b/src/core/DeepCwEngine.h @@ -25,12 +25,12 @@ namespace AetherSDR { // log-magnitude spectrogram. Given a mono audio window already at the model's // 3200 Hz rate, the engine builds the exact spectrogram the model was trained // on, runs one ONNX Runtime inference, and greedy-CTC-decodes the log-probs to -// text. Resampling to 3200 Hz is the caller's job (CwDecoder does it with an +// text. Resampling to 3200 Hz is the caller's job (DeepCwRxBackend does it with an // anti-aliased r8brain SRC) — a non-integer decimation without a filter folds // energy straight into the 400-1200 Hz analysis band. // // Deliberately Qt-free (pure std + ORT, self-contained radix-2 FFT) so it can be -// unit-tested and prototyped standalone; the CwDecoder wrapper adapts +// unit-tested and prototyped standalone; the DeepCwRxBackend wrapper adapts // std::string -> QString. ORT calls compile out without HAVE_ONNX (decode() // then returns empty). // diff --git a/src/core/deepfist/DeepFistModelAssets.cpp b/src/core/deepfist/DeepFistModelAssets.cpp index 3081207789..2486df50de 100644 --- a/src/core/deepfist/DeepFistModelAssets.cpp +++ b/src/core/deepfist/DeepFistModelAssets.cpp @@ -38,7 +38,7 @@ QVector DeepFistModelAssets::manifest() } QString DeepFistModelAssets::releaseBaseUrl() { - // Empty until a reviewed, versioned release asset set has been published. + // The build selects the published upstream release or an explicit override. return QString::fromUtf8(DEEPFIST_MODEL_BASE_URL); } DeepFistModelAssets::DeepFistModelAssets(QString directory, QString baseUrl, diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index db4106cb1c..d4c71953b0 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -6256,7 +6256,7 @@ void VfoWidget::refreshCwDecoderControls() const bool selected = CwDecodeSettings::neuralSelected(); m_zeroBeatBtn->setEnabled(!selected); const QString reason = selected - ? tr("Zero Beat needs ggmorse's pitch estimate; the neural CW decoders provide none") : QString{}; + ? tr("Zero Beat uses ggmorse's pitch estimate; neural decoder pitch is not used") : QString{}; m_zeroBeatBtn->setToolTip(reason); m_zeroBeatBtn->setAccessibleDescription(reason); } diff --git a/src/models/CwDecodeSettings.h b/src/models/CwDecodeSettings.h index bc1ab02aa9..85d3709eff 100644 --- a/src/models/CwDecodeSettings.h +++ b/src/models/CwDecodeSettings.h @@ -1,6 +1,7 @@ #pragma once #include "core/AppSettings.h" +#include "CwRxModel.h" #include #include @@ -19,7 +20,11 @@ namespace AetherSDR { class CwDecodeSettings { public: static QString backend() { return readObj().value("backend").toString("ggmorse"); } - static bool neuralSelected() { return backend() != "ggmorse"; } + static bool neuralSelected() + { + const QString key = backend(); + return key != QLatin1String("ggmorse") && CwRxModel::availableBackends().contains(key); + } static void setBackend(const QString& backend) { QJsonObject o = readObj(); diff --git a/src/models/DeepCwRxBackend.cpp b/src/models/DeepCwRxBackend.cpp index 09ac7d6400..a662698a6e 100644 --- a/src/models/DeepCwRxBackend.cpp +++ b/src/models/DeepCwRxBackend.cpp @@ -188,14 +188,14 @@ void DeepCwRxBackend::feedFixed24(const DecoderPcmBlock& block) if (m_ring.size() > kRingCapacity) { m_ring.erase(m_ring.begin(), m_ring.begin() + static_cast(m_ring.size() - kRingCapacity)); + m_resetRequested = true; } } -// K5PTB's DeepCW worker loop (prototype CwDecoder::decodeLoopDeep), fed by the -// 24 kHz mono ring: resample to the model's 3200 Hz with an anti-aliased -// r8brain SRC (a 7.5x decimation; a naive resample folds energy into the -// 400-1200 Hz analysis band), then DeepCwCommitter: a sliding window re-decoded -// every 2 s whose characters are shown once they are 5 s behind the live edge. +// Resample the 24 kHz ring with anti-aliased r8brain SRC before inference. +// The worker owns the sliding-window committer; discontinuities discard its +// provisional text so characters cannot cross source changes. +// AETHER_DEEPCW_HOLD_S overrides the 5 s hold for local measurements. void DeepCwRxBackend::decodeLoop(quint64 runId, const QString& modelPath) { if (!m_loaded) { diff --git a/tests/cw_rx_model_test.cpp b/tests/cw_rx_model_test.cpp index e892cf9d3f..749708a22c 100644 --- a/tests/cw_rx_model_test.cpp +++ b/tests/cw_rx_model_test.cpp @@ -1,4 +1,6 @@ #include "models/CwRxModel.h" +#include "models/CwDecodeSettings.h" +#include "TestSettingsProfile.h" #include #include #include @@ -122,7 +124,18 @@ bool retainLockedEstimates() int main(int argc, char** argv) { + TestSettingsProfile settings(QStringLiteral("cw-rx-model")); QCoreApplication app(argc, argv); + AppSettings::instance().load(); + CwDecodeSettings::setBackend(QStringLiteral("unknown")); + if (!require(!CwDecodeSettings::neuralSelected(), + "Unknown saved backend disables ggmorse controls")) { return 1; } + for (const QString& key : CwRxModel::availableBackends()) { + CwDecodeSettings::setBackend(key); + if (!require(CwDecodeSettings::neuralSelected() == (key != QLatin1String("ggmorse")), + "Saved backend gating disagrees with the decoder catalog")) { return 1; } + } + CwDecodeSettings::setBackend(QStringLiteral("ggmorse")); QTemporaryDir cache; qputenv("AETHER_DEEPFIST_MODEL_DIR", cache.path().toUtf8()); CwRxModel decoder; diff --git a/tests/deepcw_ctc_test.cpp b/tests/deepcw_ctc_test.cpp index 6c1ecaf142..3ae87e2b7e 100644 --- a/tests/deepcw_ctc_test.cpp +++ b/tests/deepcw_ctc_test.cpp @@ -1,6 +1,7 @@ // DeepCW's greedy CTC decode, model-free: no ONNX Runtime, weights or sockets. // Every log-prob row below is CONSTRUCTED; it exercises the decode rule only. #include "core/DeepCwEngine.h" +#include "core/DeepCwCommitter.h" #include #include @@ -60,5 +61,10 @@ int main() float avgEmpty = 0.0f; if (!eng.ctcDecode(nullptr, 0, &avgEmpty).empty() || avgEmpty != 1.0f) { return 7; } if (!eng.greedyEmissions(nullptr, 0, &blank).empty() || !blank.empty()) { return 8; } + // A hop longer than the analysis window must not underflow its trim size. + AetherSDR::DeepCwCommitter longHop(5.0, 3.0, 100.0); + const std::vector audio(DeepCwEngine::kModelSampleRate * 20, 0.0f); + longHop.push(audio.data(), audio.size(), eng); + longHop.push(audio.data(), audio.size(), eng); return 0; } diff --git a/tests/tests.cmake b/tests/tests.cmake index dbf3272e5e..ec3b7ac1de 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -8782,6 +8782,7 @@ if(NOT WIN32) endif() set(AETHER_SETTINGS_CONSUMERS + cw_rx_model_test dax_receive_applet_test hl2_discovery_logging_test reroute_dead_controls_test @@ -9561,7 +9562,7 @@ target_include_directories(deepfist_committer_test PRIVATE src) target_link_libraries(deepfist_committer_test PRIVATE Qt6::Core) add_test(NAME deepfist_committer_test COMMAND deepfist_committer_test) set_tests_properties(deepfist_committer_test PROPERTIES TIMEOUT 20) -add_executable(deepcw_ctc_test tests/deepcw_ctc_test.cpp src/core/DeepCwEngine.cpp) +add_executable(deepcw_ctc_test tests/deepcw_ctc_test.cpp src/core/DeepCwEngine.cpp src/core/DeepCwCommitter.cpp) target_include_directories(deepcw_ctc_test PRIVATE src) add_test(NAME deepcw_ctc_test COMMAND deepcw_ctc_test) set_tests_properties(deepcw_ctc_test PROPERTIES TIMEOUT 20) diff --git a/tools/deepcw_replay.cpp b/tools/deepcw_replay.cpp index ef6c907f94..19417e9ce9 100644 --- a/tools/deepcw_replay.cpp +++ b/tools/deepcw_replay.cpp @@ -11,7 +11,7 @@ // panel showed it via QTextEdit::insertHtml // deepcw_replay commit [holdSec] // -commit-H.txt the shipped path: DeepCwCommitter fed -// in 200 ms drains, exactly as decodeLoopDeep() +// in 200 ms drains, as in DeepCwRxBackend::decodeLoop() // // Input: PCM16 WAV at 24 kHz, mono or stereo (the QSO recorder's format). @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -129,6 +130,10 @@ int main(int argc, char** argv) if (argc > 4 && std::string(argv[4]) == "commit") { const double hold = argc > 5 ? std::atof(argv[5]) : 5.0; + if (!std::isfinite(hold) || hold < 1.0 || hold > 14.0) { + std::fprintf(stderr, "holdSec must be finite and between 1 and 14 seconds\n"); + return 2; + } DeepCwCommitter c(hold); std::string text; for (const auto& d : drains) text += c.push(d.data(), d.size(), eng).text; From d22d21080a7e8b9c68d9052b71c2c04a53bc6f97 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 17 Oct 2026 09:55:53 -0700 Subject: [PATCH 11/11] fix(cw): pin the release license and share model assets. Principle VIII. --- CMakeLists.txt | 2 +- THIRD_PARTY_LICENSES | 20 +++++++ docs/deepfist-cw-backend.md | 39 ++++++++---- docs/user/docs/cw-decoder.md | 2 +- ...pFistModelAssets.cpp => CwModelAssets.cpp} | 48 +++++++-------- .../DeepFistModelAssets.h => CwModelAssets.h} | 6 +- src/models/DeepCwRxBackend.cpp | 14 ++--- src/models/DeepCwRxBackend.h | 4 +- src/models/DeepFistCwModel.cpp | 20 +++---- src/models/DeepFistCwModel.h | 4 +- tests/deepfist_model_assets_test.cpp | 60 +++++++++---------- tests/tests.cmake | 2 +- 12 files changed, 129 insertions(+), 92 deletions(-) rename src/core/{deepfist/DeepFistModelAssets.cpp => CwModelAssets.cpp} (82%) rename src/core/{deepfist/DeepFistModelAssets.h => CwModelAssets.h} (90%) diff --git a/CMakeLists.txt b/CMakeLists.txt index a522ba46c0..b3196f5e3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3201,7 +3201,7 @@ if(ORT_FOUND) src/core/DeepCwEngine.cpp src/core/DeepCwEngine.h src/core/DeepCwCommitter.cpp src/core/DeepCwCommitter.h src/models/DeepCwRxBackend.cpp src/models/DeepCwRxBackend.h - src/core/deepfist/DeepFistModelAssets.cpp src/core/deepfist/DeepFistModelAssets.h) + src/core/CwModelAssets.cpp src/core/CwModelAssets.h) target_include_directories(aethercore PRIVATE "${CMAKE_SOURCE_DIR}/third_party/deepfist") # PUBLIC include dir: core/SignalClassifier.h includes onnxruntime under # HAVE_ONNX (PUBLIC) and is pulled in by gui/MainWindow.h. Same transitive diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 51857e6029..3d36918b31 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -790,3 +790,23 @@ download-only ASR entries above, this has TWO distinct AGPL-3.0 obligations: Attribution: "deepcw-engine" by e04. License: AGPL-3.0-or-later (both the ported code and the model) Source: https://github.com/e04/deepcw-engine + + +33. DeepFist — CW model weights (download-on-demand) +------------------------------------------------------------------------------- +The exp27_bt-champion model (deepfist.onnx and deepfist.onnx.json) is downloaded +on first selection, never bundled or mirrored by AetherSDR. Size and SHA-256 +are pinned in src/core/CwModelAssets.cpp. The accompanying LICENSE is fetched +from the release tag's resolved source revision, not a historical MIT revision. +The native helpers' separate historical MIT notice above remains in effect. + + Copyright: Copyright (C) 2026 Brent Crier (N9BC) + License understanding: GPL-3.0-or-later, following the published release's + LICENSE and README; accepted for this pinned download by the + operator on 2026-10-17. This is not a separate upstream ruling on + the older checkpoint. See docs/deepfist-cw-backend.md. + Release: https://github.com/n9bc/DeepFist/releases/tag/exp27_bt-champion + Source: https://github.com/n9bc/DeepFist/tree/930bd3cea7d9bf19fd45413acc9064b54c722871 + License: https://raw.githubusercontent.com/n9bc/DeepFist/930bd3cea7d9bf19fd45413acc9064b54c722871/LICENSE + LICENSE: 35927 bytes; SHA-256 + d8dcabd2c59c9e96be89f094a94474685e3fcd5ee8c4304ae7fbbf6c722df6ab diff --git a/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index c6f10fac8f..a36927ac90 100644 --- a/docs/deepfist-cw-backend.md +++ b/docs/deepfist-cw-backend.md @@ -32,11 +32,15 @@ application. It downloads from N9BC's versioned `exp27_bt-champion` release (`https://github.com/n9bc/DeepFist/releases/download/exp27_bt-champion/`), whose `deepfist.onnx` and `deepfist.onnx.json` match the pinned lengths and hashes below; a non-empty `DEEPFIST_MODEL_BASE_URL` replaces that directory. -That release publishes no LICENSE; the manifest's LICENSE asset carries -its own source, the same pinned bytes from the DeepFist repository's first -commit (`061fc1d7`). The RFC's upstream-only hosting decision remains in effect; -there is no AetherSDR mirror. A developer can point `AETHER_DEEPFIST_MODEL_DIR` -at a local verified bundle instead. +The release has no separate LICENSE asset. The manifest downloads its license +from the release tag's resolved commit, +[`930bd3cea7d9bf19fd45413acc9064b54c722871`](https://github.com/n9bc/DeepFist/tree/930bd3cea7d9bf19fd45413acc9064b54c722871), +using a commit-pinned HTTPS URL and the exact size/hash below. The shared +`CwModelAssets` downloader serves each decoder's own catalog. The RFC's +upstream-only hosting decision remains in effect; there is no AetherSDR mirror. +A developer can point `AETHER_DEEPFIST_MODEL_DIR` at a local verified bundle. +An older cache keeps its model and metadata; its historical LICENSE is +re-verified and replaced from the pinned source before the bundle is ready. The downloader verifies exact lengths and SHA-256 hashes, takes a cache lock, uses atomic file replacement, and checks the complete bundle before loading. @@ -48,13 +52,26 @@ removed asset available to new installations. |---|---:|---| | deepfist.onnx | 13051998 | 6d2d4e3d66f9001d15e21a1b38b79150eae19ead86a310202900ee69d672b94d | | deepfist.onnx.json | 1257 | 840ceb8dba9d46d04495547a8a3789968b1acd2f8ac3a3a5c631f84008ac2217 | -| LICENSE | 1068 | 9ad70a9ed30d58502e29f9e691a008ee7bccb6eba49d4384f2b7e675d68dc4f3 | +| LICENSE | 35927 | d8dcabd2c59c9e96be89f094a94474685e3fcd5ee8c4304ae7fbbf6c722df6ab | -The original PR's qualification used a bundle extracted from the Lyra 0.24.0 -installer as an archive. It is not committed or bundled into AetherSDR. Native helpers retain -their pinned historical MIT license and attribution under -`third_party/deepfist`; later upstream licensing does not identify the license -of a different checkpoint or future update. +### License understanding + +For the `exp27_bt-champion` download, AetherSDR follows the upstream release +revision's [GPL-3.0-or-later license](https://github.com/n9bc/DeepFist/blob/930bd3cea7d9bf19fd45413acc9064b54c722871/LICENSE) +and [README license notice](https://github.com/n9bc/DeepFist/blob/930bd3cea7d9bf19fd45413acc9064b54c722871/README.md#license). +The model predates upstream's MIT-to-GPL change; that history does not create +an incompatibility with AetherSDR's GPLv3 licensing. MIT is also +[GPL-compatible](https://www.gnu.org/licenses/license-list.html#Expat), with +its notices preserved. The operator accepted using the published release's +GPL terms for this pinned download on 2026-10-17. This records the project's +current understanding, rather than claiming a separate upstream clarification +of the historical checkpoint's license. Future model revisions need their own +provenance and pins. + +The native helpers under `third_party/deepfist` retain their pinned historical +MIT license and attribution. The model is downloaded directly from upstream; +it is neither committed nor bundled into AetherSDR. Its attribution and pinned +license source are also recorded in `THIRD_PARTY_LICENSES`. ## Validation contract diff --git a/docs/user/docs/cw-decoder.md b/docs/user/docs/cw-decoder.md index c003c33d67..ba96148dda 100644 --- a/docs/user/docs/cw-decoder.md +++ b/docs/user/docs/cw-decoder.md @@ -144,7 +144,7 @@ Colors are based on ggmorse's cost function: ### Technical details - Library: [ggmorse](https://github.com/ggerganov/ggmorse) by Georgi Gerganov (MIT license) -- Neural decoders: DeepFist ([n9bc/DeepFist](https://github.com/n9bc/DeepFist)) and DeepCW ([e04/deepcw-engine](https://github.com/e04/deepcw-engine), AGPL-3.0); both run with ONNX Runtime, and their models are downloaded, not bundled +- Neural decoders: DeepFist ([n9bc/DeepFist](https://github.com/n9bc/DeepFist), downloaded with the pinned upstream release’s GPL-3.0-or-later notice) and DeepCW ([e04/deepcw-engine](https://github.com/e04/deepcw-engine), AGPL-3.0); both run with ONNX Runtime, and their models are downloaded, not bundled - ggmorse is built in — no external dependency - Runs on a dedicated worker thread — does not block audio playback - CPU usage: negligible diff --git a/src/core/deepfist/DeepFistModelAssets.cpp b/src/core/CwModelAssets.cpp similarity index 82% rename from src/core/deepfist/DeepFistModelAssets.cpp rename to src/core/CwModelAssets.cpp index 2486df50de..0700152cca 100644 --- a/src/core/deepfist/DeepFistModelAssets.cpp +++ b/src/core/CwModelAssets.cpp @@ -1,4 +1,4 @@ -#include "DeepFistModelAssets.h" +#include "CwModelAssets.h" #include "core/NetworkDiagnostics.h" #include @@ -17,7 +17,7 @@ namespace AetherSDR { namespace { -bool matches(const QString& path, const DeepFistModelAssets::Asset& asset) +bool matches(const QString& path, const CwModelAssets::Asset& asset) { QFile file(path); if (!file.open(QIODevice::ReadOnly) || file.size() != asset.bytes) { return false; } @@ -25,31 +25,31 @@ bool matches(const QString& path, const DeepFistModelAssets::Asset& asset) return hash.addData(&file) && hash.result().toHex() == asset.sha256; } } -QVector DeepFistModelAssets::manifest() +QVector CwModelAssets::manifest() { return { {"deepfist.onnx", 13051998, "6d2d4e3d66f9001d15e21a1b38b79150eae19ead86a310202900ee69d672b94d"}, {"deepfist.onnx.json", 1257, "840ceb8dba9d46d04495547a8a3789968b1acd2f8ac3a3a5c631f84008ac2217"}, - // The release publishes no LICENSE; these are the pinned bytes from the - // repository's first commit, fetched from that commit. - {"LICENSE", 1068, "9ad70a9ed30d58502e29f9e691a008ee7bccb6eba49d4384f2b7e675d68dc4f3", - "https://raw.githubusercontent.com/n9bc/DeepFist/061fc1d71f2b137b7b145b76cbc89c7e3d7a0d68/LICENSE"} + // The release tag's GPL-3.0-or-later notice, pinned to its commit. + // The downloaded weights and metadata keep their independent hashes. + {"LICENSE", 35927, "d8dcabd2c59c9e96be89f094a94474685e3fcd5ee8c4304ae7fbbf6c722df6ab", + "https://raw.githubusercontent.com/n9bc/DeepFist/930bd3cea7d9bf19fd45413acc9064b54c722871/LICENSE"} }; } -QString DeepFistModelAssets::releaseBaseUrl() +QString CwModelAssets::releaseBaseUrl() { // The build selects the published upstream release or an explicit override. return QString::fromUtf8(DEEPFIST_MODEL_BASE_URL); } -DeepFistModelAssets::DeepFistModelAssets(QString directory, QString baseUrl, +CwModelAssets::CwModelAssets(QString directory, QString baseUrl, QVector assets, QNetworkAccessManager* network, QObject* parent) : QObject(parent), m_directory(std::move(directory)), m_baseUrl(std::move(baseUrl)), m_assets(std::move(assets)), m_network(network) { if (!m_network) { m_network = new QNetworkAccessManager(this); } } -DeepFistModelAssets::~DeepFistModelAssets() { cancel(); } -void DeepFistModelAssets::cleanup() +CwModelAssets::~CwModelAssets() { cancel(); } +void CwModelAssets::cleanup() { if (m_reply) { m_reply->disconnect(this); @@ -61,18 +61,18 @@ void DeepFistModelAssets::cleanup() m_hash.reset(); m_lock.reset(); } -void DeepFistModelAssets::cancel() +void CwModelAssets::cancel() { ++m_generation; m_busy = false; cleanup(); } -void DeepFistModelAssets::fail(const QString& reason) +void CwModelAssets::fail(const QString& reason) { cancel(); emit failed(reason); } -void DeepFistModelAssets::ensure() +void CwModelAssets::ensure() { if (m_busy) { return; } m_busy = true; @@ -100,7 +100,7 @@ void DeepFistModelAssets::ensure() } next(); } -void DeepFistModelAssets::next() +void CwModelAssets::next() { if (!m_busy) { return; } if (m_index == m_assets.size()) { @@ -129,7 +129,7 @@ void DeepFistModelAssets::next() watcher->setFuture(QtConcurrent::run([path, asset] { return matches(path, asset); })); emit checking(); } -void DeepFistModelAssets::download() +void CwModelAssets::download() { const Asset& asset = m_assets[m_index]; const QString prefix = m_baseUrl.endsWith('/') ? m_baseUrl : m_baseUrl + '/'; @@ -147,18 +147,18 @@ void DeepFistModelAssets::download() QNetworkRequest request(source); request.setTransferTimeout(30000); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("AetherSDR-DeepFist")); + request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("AetherSDR-CW")); m_reply = m_network->get(request); - NetworkDiagnostics::watch(m_reply, "DeepFist model download"); + NetworkDiagnostics::watch(m_reply, "CW model download"); m_reply->setReadBufferSize(64 * 1024); - connect(m_reply, &QNetworkReply::readyRead, this, &DeepFistModelAssets::readAvailable); - connect(m_reply, &QNetworkReply::finished, this, &DeepFistModelAssets::finishDownload); + connect(m_reply, &QNetworkReply::readyRead, this, &CwModelAssets::readAvailable); + connect(m_reply, &QNetworkReply::finished, this, &CwModelAssets::finishDownload); emit progress(m_completed, m_total); } -void DeepFistModelAssets::readAvailable() +void CwModelAssets::readAvailable() { if (!m_reply || !m_file) { return; } - const QPointer guard(this); + const QPointer guard(this); const quint64 generation = m_generation; while (m_reply && m_reply->bytesAvailable() > 0) { const QByteArray bytes = m_reply->read(64 * 1024); @@ -174,10 +174,10 @@ void DeepFistModelAssets::readAvailable() if (!guard || !m_busy || generation != m_generation) { return; } } } -void DeepFistModelAssets::finishDownload() +void CwModelAssets::finishDownload() { if (!m_reply) { return; } - const QPointer guard(this); + const QPointer guard(this); const quint64 generation = m_generation; if (m_reply->error() != QNetworkReply::NoError || m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { diff --git a/src/core/deepfist/DeepFistModelAssets.h b/src/core/CwModelAssets.h similarity index 90% rename from src/core/deepfist/DeepFistModelAssets.h rename to src/core/CwModelAssets.h index a8e44ef8c9..9954e4cf8d 100644 --- a/src/core/deepfist/DeepFistModelAssets.h +++ b/src/core/CwModelAssets.h @@ -16,7 +16,7 @@ namespace AetherSDR { // First-use bundle preparation. Follows the ASR download/cache pattern, with // bounded writes and cancellation covering cache verification as well as I/O. -class DeepFistModelAssets final : public QObject { +class CwModelAssets final : public QObject { Q_OBJECT public: // url, when set, is the asset's absolute HTTPS source instead of baseUrl + name. @@ -24,10 +24,10 @@ class DeepFistModelAssets final : public QObject { static QVector manifest(); static QString releaseBaseUrl(); // Network injection and an explicit catalog make socket-free tests possible. - explicit DeepFistModelAssets(QString directory, QString baseUrl = releaseBaseUrl(), + explicit CwModelAssets(QString directory, QString baseUrl = releaseBaseUrl(), QVector assets = manifest(), QNetworkAccessManager* network = nullptr, QObject* parent = nullptr); - ~DeepFistModelAssets() override; + ~CwModelAssets() override; void ensure(); void cancel(); bool busy() const { return m_busy; } diff --git a/src/models/DeepCwRxBackend.cpp b/src/models/DeepCwRxBackend.cpp index a662698a6e..ff39a80b21 100644 --- a/src/models/DeepCwRxBackend.cpp +++ b/src/models/DeepCwRxBackend.cpp @@ -3,7 +3,7 @@ #include "core/DeepCwEngine.h" #include "core/LogManager.h" #include "core/Resampler.h" -#include "core/deepfist/DeepFistModelAssets.h" +#include "core/CwModelAssets.h" #include #include #include @@ -16,7 +16,7 @@ namespace { constexpr auto kModelCommit = "9185d5da7d2344393d4e28352cd66c21ed83cad6"; const QString kModelFile = QStringLiteral("model.onnx"); -QVector deepCwManifest() +QVector deepCwManifest() { return {{kModelFile, 15139839, "ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02"}}; @@ -43,23 +43,23 @@ DeepCwRxBackend::DeepCwRxBackend(QObject* parent) DeepCwRxBackend::DeepCwRxBackend(QString directory, QString baseUrl, QNetworkAccessManager* network, QObject* parent) : CwRxBackend(parent), - m_assets(std::make_unique(directory, std::move(baseUrl), + m_assets(std::make_unique(directory, std::move(baseUrl), deepCwManifest(), network)), m_directory(std::move(directory)) { - connect(m_assets.get(), &DeepFistModelAssets::checking, this, [this] { + connect(m_assets.get(), &CwModelAssets::checking, this, [this] { setStatus(tr("Checking model…")); }); - connect(m_assets.get(), &DeepFistModelAssets::progress, this, [this](qint64 got, qint64 total) { + connect(m_assets.get(), &CwModelAssets::progress, this, [this](qint64 got, qint64 total) { setStatus(tr("Download %1%").arg(total > 0 ? got * 100 / total : 0)); }); - connect(m_assets.get(), &DeepFistModelAssets::failed, this, [this](const QString& reason) { + connect(m_assets.get(), &CwModelAssets::failed, this, [this](const QString& reason) { m_preparing = false; m_canRetry = true; m_detail = reason; setStatus(tr("Model unavailable")); }); - connect(m_assets.get(), &DeepFistModelAssets::ready, this, [this] { + connect(m_assets.get(), &CwModelAssets::ready, this, [this] { if (!m_started || !m_preparing) { return; } m_preparing = false; m_detail.clear(); diff --git a/src/models/DeepCwRxBackend.h b/src/models/DeepCwRxBackend.h index 44126c7858..2b416644ff 100644 --- a/src/models/DeepCwRxBackend.h +++ b/src/models/DeepCwRxBackend.h @@ -12,7 +12,7 @@ class QThread; namespace AetherSDR { class DeepCwEngine; -class DeepFistModelAssets; +class CwModelAssets; // DeepCW (RFC #4817, K5PTB's port of e04/deepcw-engine) behind CwRxBackend. // Consumes the converted mono 24 kHz feed (feedFixed24), resamples it to the @@ -51,7 +51,7 @@ class DeepCwRxBackend final : public CwRxBackend { void postStatus(quint64 runId, const QString& status, bool failure); void setStatus(const QString& status); - std::unique_ptr m_assets; + std::unique_ptr m_assets; QString m_directory; // Owner thread. diff --git a/src/models/DeepFistCwModel.cpp b/src/models/DeepFistCwModel.cpp index 3aee8d67ce..026dd8a917 100644 --- a/src/models/DeepFistCwModel.cpp +++ b/src/models/DeepFistCwModel.cpp @@ -1,7 +1,7 @@ #include "DeepFistCwModel.h" #include "core/Resampler.h" #include "core/deepfist/DeepFistStream.h" -#include "core/deepfist/DeepFistModelAssets.h" +#include "core/CwModelAssets.h" #include "DeepFistModel.h" #include #include @@ -35,26 +35,26 @@ DeepFistStream::Parameters DeepFistCwModel::appParameters() return parameters; } DeepFistCwModel::DeepFistCwModel(QObject* parent) - : DeepFistCwModel(modelDirectory(), DeepFistModelAssets::releaseBaseUrl(), nullptr, parent) {} + : DeepFistCwModel(modelDirectory(), CwModelAssets::releaseBaseUrl(), nullptr, parent) {} DeepFistCwModel::DeepFistCwModel(QString directory, QString baseUrl, QNetworkAccessManager* network, QObject* parent) - : QObject(parent), m_assets(std::make_unique(directory, baseUrl, - DeepFistModelAssets::manifest(), network)), m_directory(std::move(directory)) + : QObject(parent), m_assets(std::make_unique(directory, baseUrl, + CwModelAssets::manifest(), network)), m_directory(std::move(directory)) { m_parameters = appParameters(); - connect(m_assets.get(), &DeepFistModelAssets::checking, this, [this] { + connect(m_assets.get(), &CwModelAssets::checking, this, [this] { setStatus(tr("Checking model…")); }); - connect(m_assets.get(), &DeepFistModelAssets::progress, this, [this](qint64 got, qint64 total) { + connect(m_assets.get(), &CwModelAssets::progress, this, [this](qint64 got, qint64 total) { setStatus(tr("Download %1%").arg(total > 0 ? got * 100 / total : 0)); }); - connect(m_assets.get(), &DeepFistModelAssets::failed, this, [this](const QString& reason) { + connect(m_assets.get(), &CwModelAssets::failed, this, [this](const QString& reason) { m_preparing = false; m_canRetry = true; m_detail = reason; setStatus(tr("Model unavailable")); }); - connect(m_assets.get(), &DeepFistModelAssets::ready, this, [this] { + connect(m_assets.get(), &CwModelAssets::ready, this, [this] { if (!m_running || !m_preparing) { return; } m_preparing = false; const quint64 generation = m_runId; @@ -183,10 +183,10 @@ void DeepFistCwModel::feed(const PcmFrame& frame) } void DeepFistCwModel::run(quint64 runId, const QString& directory) { - // One manifest, owned by DeepFistModelAssets. Re-declaring the sizes and + // One manifest, owned by CwModelAssets. Re-declaring the sizes and // hashes here let a model bump update one copy and not the other, which // downloads and verifies clean and then reports "Model unavailable" forever. - for (const DeepFistModelAssets::Asset& asset : DeepFistModelAssets::manifest()) { + for (const CwModelAssets::Asset& asset : CwModelAssets::manifest()) { if (asset.name == QLatin1String("LICENSE")) { continue; } if (!matches(QDir(directory).filePath(asset.name), asset.bytes, asset.sha256)) { postStatus(runId, tr("Model unavailable"), true); diff --git a/src/models/DeepFistCwModel.h b/src/models/DeepFistCwModel.h index a69703e179..95a1c79360 100644 --- a/src/models/DeepFistCwModel.h +++ b/src/models/DeepFistCwModel.h @@ -11,7 +11,7 @@ #include class QNetworkAccessManager; namespace AetherSDR { -class DeepFistModelAssets; +class CwModelAssets; // GUI-facing experimental RX decoder. All DSP and model state is worker-owned. // Public methods run on the owning thread; no inference runs on that thread. class DeepFistCwModel final : public QObject { @@ -53,7 +53,7 @@ class DeepFistCwModel final : public QObject { void setStatus(const QString& status); DeepFistStream::Parameters m_parameters; std::thread m_worker; - std::unique_ptr m_assets; + std::unique_ptr m_assets; QString m_directory; QString m_detail; bool m_preparing = false; diff --git a/tests/deepfist_model_assets_test.cpp b/tests/deepfist_model_assets_test.cpp index d5a8f853f9..61cc9ee705 100644 --- a/tests/deepfist_model_assets_test.cpp +++ b/tests/deepfist_model_assets_test.cpp @@ -1,4 +1,4 @@ -#include "core/deepfist/DeepFistModelAssets.h" +#include "core/CwModelAssets.h" #include "DeepFistDownloadTransport.h" #include #include @@ -41,9 +41,9 @@ void write(const QString& path, const QByteArray& bytes) QFile file(path); if (file.open(QIODevice::WriteOnly)) { file.write(bytes); } } -QVector catalog(DeepFistTestNetwork& network) +QVector catalog(DeepFistTestNetwork& network) { - QVector result; + QVector result; for (const QString& name : {QStringLiteral("model"), QStringLiteral("metadata"), QStringLiteral("license")}) { const QByteArray bytes = name.toUtf8().repeated(20000); network.files[name] = {bytes}; @@ -56,13 +56,13 @@ void successAndCancel() QTemporaryDir dir; DeepFistTestNetwork network; const auto assets = catalog(network); - DeepFistModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); + CwModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); int ready = 0, failed = 0; - QObject::connect(&manager, &DeepFistModelAssets::ready, &manager, [&] { ++ready; }); - QObject::connect(&manager, &DeepFistModelAssets::failed, &manager, [&] { ++failed; }); + QObject::connect(&manager, &CwModelAssets::ready, &manager, [&] { ++ready; }); + QObject::connect(&manager, &CwModelAssets::failed, &manager, [&] { ++failed; }); manager.ensure(); manager.cancel(); pump(50); expect(ready == 0 && failed == 0 && network.requests == 0, "cancel verification prevents downloads and completion"); - const auto cancel = QObject::connect(&manager, &DeepFistModelAssets::progress, &manager, + const auto cancel = QObject::connect(&manager, &CwModelAssets::progress, &manager, [&](qint64 got, qint64) { if (got > 0) { manager.cancel(); } }); manager.ensure(); expect(wait([&] { return !manager.busy(); }), "transfer cancellation completes"); pump(20); @@ -90,10 +90,10 @@ void rejection(int variant) if (variant == 3) { reply.status = 404; } if (variant == 4) { reply.error = QNetworkReply::RemoteHostClosedError; } write(dir.filePath("model"), "previous bytes"); - DeepFistModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); + CwModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); bool ready = false, failed = false; - QObject::connect(&manager, &DeepFistModelAssets::ready, &manager, [&] { ready = true; }); - QObject::connect(&manager, &DeepFistModelAssets::failed, &manager, [&](const QString&) { failed = true; }); + QObject::connect(&manager, &CwModelAssets::ready, &manager, [&] { ready = true; }); + QObject::connect(&manager, &CwModelAssets::failed, &manager, [&](const QString&) { failed = true; }); manager.ensure(); expect(wait([&] { return failed || ready; }) && failed && !ready, "reject invalid or incomplete HTTP transfer"); expect(read(dir.filePath("model")) == "previous bytes", "failed replacement preserves existing file"); expect(network.requests == 1, "failed asset prevents remainder of bundle"); @@ -103,19 +103,19 @@ void lifecycle() QTemporaryDir dir; DeepFistTestNetwork network; const auto assets = catalog(network); - DeepFistModelAssets first(dir.path(), "https://fixture.invalid/v1", assets, &network); - DeepFistModelAssets second(dir.path(), "https://fixture.invalid/v1", assets, &network); + CwModelAssets first(dir.path(), "https://fixture.invalid/v1", assets, &network); + CwModelAssets second(dir.path(), "https://fixture.invalid/v1", assets, &network); bool locked = false; - QObject::connect(&second, &DeepFistModelAssets::failed, &second, [&](const QString&) { locked = true; }); + QObject::connect(&second, &CwModelAssets::failed, &second, [&](const QString&) { locked = true; }); first.ensure(); second.ensure(); expect(locked, "cache lock prevents concurrent writers"); first.cancel(); - { DeepFistModelAssets transient(dir.path(), "https://fixture.invalid/v1", assets, &network); transient.ensure(); } + { CwModelAssets transient(dir.path(), "https://fixture.invalid/v1", assets, &network); transient.ensure(); } pump(50); expect(network.requests == 0, "destroy while checking cannot start network work"); - DeepFistModelAssets unavailable(dir.path(), {}, assets, &network); + CwModelAssets unavailable(dir.path(), {}, assets, &network); bool failed = false; - QObject::connect(&unavailable, &DeepFistModelAssets::failed, &unavailable, [&](const QString&) { failed = true; }); + QObject::connect(&unavailable, &CwModelAssets::failed, &unavailable, [&](const QString&) { failed = true; }); unavailable.ensure(); expect(wait([&] { return failed; }), "unpublished source fails explicitly"); } void perAssetSource() @@ -124,10 +124,10 @@ void perAssetSource() DeepFistTestNetwork network; auto assets = catalog(network); assets.last().url = QStringLiteral("https://pinned.invalid/commit/license"); - DeepFistModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); + CwModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); bool ready = false, failed = false; - QObject::connect(&manager, &DeepFistModelAssets::ready, &manager, [&] { ready = true; }); - QObject::connect(&manager, &DeepFistModelAssets::failed, &manager, [&](const QString&) { failed = true; }); + QObject::connect(&manager, &CwModelAssets::ready, &manager, [&] { ready = true; }); + QObject::connect(&manager, &CwModelAssets::failed, &manager, [&](const QString&) { failed = true; }); manager.ensure(); expect(wait([&] { return ready || failed; }) && ready && !failed, "bundle with a per-asset source completes"); expect(network.urls.contains("https://pinned.invalid/commit/license") @@ -138,9 +138,9 @@ void perAssetSource() DeepFistTestNetwork plain; auto insecure = catalog(plain); insecure.first().url = QStringLiteral("http://pinned.invalid/model"); - DeepFistModelAssets refused(other.path(), "https://fixture.invalid/v1", insecure, &plain); + CwModelAssets refused(other.path(), "https://fixture.invalid/v1", insecure, &plain); bool refusedFailed = false; - QObject::connect(&refused, &DeepFistModelAssets::failed, &refused, [&](const QString&) { refusedFailed = true; }); + QObject::connect(&refused, &CwModelAssets::failed, &refused, [&](const QString&) { refusedFailed = true; }); refused.ensure(); expect(wait([&] { return refusedFailed; }) && plain.requests == 0, "non-HTTPS asset source is refused"); } @@ -151,13 +151,13 @@ void progressLifetime(bool finishOnly, bool destroy) const auto assets = catalog(network); // Some replies expose the last bytes only when finished is delivered. network.files["model"].notifyReadyRead = !finishOnly; - auto manager = std::make_unique(dir.path(), + auto manager = std::make_unique(dir.path(), "https://fixture.invalid/v1", assets, &network); int ready = 0, failed = 0; bool interrupted = false; - QObject::connect(manager.get(), &DeepFistModelAssets::ready, &network, [&] { ++ready; }); - QObject::connect(manager.get(), &DeepFistModelAssets::failed, &network, [&] { ++failed; }); - QObject::connect(manager.get(), &DeepFistModelAssets::progress, &network, + QObject::connect(manager.get(), &CwModelAssets::ready, &network, [&] { ++ready; }); + QObject::connect(manager.get(), &CwModelAssets::failed, &network, [&] { ++failed; }); + QObject::connect(manager.get(), &CwModelAssets::progress, &network, [&](qint64 got, qint64) { if (got == 0 || interrupted) { return; } interrupted = true; @@ -191,7 +191,7 @@ void oversizeIsRefusedMidStream() { QTemporaryDir dir; const QByteArray payload(4096, 'z'); - QVector manifest{ + QVector manifest{ {"oversize.bin", payload.size(), QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex()}}; @@ -199,18 +199,18 @@ void oversizeIsRefusedMidStream() network.files.insert("oversize.bin", {payload + QByteArray(4096, 'z'), 200, QNetworkReply::NoError, true}); - DeepFistModelAssets assets(dir.path(), "https://example.invalid/models/", + CwModelAssets assets(dir.path(), "https://example.invalid/models/", manifest, &network); bool failed = false; - QObject::connect(&assets, &DeepFistModelAssets::failed, [&](const QString&) { failed = true; }); + QObject::connect(&assets, &CwModelAssets::failed, [&](const QString&) { failed = true; }); bool ready = false; - QObject::connect(&assets, &DeepFistModelAssets::ready, [&] { ready = true; }); + QObject::connect(&assets, &CwModelAssets::ready, [&] { ready = true; }); // The end-of-transfer size+hash check rejects an oversize body too, so // asserting only "it failed" does not distinguish the streaming guard from // it. Watch the byte counter instead: with the guard the transfer is cut // off, without it every extra byte is written and reported first. qint64 highWater = 0; - QObject::connect(&assets, &DeepFistModelAssets::progress, + QObject::connect(&assets, &CwModelAssets::progress, [&](qint64 done, qint64) { highWater = std::max(highWater, done); }); assets.ensure(); wait([&] { return failed || ready; }); diff --git a/tests/tests.cmake b/tests/tests.cmake index ec3b7ac1de..9abff834d6 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -9568,7 +9568,7 @@ add_test(NAME deepcw_ctc_test COMMAND deepcw_ctc_test) set_tests_properties(deepcw_ctc_test PROPERTIES TIMEOUT 20) add_executable(deepfist_model_assets_test tests/deepfist_model_assets_test.cpp - src/core/deepfist/DeepFistModelAssets.cpp src/core/deepfist/DeepFistModelAssets.h src/core/NetworkDiagnostics.cpp) + src/core/CwModelAssets.cpp src/core/CwModelAssets.h src/core/NetworkDiagnostics.cpp) target_include_directories(deepfist_model_assets_test PRIVATE src tests) target_compile_definitions(deepfist_model_assets_test PRIVATE DEEPFIST_MODEL_BASE_URL="") target_link_libraries(deepfist_model_assets_test PRIVATE Qt6::Core Qt6::Network Qt6::Concurrent)