Skip to content

DeepFist neural CW decoder: DeepFist + Auto (hybrid) RX CW engines, with confirmed-call learning (ONNX Runtime) - #7

Open
n9bc wants to merge 66 commits into
N8SDR1:mainfrom
n9bc:feat/deepfist-cw
Open

DeepFist neural CW decoder: DeepFist + Auto (hybrid) RX CW engines, with confirmed-call learning (ONNX Runtime)#7
n9bc wants to merge 66 commits into
N8SDR1:mainfrom
n9bc:feat/deepfist-cw

Conversation

@n9bc

@n9bc n9bc commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds DeepFist, a neural (spectrogram → CNN + CTC) CW/Morse receive decoder, as
additional selectable RX CW engines alongside the existing fldigi-port ("Classic")
decoder. Three engines: Classic (default, unchanged), DeepFist (neural), and
Auto (a hybrid arbiter that runs both and shows whichever is more confident). Runs via
ONNX Runtime (CPU). Classic remains the default; nothing changes unless the operator
selects DeepFist or Auto.

Scope note: this PR grew from "second engine" to the full DeepFist feature set (Auto
hybrid + confirmed-call learning + a dev-only training-capture path) so it can be
reviewed and land as one coherent unit.

Engines

  • DeepFistsrc/dsp/deepfist/: resampler (48k→3200), front-end conditioner,
    from-scratch STFT spectrogram, greedy CTC, ONNX model wrapper, streaming adapter, and a
    CTC-lattice callsign rescorer (Super Check Partial).
  • Auto (hybrid)src/dsp/CwArbiter.*: runs Classic and DeepFist in parallel and
    displays the more-confident source moment-to-moment, falling back to Classic on fades.
    Copy is source-marked so you can see which engine produced it.

Confirmed-call learning (on by default, no UI toggle)

Decoded callsigns that are also confirmed live by an RBN/cluster spot near the tuned
frequency
are remembered in a local scp_local.txt (AppData). This feeds the rescorer's
candidate set and the "known-real" (amber) highlight, so the decoder improves at the calls
you actually work.

  • Text only — never audio. Stores callsign strings + timestamps; capped (10k) and
    aged out after a year.
  • Opt out with LYRA_CW_LEARN=0; delete scp_local.txt to reset. Privacy surface is
    public callsigns, local-only.

Training capture — developer tool, OFF by default

A trust-tiered CW audio capture path for collecting real-world DeepFist training data. It
has no UI and is disabled unless armed with LYRA_CW_HARVEST=1. When armed, it writes
int16 WAV segments + JSON sidecars to Documents/Lyra/cw_harvest, size-capped (2 GiB,
oldest-evicted), local-only. Not part of the normal user experience.

New dependency

ONNX Runtime (Microsoft, MIT), CPU build — shipped as a DLL beside the exe (load-time
import). Headers committed under third_party/onnxruntime/; binaries git-ignored. This is
why the diff is large — ~22k lines are ORT headers.

Model & data artifacts (NOT in git)

models/deepfist.onnx (+ sidecar) and MASTER.SCP are git-ignored and distributed via the
installer/release. CMake copies them next to the exe on build.

Licensing

