# Dual-CSS HF Mode — Rust

An amateur-radio chirp-spread-spectrum HF modem: dual-CSS waveform with
matched-filter detection, Reed-Solomon FEC, real-time FFT waterfall, tabbed
settings, and station/PTT control — built on
[egui/eframe](https://github.com/emilk/egui) with a pure-Rust dependency stack.

## Build & run

```sh
cargo run --release          # build + launch the GUI
cargo test                   # run the DSP / config / flrig / waterfall tests
cargo clippy --all-targets   # lints (clean)
```

The release binary is `target/release/dual_css_hf`.

## Dependencies

| Crate | Role |
|-------|------|
| `eframe` / `egui` 0.34 | windowing + immediate-mode GUI (glow/OpenGL renderer) |
| `cpal` 0.18 | cross-platform audio I/O |
| `rustfft` 6 | CPU FFT for the waterfall and dechirp |
| `wgpu` 29 / `pollster` / `bytemuck` | optional GPU-compute FFT (user-selectable) |
| `reed-solomon` 0.2 | RS(255, …) FEC over GF(256) |
| `serialport` 4 | RTS/DTR PTT keying |
| `serde` / `serde_json` 1 | JSON config persistence |

flrig rig control uses a small built-in XML-RPC client over `std::net` — no
extra HTTP/XML crates.

## GPU acceleration

The GUI (including the waterfall) already renders on the GPU via egui's
**glow/OpenGL** backend, which is vendor-neutral and works on AMD, NVIDIA, and
Intel across all three platforms.

For the FFT specifically, the Display settings tab has a **GPU FFT** toggle that
runs the transform on the GPU through **wgpu** (Vulkan/Metal/DX12/OpenGL — no
CUDA toolchain, so it stays single-codebase cross-platform). It uses a Stockham
radix-2 compute shader and is validated against the CPU `rustfft` path by a unit
test. The CPU path is the default and the automatic fallback if no GPU is
available or any GPU error occurs. Note: at the supported FFT sizes
(2048–32768) the CPU is usually just as fast, since the GPU round-trip latency
dominates — the toggle is provided for choice, not a guaranteed speedup.

## Cross-platform

Targets Windows, macOS, and Linux from one codebase:

- **Audio** — cpal selects WASAPI (Windows), CoreAudio (macOS), or
  ALSA/PipeWire (Linux) automatically.
- **Linux windowing** — both Wayland and X11 backends are compiled in; winit
  prefers Wayland when a compositor is present and falls back to X11.
- **ALSA probe-spam suppression** is Linux-only (`#[cfg(target_os = "linux")]`);
  the root-user audio warning is `#[cfg(unix)]`; "Open log file" dispatches to
  `start` / `open` / `xdg-open` per platform.
- Fonts use egui's bundled proportional/monospace faces, so there's no
  dependency on system fonts like Segoe UI / Courier New.

## PTT methods

`RTS`, `DTR`, `VOX`, `CAT/Hamlib`, `flrig`, and `None`. The flrig method talks
to a local flrig instance over XML-RPC (default `127.0.0.1:12345`); the PTT
settings tab has host/port fields and a "Test flrig connection" button.

## Config compatibility

Config is stored as `dual_css_hf_config.json` next to the binary. Unknown or
missing fields fall back to defaults (via `#[serde(default)]`), so config
files from older or newer versions load cleanly.

## RX path

Live receive is fully wired: the capture stream feeds both the waterfall FFT
and a raw-audio tap that drives the demodulator state machine
(IDLE → L1 SEARCH → SYNC → L2 PREAMBLE → HEADER → DATA → DECODED). Decoded
messages appear in the RECEIVE log with a UTC timestamp and SNR; the status
panel shows lock state, erasures, and RS-corrected byte counts.

The chirps are **cyclic** — the instantaneous frequency wraps at the band
edges, LoRa-style — so the whole transmission stays inside the SSB passband,
and detection uses analytic-signal dechirping against complex conjugate
references, which eliminates the image tones that make real-signal dechirp
detection ambiguous at some spreading factors. Symbol timing is recovered
from the periodic L1 preamble (the dechirp tone's bin position measures the
block offset, refined by fractional-bin interpolation), so frames are
acquired at arbitrary sample offsets — exercised by the `streaming_*` tests
in `dsp.rs`.

### Auto-detect RX

With **Auto RX** on (the default, toggle in Quick Settings), the receiver
runs an L1-preamble detector for every SF (7–12) × bandwidth-preset
hypothesis in parallel and latches a full demodulator onto whichever one
locks — so it decodes incoming transmissions regardless of the sender's
SF/bandwidth, as long as both stations use the same **centre frequency** and
the sender uses a bandwidth preset. The frame header carries the **coding
rate** (2 bits), a **turbo flag** (1 bit), the RS-encoded byte count (13
bits) and a **CRC-8**, so the rate and mode never need scanning and a
corrupted header is rejected instead of committing the decoder to a garbage
frame length.

### Turbo mode (2× data rate)

In the normal **robust** encoding every data block carries one symbol twice:
`s` on the up chirp and `(N−s) mod N` on the down chirp, joined by a
constraint the receiver checks per block (failures become RS erasure hints).
With **Turbo TX** (Quick Settings) the down chirp instead carries an
**independent payload symbol** — two symbols per block, double the data rate,
in the same bandwidth and with the same constant two-chirp envelope. The cost
is the joint-constraint redundancy: per-stream dechirp tone quality stands in
for the erasure flag, and weak-signal margin drops a few dB. The mode is
carried in the frame header, so receivers — including pre-turbo builds for
robust frames — need no setting; robust-mode frames are bit-identical with
earlier versions.

### Narrowband presets & the advisor

Bandwidth presets go down to **100 and 250 Hz**: halving the bandwidth halves
the in-band noise power (+3 dB sensitivity) at half the data rate, so the
narrow presets are the WSPR-style "get through at all costs" end of the dial
(SF-7 at 100 Hz ≈ 5 bps raw). The auto-detect scanner hypothesises them like
any other preset, capped by its 4-second symbol limit (SF 7–8 at 100 Hz,
SF 7–9 at 250 Hz).

An **ADVISOR** box in the right panel makes mode choice data-driven:

- While RX runs, the whitener's learned noise floor is scanned for the
  **quietest slot** of the current bandwidth across the 300–3000 Hz passband;
  if a meaningfully quieter centre exists (≥ 2 dB), one click moves the band
  there (both stations must use the same centre frequency).
- After every decoded frame, its measured SNR margin is converted into a
  **SF / rate / turbo suggestion** — spare margin is spent on speed (turbo
  ≈ 3 dB, one SF step ≈ 3 dB, one RS rate step ≈ 1.5 dB), a shortfall is
  bought back with robustness.

### Weak-signal measures

- A **spectral whitener** runs ahead of everything else: it learns the
  long-term noise floor of every frequency bin (slow attack / fast decay, so
  the sweeping chirps themselves never raise it) and grades each bin against
  that floor, flattening persistent in-band interference — a carrier, a
  heterodyne, power-line buzz — down to the level of the surrounding noise.
  The dechirp matched filter is only optimal in *white* noise, and a steady
  tone inside the band is the band-pass filter's blind spot. Floor learning
  **freezes while a frame is being decoded** — at the narrow presets the
  chirp dwells long enough in each ~47 Hz bin that a long frame would
  otherwise pull the floor up underneath its own signal.
- A **band-pass front end** (linear-phase FIR, ~74 dB stopband) confines the
  RX stream to the established band edges (+ a small guard) before
  dechirping. The dechirp is already a matched filter — each FFT bin is the
  correlation against one stored reference chirp, LoRa-style — but it is not
  band-selective: a tone up to one bandwidth outside the band still sweeps
  through the correlation bins after dechirping. The auto-detect scanner
  filters once per bandwidth preset and shares the stream across its SF
  hypotheses; the filter state follows the latched demodulator so the
  handoff is seamless.
- The header is transmitted **twice** and the receiver soft-combines the
  dechirp score vectors of both copies before slicing (~3 dB on the most
  fragile part of the frame). Slicing itself is a **soft candidate search**:
  the top few bins of every header symbol are tried in descending score
  order and the strongest CRC-valid combination wins, so one noisy header
  symbol whose true bin came second no longer costs the whole frame.
- The end of the sync word is found by **detecting the L2 up tone's
  return**, not by counting blocks after the first detected downchirp — a
  faded sync chirp (or detection delayed by the room's reverb tail) would
  otherwise shift every later symbol window and garble the header. Sync
  detection also no longer requires the up stream to be quiet, since
  acoustic reverb keeps preamble energy ringing into the downchirps; the
  CFO estimate is averaged over all clean sync chirps.
- Data symbols whose up/down peaks violate the dual-CSS joint constraint are
  flagged and passed to the Reed-Solomon decoder as **erasures** — RS corrects
  twice as many erasures as unknown errors.
- The residual **frequency offset** is measured on the sync downchirp and
  compensated in header/data detection (covers soundcard clock offsets and
  small centre-frequency mismatches).
- A **symbol-timing loop** tracks sample-clock drift between the two stations
  from the L2 preamble onward (L2, header and data blocks all update it):
  the up/down tones' fractional deviations measure the timing residual, and
  the block boundary is micro-trimmed to keep the tones at bin center
  (validated by a ±100 ppm resampling test — without it, long frames
  silently mis-slice).
- Preamble lock rides through short fades (hysteresis) instead of dropping on
  the first weak symbol.
- If the signal dies mid-frame (TX aborted, deep fade), a run of joint-
  constraint failures longer than RS could ever repair drops the frame and
  re-arms acquisition instead of collecting noise until the full symbol count
  arrives.

### Band placement

The modem band is set by **centre frequency + bandwidth** (Quick Settings or
Settings → Modem); `set_band` keeps the whole signal inside the 300–3000 Hz
SSB bandpass. The TX panel shows the **estimated time-to-transmit** for the
typed message and a live countdown while sending.
