Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Tell GitHub linguist to ignore reference material and docs
reference/** linguist-vendored
docs/** linguist-documentation

# Preserve the exact upstream WDSP snapshot, including generated-table
# whitespace, while keeping patch checks useful for AetherSDR-owned files.
third_party/wdsp/** linguist-vendored whitespace=-trailing-space,-space-before-tab
14 changes: 12 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ if(PkgConfig_FOUND)
pkg_check_modules(PORTAUDIO portaudio-2.0)
endif()

# FFTW3 (required for NR2 spectral noise reduction)
# FFTW3 (required by vendored WDSP; also used by NR2 spectral noise reduction)
# Windows: run scripts/setup/setup-fftw.ps1 first to download prebuilt DLLs
# Linux: apt install libfftw3-dev
# macOS: brew install fftw
Expand All @@ -161,7 +161,7 @@ if(WIN32)
set(FFTW3_LIBRARIES "${FFTW3_ROOT}/lib/fftw3.lib")
set(FFTW3_DLL "${FFTW3_ROOT}/bin/libfftw3-3.dll")
else()
message(WARNING "FFTW3 not found. Run scripts/setup/setup-fftw.ps1 to download it. NR2 will use fallback FFT.")
message(WARNING "FFTW3 not found. Run scripts/setup/setup-fftw.ps1; vendored WDSP requires it.")
endif()
else()
if(PkgConfig_FOUND)
Expand Down Expand Up @@ -489,6 +489,7 @@ endif()
# Sources
set(CORE_SOURCES
src/core/backends/flex/FlexBackend.cpp # aetherd RFC step 2.2 (§5.5)
src/core/dsp/WdspChannel.cpp
src/core/AppSettings.cpp
src/core/GpuSelector.cpp
src/core/AgcTCalibrator.cpp
Expand Down Expand Up @@ -1073,6 +1074,10 @@ endif()

qt_add_resources(RESOURCES resources/resources.qrc)

# WDSP 2.00 is an engine-only dependency for raw-IQ radio backends. Its C API,
# platform shims, and FFTW dependency remain private to aethercore.
add_subdirectory(third_party/wdsp EXCLUDE_FROM_ALL)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This add_subdirectory is unconditional, and third_party/wdsp/CMakeLists.txt does if(NOT FFTW3_FOUND) message(FATAL_ERROR ...). Combined with the unconditional target_link_libraries(aethercore PRIVATE aether::wdsp), this promotes FFTW from optional-with-fallback (the old NR2 will use fallback FFT warning path) to a hard configure-time requirement on all platforms — a machine without FFTW that used to configure and build now fails at CMake configure.

That's a reasonable consequence since WDSP genuinely needs FFTW, but it's a behavior change worth making deliberate: either guard this on if(FFTW3_FOUND) (and skip the raw-IQ backend when absent), or update the build docs to state FFTW is now mandatory. CI runners provide it, so this won't block here.


# aetherd RFC step 1: engine static library — src/core + src/models
# plus the vendored C/C++ sources those engine files compile against
# (rnnoise, ggmorse, RADE, specbleach; mosquitto/rtmidi are appended below in
Expand All @@ -1087,6 +1092,7 @@ add_library(aethercore STATIC
${RADE_SOURCES}
${SPECBLEACH_SOURCES}
)
target_link_libraries(aethercore PRIVATE aether::wdsp)

add_executable(AetherSDR
src/main.cpp
Expand Down Expand Up @@ -2227,6 +2233,10 @@ endforeach()
# Standalone DSP smoke tests. Built alongside the main target so they share
# the same toolchain and warning flags. Run manually with ./build/<target>.

add_executable(wdsp_channel_test tests/wdsp_channel_test.cpp)
target_link_libraries(wdsp_channel_test PRIVATE aethercore)
add_test(NAME wdsp_channel_test COMMAND wdsp_channel_test)

add_executable(client_eq_test
tests/client_eq_test.cpp
src/core/ClientEq.cpp
Expand Down
17 changes: 11 additions & 6 deletions THIRD_PARTY_LICENSES
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,20 @@ facts. The consulted facts are:
Source: https://github.com/dl1ycf/pihpsdr (src/old_protocol.c)


1. WDSP — Spectral Noise Reduction (NR2)
-----------------------------------------
The SpectralNR module (src/core/SpectralNR.h, SpectralNR.cpp) is derived from
the WDSP library's emnr.c, implementing the Ephraim-Malah MMSE-LSA noise
reduction algorithm with OSMS noise floor tracking.
1. WDSP 2.00 — engine-side SDR DSP
----------------------------------
Bundled as an isolated static library at third_party/wdsp/ from the exact
TAPR/OpenHPSDR-wdsp revision below. AetherSDR uses the complete WDSP channel
API behind src/core/dsp/WdspChannel for raw-IQ radio backends. Three local
teardown fixes are documented in third_party/wdsp/AETHERSDR-PATCHES.md.

The older SpectralNR module (src/core/SpectralNR.h, SpectralNR.cpp) is derived
from WDSP's emnr.c and remains independent of the bundled channel wrapper.

Copyright (C) 2015, 2025 Warren Pratt, NR0V
Copyright (C) 2011-2026 Warren Pratt, NR0V, and named contributors
License: GPL-2.0-or-later
Source: https://github.com/TAPR/OpenHPSDR-wdsp
Commit: 584e8aca5ba1c4c6bc66fc0cc164ce567c8ba1e3


2. RNNoise — Neural Network Noise Suppression (RN2)
Expand Down
62 changes: 44 additions & 18 deletions docs/architecture/aetherd-hl2-backend-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,27 @@ Hl2Backend : IRadioBackend
│ EP6 IQ ingest; owns the socket + its RX thread)
│ emits: iqBlockReady(complex<float> block), linkUp/linkDown, dropStats
│ accepts: setRxFrequencyHz(), setSampleRate(), setLnaGainDb() (C&C)
└── Hl2RxDsp (engine-side RX chain on its own thread)
└── Hl2Dsp (engine-side fixed-block worker; one RX channel in Phase 1)
in: raw IQ blocks from MetisClient
owns: WdspChannel RX → complete IQ-to-audio chain
out: demodulated PCM → audio outlet
FFT magnitude bins → spectrum/waterfall outlet
S-meter level → meterUpdate
```

`MetisClient` and `Hl2RxDsp` are a direct in-tree port of the proven
`prototypes/hl2/hpsdr.py` primitives (register map, framing, DC-removed FFT).
The DSP building blocks the chain needs — **liquid-dsp, FFTW, and the `WfmDsp`
pattern — are already in the tree** (`docs/aetherd-headless-engine-design.md:309-318`),
so `Hl2RxDsp` is assembly of existing parts, not new DSP.
`MetisClient` ports the proven `prototypes/hl2/hpsdr.py` register map and
framing primitives. `Hl2Dsp` replaces the spike's Python-only signal path with
the production engine boundary described below.
The modulation chain is not assembled from unrelated AetherSDR and WDSP stages.
The backend owns one complete, preplanned `WdspChannel` RX chain. A read-only
FFTW spectrum tap may observe raw IQ, but it is outside the audio path and may
not mutate it. See `docs/architecture/wdsp-integration.md` for the lifetime,
allocation, reconfiguration, and future TX contract.

The wire and DSP threads meet through a bounded SPSC IQ queue. UDP ingest never
waits for DSP. Queue starvation becomes a counted zero-IQ gap; overflow drops a
whole oldest block and records the lost sample interval. Both conditions are
observable backend metrics, not silent discontinuities.

Everything in this box lives under `src/core/backends/hl2/`, which is **below the
seam**: EB3 never inspects its includes and it may use any vendor/DSP header
Expand All @@ -91,7 +100,7 @@ needs. Adding the HL2 tree touches **no** ratchet baseline row and requires **no
## 3. Seam mapping

Each `IRadioBackend` verb/signal → HL2 mechanism. "Engine DSP" means the field
configures `Hl2RxDsp`, not the radio — the fundamental HL2 difference from Flex,
configures `Hl2Dsp`, not the radio — the fundamental HL2 difference from Flex,
where the same field would be a wire command.

### Intents DOWN
Expand All @@ -102,8 +111,8 @@ where the same field would be a wire command.
| `disconnectRadio()` | Send Metis stop (`EF FE 04 00`), stop threads in reverse order. |
| `isConnected()` | `MetisClient` link state (EP6 flowing). |
| `setSliceFrequency(id, hz)` | `MetisClient::setRxFrequencyHz` → RX1 NCO `C0=0x04`. (Phase 1: single slice, `id` fixed.) |
| `setSliceMode(id, mode)` | **Engine DSP** — selects the `Hl2RxDsp` demodulator (AM/SSB/CW/FM/…). HL2 ships raw IQ, so mode is purely engine-side. |
| `setSliceFilter(id, lo, hi)` | **Engine DSP** — sets the demod passband in `Hl2RxDsp`. |
| `setSliceMode(id, mode)` | **Engine DSP** — configures the backend-owned RX `WdspChannel` (AM/SSB/CW/FM/…). HL2 ships raw IQ, so mode is purely engine-side. |
| `setSliceFilter(id, lo, hi)` | **Engine DSP** — configures the RX `WdspChannel` passband on the control path. |
| `setKeying(bool)` | **Guarded no-op** — `capabilities().canTransmit == false`, so the engine TX guard (RFC §6) denies keying above the seam; the backend never keys. (TX is Phase 2+.) |
| `invokeExtension(...)` | Stub, exactly like FlexBackend (FlexBackend::invokeExtension): if `requestId != 0`, emit `extensionError` immediately. HL2 advertises **no** extension namespaces (see §4). |

Expand All @@ -113,7 +122,7 @@ where the same field would be a wire command.
|---|---|
| `connected` / `disconnected` / `connectionError` | `MetisClient` link transitions. |
| `sliceChanged(id, SliceDelta)` | Emitted from the backend's **own authoritative state**. HL2 has no status wire echoing frequency/mode back (unlike Flex's `decodeSliceStatus`); the engine is the source of truth, so the backend reflects the settings it applied. |
| `meterUpdate("s-meter", v)` | `Hl2RxDsp` S-meter (dBFS → dBm via a calibration constant; TBD, refine like `flex-meter-learnings.md`). |
| `meterUpdate("s-meter", v)` | `Hl2Dsp` S-meter (dBFS → dBm via a calibration constant; TBD, refine like `flex-meter-learnings.md`). |
| `panCenterBandwidthChanged(panId, ctr, span)` | Center = RX1 NCO MHz; span = sample-rate MHz. Backend-emitted from its own config. |
| `panRfGainChanged(panId, gain)` | LNA gain (−12…+48 dB) reflected back. |
| `spectrumFrameReady` / `waterfallRowReady` / `audioFrameReady` | The DSP outlets — **but see §5 for how these actually reach the UI in Phase 1** (the interface's `QByteArray` data-plane signals are not the live path yet). |
Expand Down Expand Up @@ -188,7 +197,7 @@ types flowing off `PanadapterStream` and reached via `RadioModel::panStream()`
The concrete seam frame formats are **step-4 work, not final**
(`IRadioBackend.h:210-216`; `aetherd-headless-engine-design.md:251-266`).

**Proposal (Phase 1 relays existing types):** `Hl2RxDsp` produces the **same**
**Proposal (Phase 1 relays existing types):** `Hl2Dsp` produces the **same**
in-tree frame types the UI already consumes — `QVector<float>` dBm spectrum bins,
`QByteArray` 24 kHz PCM. Rather than invent a step-4 format now, expose them the
way RadioModel already consumes Flex's: the cleanest minimal option is a small
Expand All @@ -206,7 +215,9 @@ data plane, and multi-RX all wait for their respective later steps.

## 6. Explicitly out of scope for Phase 1

- **Transmit.** `canTransmit=false`; `setKeying` is a guarded no-op. TX needs the
- **Transmit.** `canTransmit=false`; `setKeying` is a guarded no-op. The WDSP
foundation can construct a TX channel for deterministic tests, but that is
deliberately disconnected from all radio I/O. Live TX needs the
MOX bit + a TX-IQ data plane + the engine TX arbiter (RFC §6 / step 4-arbitration)
and is deliberately deferred — it is also the only path that can key a radio, so
keeping it out of Phase 1 is the safe default.
Expand Down Expand Up @@ -246,8 +257,14 @@ but clean-room is chosen to match project convention.
**Resolved**
- HL2 is one `IRadioBackend` implementor, RX-only in Phase 1 (RFC §5.5 Q3: one
interface, `canTransmit=false`, guarded `setKeying`).
- DSP is engine-side and encapsulated below the seam; consumers see the same
normalized signals (RFC §5.5, `IRadioBackend.h:50-53`).
- DSP is engine-side and encapsulated below the seam. One backend-owned WDSP
channel owns the complete RX modulation chain; it is never interleaved with
`AudioEngine` stages. Consumers see the same normalized signals (RFC §5.5,
`IRadioBackend.h:50-53`).
- FFT planning, allocation, and rate changes are control-path work. Live rate
changes prebuild a replacement channel and swap it at a block boundary.
- Metis ingest and DSP communicate through a bounded SPSC queue with explicit
gap/overflow metrics; neither the UDP thread nor DSP callback blocks the other.
- Below-seam placement under `src/core/backends/hl2/` — no EB3/tags impact.
- Protocol authority named and consulted clean-room (Principle I / AGENTS.md:334).

Expand All @@ -268,13 +285,22 @@ but clean-room is chosen to match project convention.

## 9. Phasing

1. **1a — MetisClient + Hl2RxDsp in-tree** (port the spike; unit-tested against
captured EP6 fixtures). No RadioModel change yet.
0. **Foundation — complete.** Pinned WDSP 2.00 static library, portability
boundary, RAII channel owner, and deterministic RX/TX/lifecycle tests. This
phase has no radio I/O and cannot key hardware.
1. **1a — MetisClient + Hl2Dsp in-tree** (port the spike; unit-tested against
captured EP6 fixtures and packet-gap cases). One RX `WdspChannel`; no
RadioModel change yet.
2. **1b — backend-selection seam** (Gap A): `makeBackend`, Flex-sink adapter step.
3. **1c — data-plane relay** (Gap B): HL2 DSP frames reach the existing UI path;
first live in-app HL2 panadapter + audio.
4. **1d — capabilities + connect UX**: family reporting, explicit-host connect,
error surfacing.

Later (own steps): discovery-driven selection, multi-RX, transmit, vendor
extensions, migration to finalized step-4 binary frames.
5. **Phase 2 — TX after RX soak.** Add a separate preplanned WDSP TX channel,
audio-to-EP2 buffering, hard operator-intent/MOX gates, and clear-MOX-first
teardown. Prove into a dummy load before advertising `canTransmit=true`.

Later (own steps): discovery-driven selection, multi-RX, vendor extensions,
migration to finalized step-4 binary frames, then the cross-platform receiver
count/rate benchmark matrix in `wdsp-integration.md`.
119 changes: 119 additions & 0 deletions docs/architecture/wdsp-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# WDSP 2.00 integration boundary

**Status:** Foundation implemented; Hermes-Lite 2 backend integration remains a
separate phase. This boundary is engine-only and introduces no radio behavior.

## Decision

AetherSDR uses WDSP as a **whole-channel DSP engine** for raw-IQ radio backends.
It does not select individual WDSP stages and interleave them with
`AudioEngine`. For Hermes-Lite 2, one RX `WdspChannel` will own the complete
IQ-to-audio receive chain; a separate TX `WdspChannel` will later own the
complete audio-to-IQ transmit chain. A spectrum tap may observe the raw IQ, but
must not participate in or mutate the audio DSP chain.

This makes the ownership line testable:

```text
Metis UDP RX -> bounded IQ queue -> HL2 DSP worker -> WDSP RX channel -> PCM outlet
|
+-> read-only FFT/spectrum tap

PCM TX source -> bounded audio queue -> HL2 DSP worker -> WDSP TX channel -> Metis EP2
```

No GUI target, `AudioEngine`, or backend-neutral model may include a WDSP
header. `src/core/dsp/WdspChannel` is the only C++ entry point, and the C API in
`third_party/wdsp/include/aether_wdsp.h` is private to `aethercore`.

## Reproducible source and build

The snapshot is pinned to TAPR/OpenHPSDR-wdsp commit
`584e8aca5ba1c4c6bc66fc0cc164ce567c8ba1e3`, whose subject is
`Release Version 2.00`. `third_party/wdsp/COMMIT` is the machine-readable pin.
The upstream PDF and binary calculus lookup table are not vendored. The latter
is optional in WDSP and its source fallback remains active.

`third_party/wdsp/CMakeLists.txt` produces the position-independent static
target `aether::wdsp`. Only `aethercore` links it, privately. WDSP's FFTW and
thread dependencies, source include directory, warning policy, and platform
definitions do not become public AetherSDR usage requirements.

The source snapshot has three documented teardown corrections. See
`third_party/wdsp/AETHERSDR-PATCHES.md`; refreshes must either find the fixes
upstream or reapply and retest them explicitly.

## Portability layer

All platform adaptation stays under `third_party/wdsp/port/`:

- Win32-style critical sections, semaphores, events, waits, and worker starts
map to pthreads on macOS/Linux.
- aligned allocation is routed through one shim on every platform; counters
make callback allocation and teardown leaks test-visible.
- symbol export annotations and the small CRT compatibility surface are
neutralized at the boundary rather than scattered through WDSP.
- flush-to-zero setup uses AArch64 FPCR or SSE MXCSR as appropriate.
- priority requests are deliberately best-effort; the owning backend chooses
its worker scheduling policy rather than allowing WDSP to own it globally.

The port implements only calls consumed by the pinned snapshot. Adding another
Win32 emulation API requires a concrete WDSP call site and a unit test.

## Lifetime and real-time contract

`WdspChannel` owns one numeric slot in WDSP's process-global channel table.
Construction acquires a unique slot; destruction closes the channel and returns
the slot. Copy and move are disabled so a slot has exactly one C++ owner.

WDSP setup is serialized because its FFTW planner and channel tables are global.
`OpenChannel`, `CloseChannel`, rate changes, mode changes, and filter changes are
control-thread operations. `processIq` is the only real-time operation:

- input and output buffers have fixed, validated sizes;
- no FFT plan or WDSP object is created or destroyed;
- allocation sequence changes are reported as `AllocationViolation`;
- nonblocking starvation is reported as `Underrun`;
- control/callback overlap is rejected as `Busy`.

The current `reconfigure` API requires the caller to stop the feed. For a live
HL2, do not close and rebuild the active channel on the DSP worker. Prepare a
second `WdspChannel` completely on a control thread, then swap owners at a block
boundary and retire the old instance off the real-time path. This also makes
48/96/192/384 kHz changes bounded and rollback-safe.

## HL2 staging and safety

Phase 1 is one receiver and no transmission:

1. packet parsing and loss accounting feed a bounded SPSC IQ queue;
2. a fixed-block DSP worker owns one preplanned RX `WdspChannel`;
3. queue starvation inserts a timestamped zero-IQ gap and increments an
underrun counter rather than blocking the UDP thread;
4. demodulated PCM and spectrum frames leave through backend-owned outlets;
5. `canTransmit` remains false and no MOX-bearing packet is generated.

Phase 2 adds a distinct TX channel only after live RX is stable. TX construction
does not imply authorization to key: the operator-intent/arbiter gate must pass
before the backend sets MOX or emits a nonzero TX IQ sample. Teardown clears MOX
first, stops EP2 production second, then destroys the TX channel.

## Verification and future performance matrix

`wdsp_channel_test` currently covers:

- reproducible RX complex-tone to audio and TX audio-tone to IQ vectors;
- allocation-free processing;
- explicit nonblocking underruns;
- rate/block reconfiguration outside the callback;
- unique concurrent channel IDs and leak-free repeated teardown.

Captured Metis frames and packet-gap fixtures belong in the future HL2 backend
test, not in this generic WDSP test.

Performance work starts only after the live RX path is correct. Record p50/p95/
p99 block time, CPU, peak resident memory, underruns, and queue high-water mark
for 1, 2, 4, and the supported maximum receiver count at 48/96/192/384 kHz on
macOS ARM, Windows x64, and Linux x64. A run passes only when p99 remains below
the block deadline with zero steady-state allocations and zero underruns after
warm-up.
Loading
Loading