A Haskell/Faust/SoapySDR RF TX/RX pipeline and a polyglot low-latency communications framework.
2.5K
https://github.com/ALH477/FauSDR
DeMoD LLC · lGPL-3.0 · [email protected]
Two codebases. One stack. A Haskell/Faust/SoapySDR RF TX/RX pipeline and a polyglot low-latency communications framework, sharing a custom 17-byte transport protocol at their boundary.
┌─────────────────────────────────────────────────────────────────┐
│ Haskell │
│ │
│ DeModFrame ──encodeFrame──► ByteString (17 bytes) │
│ │ │
│ encodeBpsk (BPSK NRZ-L) │
│ │ │
│ Vector Float (symbol stream) │
│ ┌───── FFI ───┘ │
└──────────────────────────┼──────────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────────┐
│ Faust (modulator_hs.dsp → modulator_hs_gen.rs, Rust FFI) │
│ │
│ inputs[0]: symbol stream (±1.0 NRZ) │
│ → 63-tap Kaiser-windowed RRC FIR (α=0.35, β=8.0) │
│ → Phase modulation onto NCO carrier │
│ outputs[0] = I, outputs[1] = Q │
│ ┌───── FFI ───┐ │
└──────────────────────────┼──────────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────────┐
│ SoapySDR (soapy_bridge.cpp) │
│ Interleaved CF32 → writeStream → RF hardware │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SoapySDR (soapy_bridge.cpp — RX path) │
│ RF hardware → readStream → Interleaved CF32 │
│ ┌───── FFI ───┘ │
└──────────────────────────┼──────────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────────┐
│ Faust (bpsk_demod.dsp → bpsk_demod_gen.rs, Rust FFI) │
│ │
│ inputs[0]=I, inputs[1]=Q │
│ → Costas loop (2nd-order, ζ=0.707) — carrier recovery │
│ → 63-tap RRC matched filter (same coefficients as TX FIR) │
│ outputs[0]: soft symbols ∈ ℝ │
│ ┌───── FFI ───┐ │
└──────────────────────────┼──────────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────────┐
│ Haskell (DCF.Demodulator) │
│ │
│ sliceSoftSymbols (downsample by sps, threshold at 0) │
│ bitsToBytes │
│ findFrame (sliding window sync search) │
│ decodeFrame + CRC validate │
│ → Maybe DeModFrame │
└─────────────────────────────────────────────────────────────────┘
| Scheme | TX DSP | RX DSP | TX filter | RX filter |
|---|---|---|---|---|
| BPSK (RF) | modulator_hs.dsp | bpsk_demod.dsp | 63-tap RRC FIR | 63-tap RRC MF |
| QPSK | qpsk_mod.dsp | qpsk_demod.dsp | 63-tap RRC FIR | 63-tap RRC MF |
| GMSK | gmsk_mod.dsp | gmsk_demod.dsp | 3× cascaded IIR | 3× cascaded IIR |
| FSK (RF) | fsk_mod.dsp | fsk_demod.dsp | — (CP via phasor) | IIR lowpass |
| OOK/ASK | ask_mod.dsp | ask_demod.dsp | IIR RC shaping | Envelope + IIR |
| BPSK (guitar cable) | jack_mod.dsp | jack_demod.dsp | 63/121-tap RRC | 63/121-tap RRC MF |
| FSK (acoustic) | acoustic_fsk_mod.dsp | acoustic_fsk_demod.dsp | — (CP phasor) | Dual BPF + envelope |
The BPSK and QPSK TX and RX filters are matched: both use the same 63-tap Kaiser-windowed RRC FIR (α=0.35, β=8.0), giving zero ISI at the correct sampling instants (raised-cosine end-to-end response).
0 1 2-3 4-5 6-7 8-11 12-14 15-16
┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐
│ SYNC │ FLAGS │ SEQ │SRC_ID │DST_ID │PAYLOAD│ TS │ CRC16 │
│ 0xD3 │vvvvtttt│ BE u16│ │ │4 bytes│24-bit │CCITT │
└───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘
1 B 1 B 2 B 2 B 2 B 4 B 3 B 2 B
| Field | Description |
|---|---|
| SYNC | 0xD3 preamble — first validity gate |
| FLAGS | [7:4] version · [3:0] type (0=DATA 1=ACK 2=BEACON 3=CTRL) |
| SEQ | Big-endian rolling counter (replay detection) |
| SRC_ID / DST_ID | Node IDs · 0xFFFF = broadcast |
| PAYLOAD | 4 bytes application data |
| TIMESTAMP | 24-bit big-endian µs offset, wraps ~16.7 s |
| CRC16 | CRC-CCITT poly 0x1021, init 0xFFFF, over bytes [0..14] |
Valid iff sync == 0xD3 and crc16_ccitt(frame[0..14]) == frame[15..16].
A stripped-down mini-frame for latency-critical point-to-point links over guitar cables or acoustic channels. No routing metadata — just payload and integrity check.
0 1 2-5 6-7
┌───────┬───────┬──────────┬───────┐
│ SYNC │ HDR │ PAYLOAD │ CRC16 │
│ 0xA5 │ssssttt│ 4 bytes │ CCITT │
└───────┴───────┴──────────┴───────┘
1 B 1 B 4 B 2 B
| Field | Description |
|---|---|
| SYNC | 0xA5 — distinct from DCF 0xD3, no ambiguity on mixed links |
| HDR | [7:4] seq nibble (0–15 rolling) · [3:0] type (0=DATA 1=ACK 2=BEACON 3=CTRL) |
| PAYLOAD | 4 bytes: float32, uint32, or 4×uint8 |
| CRC16 | CRC-CCITT over bytes [0..5] |
Reference vector — DATA, seq=0, payload=DEADBEEF:
Wire: A5 01 DE AD BE EF C2 3F CRC pin: 0xC23F
Bridge to DCF: jfToDCF in DCF.Transport.JackFrame wraps the 4-byte payload into a full DCF DATA frame for forwarding into the wider network.
Reference test vector — version=1, DATA, seq=1, src=1, dst=0xFFFF, payload=DEADBEEF, ts=0:
Bytes [0..14]: D3 10 00 01 00 01 FF FF DE AD BE EF 00 00 00
CRC [15..16]: 42 DD
Full frame: D3 10 00 01 00 01 FF FF DE AD BE EF 00 00 00 42 DD
The CRC pin 0x42DD is asserted in every language's test suite. Divergence is a wire-breaking change.
| Language | File | Encode | Decode | Validate | CRC pin test |
|---|---|---|---|---|---|
| C | transport/dcf_frame.h | dcf_frame_encode() | dcf_frame_decode() | dcf_frame_valid() | src/test_conformance.cpp |
| Haskell | DCF/Transport/Frame.hs | encodeFrame | decodeFrame | validateFrame | test/ConformanceSpec.hs |
| Rust | dcf/rust/src/frame.rs | Frame::encode() | Frame::decode() | Frame::is_valid() | #[test] crc_cross_language_pin |
| Common Lisp | hydramesh.lisp | encode-dcf-frame | decode-dcf-frame | valid-dcf-frame-p | dcf-frame-crc-test |
| Go | dcf/go/frame.go | Frame.Encode() | Decode() | IsValid() | TestCRCPin |
| Python (GR) | gr-demod/python/demod/ | encode_frame() | decode_frame() | inline | qa_dcf_frame.py::TestCRCPin |
The C SDK and Python SDK use a compact TLV envelope over any transport plugin:
magic(1) version(1) type(1) seq(4 BE) timestamp_us(8 BE)
sender_len(1) sender(N) recipient_len(1) recipient(M)
group_id_len(1) group_id(G) data_len(4 BE) data(D)
msg_id_len(1) msg_id(I)
magic = 0xDC, version = 0x01. All multi-byte integers big-endian.
cd haskell && nix develop
faust-all-mod # compile all modulator DSP files → *_gen.rs (once per DSP change)
cabal-build && cabal-test
cabal run demod-sdr-hs -- --driver rtlsdr --freq 433.92e6 --rate 2e6 --gain 20
cd haskell && nix develop
cabal run demod-rx-hs -- --driver rtlsdr --freq 433.92e6 --rate 2e6 --gain 40
nix develop
cmake -B build -G Ninja
faust-all-mod && faust-all-demod && faust-all-audio # compile all DSP → *_gen.rs
cmake --build build
ctest --test-dir build -V # conformance + frame tests
./build/demod-sdr
cd dcf/rust && cargo test
cd dcf/go && go test -v
sbcl --load hydramesh.lisp --eval '(run-tests)'
cd gr-demod
cmake -B build && cmake --build build && sudo cmake --install build
python3 -m pytest python/demod/qa_dcf_frame.py -v # no hardware required
cd dcf/c_sdk/plugins
gcc -shared -fPIC -o libdcf_sdr_transport.so sdr_transport.c -lcjson -lpthread
# config.json: "transport": "libdcf_sdr_transport.so"
# demod-sdr-hs must be running to open the named pipes
Transmits JackFrame data as audible two-tone FSK. Sounds like a dial-up modem. Works between any two machines with a speaker and a microphone within ~50 cm.
cd haskell && nix develop
# 1. Compile the acoustic DSP (once per change):
faust-all-audio # compiles acoustic_fsk_mod.dsp + acoustic_fsk_demod.dsp → *_gen.rs
# 2. Build:
cabal build acoustic-hello-tx acoustic-hello-rx
# 3. Machine B — start listener first:
cabal run acoustic-hello-rx
# 4. Machine A — interactive TX prompt:
cabal run acoustic-hello-tx
# send> HELLO
# send> DeMoD LLC
# send> ← empty line to quit
Parameters: mark=2000 Hz, space=3000 Hz, 1200 baud, 48 kHz SR, sps=40, frame=53.3 ms.
See acoustic_fsk_guide.docx for full tuning, troubleshooting, and JACK/PipeWire routing.
Single-machine loopback test (no second machine needed):
pactl load-module module-null-sink sink_name=loopback
pactl load-module module-loopback source=loopback.monitor
# run TX and RX in separate terminals — they talk through PipeWire
| Path | Default | Range | Notes |
|---|---|---|---|
/modulator/carrier_freq | 0.1 | 0.001–0.5 | Normalized to SR/2 |
/modulator/symbol_rate | 9600 | 300–115200 | Baud |
/modulator/mod_index | π | 0–2π | π = BPSK |
/modulator/rrc_rolloff | 0.35 | — | Label only; FIR is compiled in (α=0.35) |
/modulator/output_gain | 0.8 | 0–1.0 | Headroom |
/bpsk_demod/symbol_rate | 9600 | 300–115200 | Must match TX |
/bpsk_demod/loop_bw | 0.02 | 0.001–0.1 | Costas loop BW |
/bpsk_demod/output_gain | 1.0 | 0–4.0 | Soft symbol scale |
/qpsk_demod/symbol_rate | 28800 | 300–115200 | Must match TX |
/qpsk_demod/loop_bw | 0.02 | 0.001–0.1 | 4-phase Costas loop BW |
/qpsk_demod/output_gain | 1.0 | 0–4.0 | Soft symbol scale |
/jack_demod/loop_bw | 0.03 | 0.001–0.1 | Guitar-cable Costas BW |
/acoustic_fsk_demod/agc_tc | 0.05 | — | AGC time constant (50 ms) |
ITransport* dcf_transport_create(void) {
ITransport* tp = calloc(1, sizeof(ITransport));
tp->name = "my-transport";
tp->version_major = 1; // must be >= 1.1
tp->version_minor = 1;
tp->setup = my_setup;
tp->send = my_send;
tp->receive = my_receive;
tp->health_check = my_health_check;
tp->destroy = my_destroy;
return tp;
}
dlopen() handles are stored and dlclose()'d at dcf_plugin_manager_destroy(). Transport load failure is fatal; hardware plugin failures are non-fatal.
{
"node_id": "node-alpha",
"mode": "p2p",
"listen_addr": "0.0.0.0:9000",
"peers": ["10.0.0.2:9000"],
"plugins": {
"transport": "libdcf_sdr_transport.so",
"hardware": { "i2c_sensor": "libi2c_sensor.so" }
},
"transport_params": {
"tx_pipe": "/tmp/dcf_sdr_tx",
"rx_pipe": "/tmp/dcf_sdr_rx",
"frame_timeout_ms": 500
},
"timeout_ms": 5000,
"retries": 3,
"redundancy": { "enabled": true, "reroute_timeout_s": 10 }
}
Pre-built packages for all four binaries (acoustic-hello-tx, acoustic-hello-rx, demod-sdr-hs, demod-rx-hs).
nix build .#docker
docker load < result
# Acoustic TX — mount host PipeWire/JACK socket:
docker run --rm -it \
-v /run/user/1000/pipewire-0:/run/user/1000/pipewire-0 \
-v /run/user/1000/jack:/run/user/1000/jack \
demod-faust-sdr:latest acoustic-hello-tx
# SDR TX — pass through USB device:
docker run --rm -it --device /dev/bus/usb \
demod-faust-sdr:latest demod-sdr-hs
Layered image — runtime libs and SDR plugins in low layers, binaries on top. Only the top layer rebuilds on code changes.
nix build .#appimage-hello-tx && chmod +x result && ./result
nix build .#appimage-hello-rx && chmod +x result && ./result
nix build .#appimage-sdr-tx && chmod +x result && ./result
nix build .#appimage-sdr-rx && chmod +x result && ./result
Bundles the binary and all .so dependencies via patchelf rpath. No Nix, no Docker, no package manager. JACK/PipeWire must be running on the host.
nix build .#tarball
tar xf result/demod-faust-sdr.tar.gz
./demod-faust-sdr/run acoustic-hello-tx
./demod-faust-sdr/run demod-sdr-hs
Contains bin/, a flat lib/ with the full runtime closure, and a run launcher that sets LD_LIBRARY_PATH and SOAPY_SDR_PLUGIN_PATH automatically relative to the unpacked directory.
nix build .#bundle-hello-tx # → single self-extracting shell script
nix build .#bundle-hello-rx
# or, using the nix-appimage bundler:
nix bundle --bundler github:ralismark/nix-appimage .#hello-tx
Packs the entire Nix store closure into a self-extracting archive. Extracts to /tmp on first run. Largest format (~500 MB unpacked) but requires nothing on the target machine.
| Shell | Command | Includes |
|---|---|---|
| Full | cd haskell && nix develop | GHC 9.10, HLS, cabal, Faust 2.83, SoapySDR, GNURadio, inspectrum, JACK2 |
| Headless | cd haskell && nix develop .#headless | GHC, cabal, Faust, SoapySDR, JACK2 |
| Modem dev | cd haskell && nix develop .#modem-dev | Headless + liquid-dsp, codec2, minimodem, sox, baudline, PipeWire |
| Embedded | cd haskell && nix develop .#embedded | Rust, ARM cross-compiler, openocd, probe-rs, cargo-embed |
| Language | Frame Codec | Plugin Manager | Transport | Redundancy | Status |
|---|---|---|---|---|---|
| C | ✓ DCF + JackFrame | ✓ dlopen/linked-list | ✓ UDP + SDR | ✓ Dijkstra | Complete |
| Haskell | ✓ DCF + JackFrame | — | ✓ SoapySDR TX/RX + Acoustic FSK | — | TX + RX + Acoustic complete |
| Rust | ✓ no_std | ✓ traits/HashMap | stub | — | Frame + core complete |
| Common Lisp | ✓ | ✓ CLOS/hash-table | ✓ UDP (HydraMesh) | — | Complete |
| Python | — | ✓ ABC/importlib | ✓ TLV encode/decode | — | Core complete |
| Go | ✓ | — | — | — | Frame codec complete |
| GNU Radio OOT | ✓ (Python) | — | ✓ byte stream | — | Sink + source complete |
All DSP files were migrated from pre-2.73 Faust syntax to Faust 2.83 (nixpkgs binary):
| Old syntax | Replacement | Files affected |
|---|---|---|
let { 'x = ...; } in expr | with {} local bindings | all demod files |
letrec { 'x = ...; } in expr | pll_core_* function + ~ (_, _) | bpsk_demod, qpsk_demod, jack_demod |
'x = expr (prime recursive binding) | pll_core_* state-machine function | Costas loop files |
\(a, b) -> expr (lambda) | named with {} local function | acoustic_fsk_demod |
Trailing comma in waveform{} | removed | qpsk_mod, all demod files with RRC tables |
-lang c++ backend | -lang rust | flake.nix (C++ backend not compiled in nixpkgs binary) |
Costas loop PLL architecture: all three Costas loop implementations (bpsk_demod, qpsk_demod, jack_demod) were rewritten using a pll_core_*(i_s, q_s, ph, fi) state-transition function whose with{} bindings only reference its own arguments (no mutual cross-references), combined with ~ (_, _) to feed (ph_new, fi_new) back as (ph, fi) with one sample of delay. This avoids the definitional cycle that Faust's term-rewriting evaluator cannot resolve when with{} bindings reference each other.
loop_bw via a continuous-time 2nd-order approximation. Empirical tuning against real IQ captures is required for production lock performance.DCF.Demodulator.findFrame uses a naive byte-aligned sliding window. A matched filter correlator in the soft domain would provide 3–5 dB better sensitivity in noise.DCF.Modem.QPSK provides the symbol de-mapper; the full QPSK demodulator pipeline (qpsk_demod.dsp + Haskell soft-symbol slicing + Gray decoding) is not yet wired in runDemodulator (only BPSK is currently the default RX scheme).sdr_transport.c carries only 4 bytes of application data per DCF frame. Fragmentation for longer messages via FCtrl frames is not yet implemented.nix build .#docker) — layered, docker load-readynix build .#appimage-*)run script (nix build .#tarball)nix build .#bundle-*)jack-0.7.2.2 builds in dev shell (jack2 C headers added to all devShells)runDemodulator as selectable schemesFCtrl fragmentation in sdr_transport.c for messages > 4 byteslibdcf_udp_transport.so).yml block descriptor files for dcf_frame_sink and dcf_frame_sourcewriteAudio / readAudio stubs in AcousticHelloTx / AcousticHelloRx to JACK2 or PipeWire Simple APIprocessChunked stubs in AcousticFSK.hs wired to DCF.Modulator.processChunklGPL-3.0. See LICENSE.
DCF excludes cryptographic primitives to comply with U.S. EAR and ITAR. Users adding encryption (e.g. TLS via a transport plugin) are responsible for their own export compliance. DeMoD LLC disclaims liability for non-compliant modifications.
DeMoD LLC — Design ≠ Marketing
Content type
Image
Digest
sha256:6cc0edb9b…
Size
1.1 GB
Last updated
6 months ago
docker pull alh477/demod-fausdr