diff --git a/BUILD-OPTIONS.md b/BUILD-OPTIONS.md index d68162dc5a..f5c59dc3f6 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` | 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 b7e6ef29d8..b3196f5e3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3173,27 +3173,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/CwModelAssets.cpp src/core/CwModelAssets.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). @@ -3540,6 +3549,16 @@ 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. +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..3d36918b31 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 @@ -766,3 +766,47 @@ 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 + + +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 7359d2284f..a36927ac90 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,23 @@ 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. +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. @@ -56,13 +52,26 @@ removed asset available to new installations. |---|---:|---| | deepfist.onnx | 13051998 | 6d2d4e3d66f9001d15e21a1b38b79150eae19ead86a310202900ee69d672b94d | | deepfist.onnx.json | 1257 | 840ceb8dba9d46d04495547a8a3789968b1acd2f8ac3a3a5c631f84008ac2217 | -| LICENSE | 1068 | 9ad70a9ed30d58502e29f9e691a008ee7bccb6eba49d4384f2b7e675d68dc4f3 | +| LICENSE | 35927 | d8dcabd2c59c9e96be89f094a94474685e3fcd5ee8c4304ae7fbbf6c722df6ab | + +### 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 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. +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 @@ -72,7 +81,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..ba96148dda 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,31 @@ 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. 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. + +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. + +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 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 +100,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 @@ -91,7 +118,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 +144,8 @@ Colors 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), 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 79% rename from src/core/deepfist/DeepFistModelAssets.cpp rename to src/core/CwModelAssets.cpp index 6e15da8780..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,28 +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"}, - {"LICENSE", 1068, "9ad70a9ed30d58502e29f9e691a008ee7bccb6eba49d4384f2b7e675d68dc4f3"} + // 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() { - // 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, +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); @@ -58,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; @@ -97,7 +100,7 @@ void DeepFistModelAssets::ensure() } next(); } -void DeepFistModelAssets::next() +void CwModelAssets::next() { if (!m_busy) { return; } if (m_index == m_assets.size()) { @@ -126,35 +129,36 @@ void DeepFistModelAssets::next() watcher->setFuture(QtConcurrent::run([path, asset] { return matches(path, asset); })); emit checking(); } -void DeepFistModelAssets::download() +void CwModelAssets::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")); + 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); @@ -170,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 85% rename from src/core/deepfist/DeepFistModelAssets.h rename to src/core/CwModelAssets.h index dedf1accc2..9954e4cf8d 100644 --- a/src/core/deepfist/DeepFistModelAssets.h +++ b/src/core/CwModelAssets.h @@ -16,17 +16,18 @@ 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: - 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. - 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/core/DeepCwCommitter.cpp b/src/core/DeepCwCommitter.cpp new file mode 100644 index 0000000000..7facb21b96 --- /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::max(0.0, 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..f24264393f --- /dev/null +++ b/src/core/DeepCwCommitter.h @@ -0,0 +1,51 @@ +#pragma once + +// 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" + +#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..00c69bead3 --- /dev/null +++ b/src/core/DeepCwEngine.cpp @@ -0,0 +1,367 @@ +// 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); 2026-10: both inference paths validate the +// output tensor before reading it. +#include "DeepCwEngine.h" + +#include +#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; + } + } + } +} + +#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") + , 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 int outFrames = checkedOutputFrames(outputs[0]); + if (outFrames == 0) { return {}; } + const float* lp = outputs[0].GetTensorData(); + *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 (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; + 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 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 {}; + } +#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..414336491e --- /dev/null +++ b/src/core/DeepCwEngine.h @@ -0,0 +1,116 @@ +#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); 2026-10: both inference paths validate the +// output tensor before reading it. + +#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 (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 DeepCwRxBackend 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/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/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/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..d4c71953b0 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,14 +6249,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 uses ggmorse's pitch estimate; neural decoder pitch is not used") : QString{}; m_zeroBeatBtn->setToolTip(reason); m_zeroBeatBtn->setAccessibleDescription(reason); } 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/CwDecodeSettings.h b/src/models/CwDecodeSettings.h index 9ee9f4ebf8..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 deepFistSelected() { return backend() == "deepfist"; } + 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/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..ff39a80b21 --- /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/CwModelAssets.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(), &CwModelAssets::checking, this, [this] { + setStatus(tr("Checking model…")); + }); + 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(), &CwModelAssets::failed, this, [this](const QString& reason) { + m_preparing = false; + m_canRetry = true; + m_detail = reason; + setStatus(tr("Model unavailable")); + }); + connect(m_assets.get(), &CwModelAssets::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)); + m_resetRequested = true; + } +} + +// 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) { + 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 0000000000..2b416644ff --- /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 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 +// 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 e194b8be76..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; @@ -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) @@ -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/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/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 new file mode 100644 index 0000000000..3ae87e2b7e --- /dev/null +++ b/tests/deepcw_ctc_test.cpp @@ -0,0 +1,70 @@ +// 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 +#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; } + // 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/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_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(); diff --git a/tests/deepfist_model_assets_test.cpp b/tests/deepfist_model_assets_test.cpp index 605ecaf7c3..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,21 +103,47 @@ 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() +{ + QTemporaryDir dir; + DeepFistTestNetwork network; + auto assets = catalog(network); + assets.last().url = QStringLiteral("https://pinned.invalid/commit/license"); + CwModelAssets manager(dir.path(), "https://fixture.invalid/v1", assets, &network); + bool ready = false, failed = false; + 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") + && !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"); + CwModelAssets refused(other.path(), "https://fixture.invalid/v1", insecure, &plain); + bool refusedFailed = false; + 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"); +} void progressLifetime(bool finishOnly, bool destroy) { QTemporaryDir dir; @@ -125,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; @@ -165,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()}}; @@ -173,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; }); @@ -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 3caa989876..9abff834d6 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,17 +9562,21 @@ 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 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) 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) 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) @@ -9586,6 +9591,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 0000000000..19417e9ce9 --- /dev/null +++ b/tools/deepcw_replay.cpp @@ -0,0 +1,191 @@ +// 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, as in DeepCwRxBackend::decodeLoop() +// +// 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 +#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 | 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; + 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); + 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); + 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); + 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 + 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; + 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; + 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; +}