diff --git a/BUILD-OPTIONS.md b/BUILD-OPTIONS.md index e4be88ed6..1b264391f 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. | | `AETHER_HL2_TX_TXA` | ON | Select WDSP's TXA chain for the Hermes-Lite 2 SSB transmit modulator. OFF builds the in-tree phasing modulator. 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. @@ -148,7 +149,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. | @@ -176,7 +177,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` | 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 286e43086..c905e9898 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3014,27 +3014,36 @@ 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)") -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}") +# 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. +# 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) + # 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_DEEPFIST HAVE_DEEPCW HAVE_CW_RX_BACKENDS) 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 - third_party/deepfist/DeepFistModel.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") -endif() - -if(ORT_FOUND) - target_compile_definitions(aethercore PUBLIC HAVE_ONNX) # 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). @@ -3376,6 +3385,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 782759cc2..6573f4ac6 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -621,8 +621,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 @@ -726,3 +726,27 @@ tools/docs/pdf/fonts/OFL.txt. Copyright: Copyright 2022 The Noto Project Authors License: SIL Open Font License, Version 1.1 Source: https://github.com/notofonts/symbols + + +29. 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/docs/deepfist-cw-backend.md b/docs/deepfist-cw-backend.md index 7359d2284..0c6101b65 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,18 @@ 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. 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 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 +63,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 14e5c4f50..70648d4dd 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 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. + ### 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. @@ -73,7 +98,7 @@ When the decoder copies a station identifying itself (`DE `), and QRZ.com ### 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 @@ -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,7 +142,8 @@ Colours are based on ggmorse's cost function: ### Technical details - Library: [ggmorse](https://github.com/ggerganov/ggmorse) by Georgi Gerganov (MIT license) -- Bundled directly — no external dependency +- 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 +- 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/DeepCwCommitter.cpp b/src/core/DeepCwCommitter.cpp new file mode 100644 index 000000000..bb8ea18a0 --- /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 000000000..3f82318e9 --- /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 000000000..00265a995 --- /dev/null +++ b/src/core/DeepCwEngine.cpp @@ -0,0 +1,348 @@ +// 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 +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +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 + // 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); +#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 000000000..c08869f44 --- /dev/null +++ b/src/core/DeepCwEngine.h @@ -0,0 +1,115 @@ +#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. +// Modified for AetherSDR, 2026-09: per-character emissions with frame timing +// (inferLogProbs, greedyEmissions); decode() is unchanged. + +#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/src/core/deepfist/DeepFistModelAssets.cpp b/src/core/deepfist/DeepFistModelAssets.cpp index b5a6b92e1..7c6f9efaa 100644 --- a/src/core/deepfist/DeepFistModelAssets.cpp +++ b/src/core/deepfist/DeepFistModelAssets.cpp @@ -29,7 +29,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() @@ -127,19 +130,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 dedf1accc..a8e44ef8c 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/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index f7461413a..8f735f69e 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2679,7 +2679,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"); @@ -9166,7 +9166,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, @@ -9284,7 +9284,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 02ddd424c..5b74fca08 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -1293,7 +1293,7 @@ private slots: VkampConnection m_vkampConn; // VK3AMP amplifier, TCP control/status + UDP telemetry 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 7e49d7b1b..cd234050d 100644 --- a/src/gui/MainWindow_DigitalModes.cpp +++ b/src/gui/MainWindow_DigitalModes.cpp @@ -100,7 +100,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/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index 946adb41e..cb6aed33d 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -6070,8 +6070,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/PanadapterApplet.cpp b/src/gui/PanadapterApplet.cpp index 4c80a4c76..8a684cfcd 100644 --- a/src/gui/PanadapterApplet.cpp +++ b/src/gui/PanadapterApplet.cpp @@ -229,13 +229,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); @@ -837,8 +839,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; @@ -863,14 +865,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) @@ -972,9 +975,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 3fa27616f..977bf0d09 100644 --- a/src/gui/PanadapterApplet.h +++ b/src/gui/PanadapterApplet.h @@ -73,8 +73,8 @@ class PanadapterApplet : public QWidget { 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); @@ -110,7 +110,7 @@ class PanadapterApplet : public QWidget { QSize sizeHint() const override { return {800, 316}; } signals: -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS void cwEngineChanged(const QString& backend); void cwModelActionRequested(); #endif @@ -182,7 +182,7 @@ class PanadapterApplet : public QWidget { #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 4a266fc60..b14bdb61d 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" @@ -6164,7 +6164,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); @@ -6184,14 +6184,14 @@ void VfoWidget::rebuildFilterButtons() updateFilterHighlight(); } -#ifdef HAVE_DEEPFIST +#ifdef HAVE_CW_RX_BACKENDS 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/gui/VfoWidget.h b/src/gui/VfoWidget.h index 3b896e87d..09f916de2 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/CwDecodeSettings.h b/src/models/CwDecodeSettings.h index 419448976..b07de47bd 100644 --- a/src/models/CwDecodeSettings.h +++ b/src/models/CwDecodeSettings.h @@ -18,7 +18,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/CwRxModel.cpp b/src/models/CwRxModel.cpp index a6a3bd8eb..d6cbcb217 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 000000000..09ac7d640 --- /dev/null +++ b/src/models/DeepCwRxBackend.cpp @@ -0,0 +1,244 @@ +#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; + } + m_canRetry = false; + m_detail.clear(); + stopWorker(); + setStatus({}); +} + +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; + QMutexLocker lock(&m_ringMutex); + m_ring.clear(); +} + +void DeepCwRxBackend::reset() +{ + 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 5 s behind the live edge. +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; + 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)); + 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); + // 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 000000000..44126c785 --- /dev/null +++ b/src/models/DeepCwRxBackend.h @@ -0,0 +1,78 @@ +#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; } + 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}; + 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 diff --git a/src/models/DeepFistCwModel.cpp b/src/models/DeepFistCwModel.cpp index e194b8be7..3aee8d67c 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) diff --git a/tests/DeepFistDownloadTransport.h b/tests/DeepFistDownloadTransport.h index 90513150a..58ff65e5b 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 000000000..4cee9e886 --- /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_cw_model_test.cpp b/tests/deepfist_cw_model_test.cpp index 97543b9bc..484366b4c 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(); diff --git a/tests/deepfist_model_assets_test.cpp b/tests/deepfist_model_assets_test.cpp index 605ecaf7c..d5a8f853f 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 13574c269..cb6464a0f 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -8672,8 +8672,8 @@ 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. -if(ENABLE_DEEPFIST_EXPERIMENT) +# 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) target_link_libraries(deepfist_cw_model_test PRIVATE aethercore Qt6::Core) @@ -8688,6 +8688,11 @@ if(ENABLE_DEEPFIST_EXPERIMENT) 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() diff --git a/tools/deepcw_replay.cpp b/tools/deepcw_replay.cpp new file mode 100644 index 000000000..b59ee673b --- /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; +}