DeepFist is MIT (© Brent Crier, N9BC); ONNX Runtime is MIT — GPL-compatible. As integrated
it's part of Lyra's GPLv3+ aggregate; per-file SPDX headers on src/dsp/deepfist/* record
the MIT provenance. See NOTICE.md / CREDITS.md.

Verification

Builds clean (VS2022) and runs; coexists with ZeroBeat and the classic decoder. Rebased/
merged up to date with main (v0.20.0). test_rescore checks the CTC forward pass against
PyTorch F.ctc_loss; test_neural_cw / test_neural_cw_stream match the Python/ONNX
reference; Classic + DeepFist smoke-tested decoding live.

Reviewer notes

  • Opt-in engines, opt-out learning, dev-gated capture — Classic default; Learn is
    text-only + disableable; Harvest is invisible unless env-armed.
  • Real Lyra code lives in src/dsp/deepfist/, src/dsp/CwArbiter.*, src/wdsp_engine.*,
    src/spotstore.*, and src/qml/CwDecoderPanel.qml; the rest of the diff is vendored ORT
    headers.
  • Known limitation: on marginal/fading signals DeepFist's presence gate is less robust than
    Classic (documented in docs/DEEPFIST.md); Auto and Classic cover that case.

🤖 Generated with Claude Code

brentc316 and others added 26 commits July 12, 2026 01:12
Design for adding DeepFist (self-trained CNN+CTC neural CW decoder, MIT)
as a second selectable RX CW engine alongside the fldigi port, via ONNX
Runtime (C++, CPU). Covers model contract, new src/dsp/deepfist module,
engine selection in WdspEngine, dockable pop-out DeepFistPanel, licensing
clearance, and a code-only (model out of git) delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native C++ port of the DeepFist neural CW decoder (spectrogram -> CNN+CTC
ONNX -> greedy CTC), mirroring the verified Rust reference (diddle
cw_neural.rs). Qt-free DSP under src/dsp/deepfist/:
  - DeepFistResampler   : 48k->3200 Hz decimator (121-tap, 1440 Hz), exact
                          match to the reference filter
  - DeepFistSpectrogram : STFT 256/hop48, 65 bins 400-1200 Hz, log1p,
                          per-window unbiased standardize; from-scratch FFT
  - DeepFistCtc         : greedy CTC decode
  - DeepFistModel       : ONNX Runtime session + sidecar token table
  - NeuralCwDecoder     : streaming adapter (rolling 6 s window, re-decode
                          every ~1.5 s), same shape as the classic CwDecoder

Vendors prebuilt CPU ONNX Runtime 1.20.1 (MIT) under third_party/onnxruntime
(headers + license committed; DLL/model git-ignored, code-only PR). CMake
links ORT, ships onnxruntime.dll + models/ next to the exe.

Verified: scratch/test_neural_cw decodes known clips (3200 Hz direct path +
48 kHz decimator path) with a 0-mismatch exact match to the Python-ONNX
reference on CQ TEST DE K3E / AGN / 5NN 001 K / CQ OK8G / R FB TU 73.

Licensing: DeepFist MIT (c) Brent Crier + ONNX Runtime MIT, both permissive
inside Lyra's GPLv3; no AGPL (DeepCW/HamNoise) code used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds DeepFist as a second, selectable CW decode engine beside the classic
fldigi port:

- WdspEngine: NeuralCwDecoder member + cwEngine_ switch (0=Classic,1=Neural),
  Q_PROPERTY cwDecodeEngine + cwNeuralAvailable, cwNeuralText signal. The
  CW-mode-gated audio tap routes cwMonoBuf_ to the selected engine only. The
  model is lazy-loaded on first switch to Neural (from <exeDir>/models or
  $DEEPFIST_MODEL_DIR); engine flips to Neural only after the ONNX session is
  fully built, so the audio thread never touches a half-constructed session.
- CwDecoderPanel.qml: Classic/DeepFist selector + model-status line. The neural
  engine's full 6 s-window decode shows in replace mode (cwNeuralText); the
  fldigi-only knobs (speed/BW/tracking/squelch/signal) are hidden for Neural,
  which is self-tuning.
- Prefs: persist cwDecodeEngine; restored on panel load.
- NOTICE.md / CREDITS.md: DeepFist (MIT) + ONNX Runtime (MIT) attribution.

Not yet compiled in the full app: the v0.17.0 tree currently fails to
configure because the installed Qt 6.11.1 kit is missing the Qt::SerialPort
module (required by unrelated serial CW-key code). The DeepFist decode core is
independently verified (see prior commit). Full-app build + live UI check are
pending the SerialPort module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Positive confirmation in the app log (%APPDATA%/N8SDR/Lyra-cpp/logs) that the
ONNX model loaded, alongside the existing warning on failure. Verified via a
launch smoke test: app starts with onnxruntime.dll, panel restores the Neural
engine, and the model loads from the deployed models/ dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…decode)

The neural engine emitted the full 6 s window every 1.5 s in replace mode, so
the panel only showed ~6 s of text and updated in slow chunks. Replaced that
with the reference live-decode approach (deepfist/scripts/tci_decode.py):

- Frame-timed commit: each decoded char carries a frame time (greedyCtcFrames);
  it is emitted exactly once when its audio settles (~1.3 s old), streaming
  characters into an accumulating transcript instead of replacing a window.
- Re-decode every ~0.4 s (was 1.5 s).
- Inference moved to a worker thread so the faster cadence never stalls the
  audio callback; it idles when no new audio arrives (Neural not active).
- NeuralCwDecoder.onText now delivers incremental committed text; the panel
  appends it (both engines now stream into the same transcript).

DeepFistModel split into infer() (raw log-probs) + decode3200(); the one-shot
harness still matches the reference exactly (7/7). New test_neural_cw_stream
harness plays a 48 kHz WAV through the real streaming path: on DeepFist's real
25 s off-air capture it streams "…ARY 5NN … K6ZH TEST K7CO K7CO 5NN 6",
matching the reference decode of that clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DeepFist's shipped model is now exp15, trained on CONDITIONED single-signal
audio, so inference must condition too (train == inference). Ports diddle's
verified live Rust conditioner (cw_neural.rs::Conditioner) to C++:
AGC (unit RMS) -> tone AFC (4096-pt FFT, 400-1200 Hz) -> complex downconvert +
two cascaded 1-pole LPFs (~90 Hz) -> recenter to 600 Hz -> peak-norm. Runs on
the 3200 Hz window inside DeepFistModel::infer, before the spectrogram.

New src/dsp/deepfist/DeepFistConditioner.{h,cpp} (incremental-phasor form
matching the Rust numerics). Verified: C++ conditioner+exp15 matches the Rust
reference (diddle cargo example) EXACTLY on 5 clean clips; streaming the real
audio_001 off-air capture now copies "WP3Z TEST WP3Z ..." (exp9 was patchy).
Marginal fast/weak clips (untitled) sit at the model's decision boundary where
Rust/Python/C++ all differ slightly — inherent model instability, not a port
bug. Model artifact (exp15) refreshed in models/ (git-ignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports diddle's dsp/rescore.rs + scp.rs to C++: for each callsign-shaped word in
a decoded window, swap in nearby MASTER.SCP candidates and score each against
the model's CTC log-prob lattice (ctcNll = full-sequence CTC loss); the call
the audio actually supports wins. Fixes garbles text edit-distance can't (WP3B
vs WP3Z). Confident = margin >= 3 nats over the best different candidate.

- src/dsp/deepfist/DeepFistRescore.{h,cpp}: ctcNll (log-space CTC forward),
  is_call_shape, run/span finding, rescoreCalls.
- src/dsp/deepfist/DeepFistScp.{h,cpp}: MASTER.SCP loader + edit-distance
  candidate generation.
- NeuralCwDecoder: loads MASTER.SCP beside the model; runs the rescorer per
  window on the worker thread; emits confident verdicts via onCalls.
- WdspEngine: cwNeuralCall signal. CwDecoderPanel: a "Calls" readout of
  confirmed/corrected calls (deduped, ranked by confirmation count, green =
  confirmed / amber = corrected), click -> His Call.
- MASTER.SCP bundled next to the exe (git-ignored, like the model).

Verified: test_rescore matches PyTorch F.ctc_loss (1e-4) + flips A1B->A1C;
streaming the real audio_001 capture confirms WP3Z at 5.4-9.1 nats (matches
the DeepFist reference's 5.4-9.3). Runtime log: "SCP rescorer: 50038 calls".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	CMakeLists.txt
#	src/wdsp_engine.h
LYRA_CW_WAV now writes UNCLAMPED 32-bit float (was clamped 16-bit) so the true
decoder-input level/envelope are visible — a clamped capture hid that the live
RX audio runs at ~x21 full scale (+19 dB makeup gain), which looked like
clipping but is fine (the conditioner AGC normalizes it). test_neural_cw and
test_neural_cw_stream now read fmt=3/32-bit float WAVs too.

Used to diagnose the "horrible live decode": the audio is clean (decodes to
KG4CB when a window is well-aligned); the streaming garble is exp15 being
alignment-sensitive, and diddle's reference decoder garbles the same capture
identically — i.e. a model limitation, not a Lyra bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… finding

Single resume anchor for the feat/deepfist-cw work: architecture/file map,
build+run+verify procedure, environment gotchas, and the resolved "horrible
live decode" investigation — the audio is clean and the Lyra port is faithful
(diddle's reference garbles the same capture identically); the garble is exp15
alignment-sensitivity, a DeepFist model limitation to fix in that project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…no chars)

Ports DeepFist's live-copy-display fix (HANDOFF §18.23): the neural decoder was
hallucinating streams of junk on empty frequencies because Lyra's CW AGC makes
a steady ~700 Hz artifact tone whose LEVEL is indistinguishable from a real
signal. KEYING separates them. Per-window keying ratio = p90/p10 of the locked
tone's baseband envelope (DeepFistConditioner::keyingRatio); below 12 -> skip
the decode, emit nothing, just advance the commit boundary.

Chose DeepFist's keying_ratio (tools/squelch.py, VALIDATED on real Lyra x21
captures: dead air ~4, keyed CW 12-600+) over diddle's keying_depth, which gives
a flat ~0.68 on Lyra audio and doesn't separate. Verified on the KG4CB capture:
dead-air windows (score 3.5-5.5) now emit NOTHING; signal windows (12-671)
decode. Real-signal copy quality on hand-sent CW is unchanged (still the DeepFist
training frontier; exp16 not yet exported to ONNX).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…del)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nfig)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CTC blank penalty was env-var-only (LYRA_CW_BLANKPEN, invisible). Make it a
visible, live, persisted control so it can be tuned/A-B'd on the same signal
without a relaunch:

- NeuralCwDecoder: blank penalty is now a live atomic (setBlankPenalty /
  blankPenalty), read by the worker each decode; initial value still seeds from
  LYRA_CW_BLANKPEN.
- WdspEngine: cwBlankPenalty property + setCwBlankPenalty (clamped 0..5).
- Prefs: persist cwBlankPenalty; restored on panel load.
- CwDecoderPanel: a "Blank penalty" slider (0..5, step 0.5) with a live readout,
  visible when the DeepFist engine is active.

Higher = recover more dropped chars on weak audio; more spurious chars on strong
signals. Default 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Blank-penalty slider now snaps to 0.5 steps (snapMode SnapAlways).
- Space on long pause: the keying gate emits ONE space when a keyed-CW pause
  starts (idleGap), so copy across gaps doesn't run together (mirrors
  tci_decode's idle-gap space).
- Removed the green "Calls" chips (rescorer verdicts) that overlapped the decode
  window; also unwired neuralCw_.onCalls so the per-window callsign-DB rescore
  no longer runs (CPU saved). Double-click a call in the transcript -> His Call
  still works (built into the decoded-text area).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- WPM: the neural engine now estimates RX speed from the keying envelope
  (DeepFistConditioner::estimateWpm — threshold the tone envelope, dot = short
  on-elements, WPM = 1200/dot_ms) and emits it on the existing cwRxWpmChanged
  surface, so the header "wpm" populates for DeepFist too (was Classic-only).
- Renamed the blank-penalty slider to "Sensitivity" with a Low/Med/High readout
  and a plain-language hint ("Lower = clean copy … Higher = pull weak code").
- RBN highlight: decoded call-shaped words that are currently spotted
  (SpotStore::isSpotted, exposed to QML as Spots) are coloured green in the
  transcript (RichText) — a real, active station stands out as it's copied.
  Re-highlights on Spots.changed().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aming)

The double-click handler used selectWord()/selectedText, which the RichText
transcript switch and the frequent DeepFist text updates (onTextChanged reset
cursorPosition) could disturb -> empty/wrong call. Extract the call-shaped word
straight from decodedText at the click position instead (wordAt); the right-
click menu uses the same. No dependency on selection state or cursorPosition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Removed the "• neural (rolling 6 s window)" reference next to the engine
  selector (kept only the "model not found" warning).
- Sensitivity indicator now reads Low (<=0.4) or High, no "Med".

Note: AR (+) / BT (=) prosigns are misdecoded by exp16 (BT -> X, AR dropped) —
a DeepFist training gap, not a Lyra issue; needs more prosign data in training.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- DeepFist neural copy now shows BT/AR prosigns as <BT>/<AR> (angle notation,
  matching <SK>/<KN> and the classic decoder) instead of the raw = / + aliases.
  Display-only: a misread prosign still shows what the model decoded.
- Sensitivity (blank-penalty) slider is now bipolar (-2..+5, default 0):
  negative suppresses stray/doubled characters on strong signals, positive
  pulls weak code out of the noise. Signed numeric readout + numbered ticks
  (new LyraSlider showTicks/showTickNumbers). Clamps widened in Prefs and
  WdspEngine to match.
- CwWavCapture (LYRA_CW_WAV): re-patch the RIFF/data size fields every ~1 s of
  audio so a capture stays a valid, playable WAV even when the process is
  killed before the static-local destructor runs (Qt app close doesn't
  guarantee it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `SPDX-License-Identifier: MIT` + Brent Crier copyright to each
src/dsp/deepfist/ file. DeepFist is MIT (© Brent Crier) integrated into
Lyra's GPLv3+ aggregate — permissive-into-copyleft, already documented in
NOTICE.md/CREDITS.md. Per-file headers make the provenance unambiguous now
that these files carry an explicit license marker even if copied out of tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- build3.ps1: per-machine build helper (like _b*.bat / _run_lyra_vac1.bat)
- .claude/scheduled_tasks.lock: transient runtime lock
- models/*.bak: local rollback copies of swapped-in models (the exp16 .bak
  guarding an exp27 swap) — 13 MB binaries that must stay out of git like the
  live .onnx artifacts already do

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DEEPFIST_HANDOFF.md was session-handoff notes — a dated commit list, a
resolved investigation log, a TODO list, and references to private local
paths / a sibling repo. Replace with docs/DEEPFIST.md: a focused,
public-appropriate integration reference (what it is + license/attribution,
architecture & file map, model contract, front-end recipe, build/run, and
verification) with the internal cruft and private paths removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pot color

- Decode pane: auto-scroll now triggers off the ScrollView viewport's
  contentHeight instead of the TextArea's. For RichText the TextArea height
  settled a layout pass late, so the newest (just-wrapped) line only appeared on
  the following update. Scrolling when the Flickable's contentHeight changes
  includes the new line immediately.
- Sensitivity (blank penalty) slider narrowed to a symmetric -1..+1 (ticks at
  -1, -0.5, 0, +0.5, +1); Prefs + WdspEngine clamps narrowed to match so a
  stale persisted value can't sit past the slider ends.
- RBN-confirmed callsign highlight brightened (#5fe0a0 -> #5fffa8) so a spotted
  station stands out more in the transcript.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the author's callsign (N9BC) to the DeepFist copyright line everywhere it
appears — the 16 src/dsp/deepfist/ SPDX headers, NOTICE.md, CREDITS.md,
docs/DEEPFIST.md, and the design spec — matching the "Brent Crier (N9BC)"
form already used elsewhere in the repo. (DeepFist's own MIT LICENSE is updated
in the separate DeepFist project.)

Also fix a stale figure in docs/DEEPFIST.md: the Sensitivity slider range is
-1..+1 (narrowed this session), not the old -2..+5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@n9bc

n9bc commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on the model artifacts (not in this PR by design):

The neural engine needs three runtime files that are intentionally git-ignored and not part of this diff:

  • models/deepfist.onnx + models/deepfist.onnx.json (the trained model + sidecar)
  • models/MASTER.SCP (callsign database for the rescorer)
  • third_party/onnxruntime/lib/onnxruntime.dll (+ import lib)

These are large binaries meant to ship via the installer / release asset, not committed to git (same pattern as other runtime binaries). CMake's POST_BUILD copies whatever is in models/ next to the exe.

What this means for review: the code builds and links fine without them (only the API headers under third_party/onnxruntime/ are committed), so this PR is fully reviewable as-is. They're only needed to run the DeepFist engine at runtime. See third_party/onnxruntime/README.md for repopulating lib/, and docs/DEEPFIST.md for the model contract. Happy to sort out the delivery mechanism (installer bundle vs. release asset vs. Git LFS) separately — just didn't want the absence of the binaries to look like a gap.

brentc316 and others added 3 commits July 15, 2026 07:01
Record that the DeepFist engine is portable (non-Windows ONNX path already
present; DSP is plain C++/STL) and ONNX Runtime ships for Linux/macOS — so it
adds no new Windows lock-in. The blocker to a Linux/macOS build is the rest of
Lyra (WDSP DLL, WinSock2; os_compat.h POSIX shim not yet wired in), not DeepFist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rvest)

Adds Auto as a third RX CW engine: runs Classic + DeepFist unmodified and a
thin engine-agnostic CwArbiter hands display ownership to whichever is
trustworthy, using DeepFist's keyingRatio as the fade detector. Never goes
silent; fallback copy is subtly source-marked. Second layer (CwCaptureHarvester)
passively harvests a training corpus from arbiter events with trust-tiered
labels, plus an RBN->SCP loop feeding verified calls back to the rescorer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TDD, bite-sized tasks: CwArbiter ownership state machine (pure unit +
test_cw_arbiter target), onKeying feed from NeuralCwDecoder, WdspEngine
cwEngine_=2 fan-out to both decoders, and the QML Auto selector with subtle
source-marked (dimmed) fallback text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brentc316 and others added 26 commits July 15, 2026 13:52
…s engine switches

Two-tier call colouring in the decode pane: any token that is an exact
MASTER.SCP member renders amber (known-real call — DeepFistScp::contains,
O(log n), surfaced as Q_INVOKABLE WdspEngine::cwCallKnown); an RBN/cluster
spot still upgrades to bright green + bold (live, verified). A strict
shape-only fallback (1-3 letter suffix) covers sessions where the SCP never
loads; SCP arrival re-renders the transcript so earlier copy gains its amber.
Future validation sources (Phase-3 RBN-confirmed local list, SDRLogger+
worked-before sync) OR into cwCallKnown without touching the QML.

Engine switches no longer clear the transcript — the operator A/Bs engines
on the same signal and tunes each; a " • " seam marks the switch point and
the Clear chip still empties on demand. Operator-verified on air (NA2DX
amber; AA0TT DE run-together no longer false-ambers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ScpLocal store (capped, aged, atomic rewrite; TDD), DeepFistScp::addCalls
merge at model load, WdspEngine ownership + cwNoteConfirmedCall +
Prefs.cwLearnCalls (opt-in per spec §6.6), QML word tap + Learn chip.
Also folds in the Phase-1 escape bugfix: prefs engine clamp 0..1 → 0..2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… list

Also: restore a persisted Auto engine at startup (Prefs.cwDecodeEngine === 2
now survives restart, not just DeepFist), and wire ScpLocal.cpp/h into the
main lyra target — it was only ever linked into the standalone
test_scp_local unit test, leaving WdspEngine::cwNoteConfirmedCall unresolved
at link time.
… placement

clearDecoded() also clears the Learn tap's pending word (no cross-Clear word
concatenation); the setCwDecodeEngine doc block moves back to its function
(the two Phase-3 helpers had landed between them); ensureCwScpLocal's comment
now states the privacy boundary (the file, not the mkpath'd dir — load()
never creates it, note() is QML-gated by Prefs.cwLearnCalls).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CwCaptureHarvester (Qt-free core): 60s 3200Hz ring fed from the CW tap via a
parallel decimator, two v1 triggers (arbiter DeepFist->Classic fade ->
hard_negative; RBN-confirmed call -> gold_rbn), per-tier debounce, post-roll,
2GiB retention. Segments = int16@3200 peak->20000 WAV + JSON sidecar (tier,
keying trace, both texts, wpm, peak) — matching deepfist's curation-tool
conventions; windowing/labeling stays offline in the deepfist repo. New
CwArbiter::onOwnerChange callback; cwCaptureEnabled pref (opt-in per spec
§6.6); Harvest chip. SILVER/agreement/SCP-margin tiers deferred (schema:1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RBN/cluster spots often land 10-60 s after a station starts up, so a
cleanly-decoded call could finish its live tapWord() check before its
spot existed - the transcript turned green retroactively but the learner
never re-looked (W2DON on air, 2026-07-16). Re-scan the recent
transcript whenever the spot bank changes; ScpLocal::note() dedupes so
repeat scans are free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 3-char decode garble ("M5N" on air, 2026-07-16) went green because it
exactly matched a call spotted on a different frequency - isSpotted()
answers for the whole spot bank. The decode panel copies the station
under the cursor, so green highlight + Learn now use isSpottedHere():
same lookup, but the spot must sit within 1.5 kHz of the tuned CW
carrier (same carrier convention as activate()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ention

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
enforceCap() sorted segment paths lexicographically to pick deletion
order. Filenames are "<tier>_<epoch>_<seq>.(wav|json)", and the tier
string is the primary sort key, so every gold_rbn_* segment (highest
trust) sorted before every hard_negative_* segment ('g' < 'h') and was
evicted first regardless of actual age. Parse the age key from the
trailing <epoch>_<seq> fields instead (the tier prefix itself contains
underscores — "hard_negative", "gold_rbn" — so it's parsed from the end
of the stem) and sort ascending by (epoch, seq), tier-independent.

Adds a mixed-tier retention test that writes an older hard_negative
segment and a newer gold_rbn segment, forces the cap to fit exactly one
pair, and asserts the older pair is evicted regardless of tier name.
Verified the new test fails against the pre-fix code (evicts the newer
gold_rbn pair instead) and passes after the fix; full test_cw_harvest
suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, cwCaptureEnabled pref

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Critical defect found in review of 3cbdc12: ~WdspEngine() joined the
harvest pump thread and called closeRx1(), but never stopped
neuralCw_'s internal worker thread. C++ destroys members in reverse
declaration order, and in wdsp_engine.h the harvest members
(cwHarvestRing_/cwHarvestDecim_/cwHarvester_) are declared AFTER
neuralCw_ and cwArbiter_ — so they were destructing BEFORE
~NeuralCwDecoder() ever ran (which is where its worker thread actually
stops). That worker fires onText/onWpm/onKeying synchronously off its
own thread (wired in setCwDecodeEngine), and those callbacks
dereference cwHarvester_ (when cwCaptureOn_ is true) and
unconditionally call cwArbiter_.updateKeying — a decode callback in
flight during teardown could land on an already-destroyed
harvester/arbiter.

Fix: explicitly stop+join neuralCw_'s worker (new public
NeuralCwDecoder::stop(), a thin wrapper over the existing private
stopWorker(), which is already idempotent via
running_.exchange(false)) at the top of ~WdspEngine(), before the
harvest-pump join and before any harvest/arbiter member is touched.
This covers cwArbiter_ for free, since the same worker thread is its
only caller. Header member order is left untouched (lower-risk fix
than reordering declarations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Learn re-scan re-confirms every spotted call still in the transcript
on every spot-bank change, so a single call minted a near-identical
gold_rbn segment every debounce window (observed on air: 5x W1AW/3 in
3 minutes). Per-call dedupe map with a configurable goldRepeatSec
window (default 600 s); tier debounce unchanged. Test 10 covers
same-call suppression, different-call pass-through, and window expiry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rescanLearn only looked at the last 1500 chars while displayHtml greens
everything it renders (6000-char cap) - on air, KT4K sat visibly green
but permanently unlearnable once it drifted past the window. Scan the
full transcript; the per-call gold dedupe and ScpLocal note() throttle
make repeat scans free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three on-air failures shared one root cause: the learn decision was
re-derived at a different moment than the render that showed the
operator a green call, and each variant lost calls to timing:
- tap at word completion missed spots arriving seconds later (W2DON)
- a 1500-char rescan window missed calls still visibly green (KT4K)
- rescan-on-spot-change missed spots evicted from the 200-cap bank
  before the next bank event (KA1ULN)

displayHtml''s green pass is the only place green-ness is guaranteed
observed, so the learn note now fires there, memoized per call per
10 min (matching the harvester''s gold dedupe window). The invariant
the operator actually expects - "if I saw it green, it''s learned" -
now holds by construction. tapWord/pendingWord/rescanLearn machinery
removed; spotRev re-render on spot-bank changes covers the late-spot
case since the render IS the learner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- scratch/test_neural_raw.cpp - deterministic tiled-window decode of a
  WAV slice through the real DeepFist pipeline (per-window keyingRatio
  probe + blank-penalty sweep); root-caused the presence-gate fade
  limitation and stays the replay tool for LYRA_CW_WAV captures.
- scratch/gap_probe.cpp - keying-envelope on/off gap histogram.
- scratch/rbn_fetch.py - stdlib-only RBN daily-archive fetcher/filter
  producing label CSVs for IQ-capture training sessions.
- scratch/rbn_20260708_cwt19z.csv - proven sample output (CWT 19z,
  19759 spots / 520 calls on 20m).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ev-only (env-gated)

Learn and Harvest were opt-in CW-panel chips.  A chip nobody understands
raises support questions ("is Lyra recording me?"), and Harvest is really a
training-data tool, not a user feature.  Remove both chips:

- Learn (RBN-confirmed calls -> local scp_local.txt, text only, never audio)
  is now on by default for everyone, gated by WdspEngine.cwLearnEnabled;
  disable with LYRA_CW_LEARN=0.  Reset by deleting scp_local.txt.
- Harvest (trust-tiered CW audio capture for DeepFist training) is developer-
  only, gated by WdspEngine.cwHarvestEnabled; off unless LYRA_CW_HARVEST=1.

Both flags are CONSTANT, read once at construction.  The Prefs.cwLearnCalls
and Prefs.cwCaptureEnabled properties are removed entirely (no dead settings).
gold_rbn capture still rides the Learn tap but only writes when Harvest is
armed, so no audio is ever written without the developer opting in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	src/qml/CwDecoderPanel.qml
… branch

Brings the complete DeepFist feature set up to date with origin/main so
PR N8SDR1#7 can carry it all: base neural decoder + Auto arbiter (hybrid) +
RBN->SCP Learn + training Harvest (dev-gated) + render-time learn + the
Learn/Harvest chip retirement.  Build verified; merges cleanly into main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@n9bc n9bc changed the title DeepFist neural CW decoder: optional second RX CW engine (ONNX Runtime) DeepFist neural CW decoder: DeepFist + Auto (hybrid) RX CW engines, with confirmed-call learning (ONNX Runtime) Jul 18, 2026
@n9bc

n9bc commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Hi Rick — PR #7 is rebased up to date with main (v0.20.0) and shows clean/mergeable now.

Heads-up on scope: I folded the rest of the DeepFist work into this PR rather than splitting it, so it can land as one coherent unit. Beyond the original second-engine decoder, it now also includes:

  • Auto — a hybrid arbiter that runs Classic + DeepFist and shows the more-confident one
  • Confirmed-call learning — on by default, text-only (callsigns, never audio), local + capped, opt out with LYRA_CW_LEARN=0
  • A training-capture path — developer-only, no UI, off unless LYRA_CW_HARVEST=1

I flagged the privacy-relevant bits (Learn / Harvest) up front in the description since those are the ones worth your eyes. Classic stays the default; DeepFist/Auto are opt-in.

Two things I'd value your call on whenever you get to it:

  1. Model-artifact deliverydeepfist.onnx + MASTER.SCP are git-ignored; installer bundle vs. release asset vs. Git LFS?
  2. Whether one combined PR works for you, or you'd rather I split the learning/capture parts back out into a follow-up.

No rush — review at your pace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants