Skip to main content

weaklink

Streaming digital modem: bytes on stdin → audio → bytes on stdout. Works with tail -f; no memory buffering, no wait-for-EOF. RS + convolutional

  • soft Viterbi outer/inner coding, per-block interleaver, cross-copy soft-LLR combining. Modes: OOK (1-tone) through 16-FSK, at 45 / 300 / 1200 baud.

alt text

Distribution: weaklink-modem (PyPI + release binaries).


Install

Portable Linux binary:

sudo apt install libportaudio2 libsndfile1
curl -L -O https://github.com/ivica3730k/weaklink-9a3ice/releases/latest/download/weaklink-modem-linux-x86_64-latest
chmod +x weaklink-modem-linux-x86_64-latest

Debian / Ubuntu .deb:

curl -L -O https://github.com/ivica3730k/weaklink-9a3ice/releases/latest/download/weaklink-modem_amd64-latest.deb
sudo dpkg -i weaklink-modem_amd64-latest.deb

From source:

poetry install
poetry run weaklink-modem --version

PyPI:

pip install weaklink-modem

Quickstart

# WAV roundtrip
echo -n "hello weaklink" | weaklink-modem tx --modem-wav /tmp/hello.wav
weaklink-modem rx --modem-wav /tmp/hello.wav

# Live speaker → mic
weaklink-modem rx > out.txt &
echo -n "over the room" | weaklink-modem tx

# Long-lived stream
tail -f /var/log/syslog | weaklink-modem tx --modem-baud 300

Both sides must use the same --modem-baud (no handshake).


Presets

The three baud rates below are presets — starting points, not fixed configurations. Every preset carries 13 B of payload per RS block (RS(16,8) + CRC-32) by default, so message sizes and per-block air time in the table are what you get if you don't override anything. Preset defaults use 4-FSK; every other mode (--modem-num-tones ∈ {1, 2, 4, 8, 16}, OOK through 16-FSK) is available at any baud.

Every parameter shown is overridable on both sides via CLI flags (--modem-num-tones, --modem-rs-data-bytes, --modem-rs-parity-bytes, --modem-block-repeats, ...), or via ModemOptions in the Python API. Changing them shifts the numbers in the table proportionally -- smaller RS blocks and lower repeat counts mean shorter minimum payloads (and a worse cliff); the reverse is also true.

Baud CLI (both tx / rx) 4-FSK tones (Hz) Bandwidth Default repeats Measured best SNR Min live tx (13 B payload)
45 --modem-baud 45 1200 / 1400 / 1600 / 1800 600 Hz 4× ≈ −14 dB 28 s
300 --modem-baud 300 1050 / 1350 / 1650 / 1950 900 Hz 2× ≈ −5 dB 2.4 s
1200 --modem-baud 1200 500 / 1700 / 2900 / 4100 3600 Hz 2× ≈ +2 dB 1.0 s

SNR is measured with AWGN normalised to a 3 kHz reference band — a cross-baud comparison convention, not a physical channel filter.

Doubling --modem-block-repeats buys ~2–3 dB via soft-LLR combining at proportional air time. Full sweep of every combo we test is in results.md.

OOK / 1-tone mode

--modem-num-tones 1 selects on-off keying: single carrier at center_hz, symbol 0 = silence, symbol 1 = tone. Narrowest possible bandwidth of any mode (no tone stack — just the carrier and its modulation sidelobes). Same 1 bit/symbol as 2-FSK but a few dB worse in AWGN — the trade you make for the narrow spectrum. Cliff at 45 baud with block_repeats=4: ≈ −14 dB, matching 4-FSK at the same settings. See results.md for the per-baud numbers.

One tone at a time (constant envelope, any N)

Every mode in this modem is single-tone-at-a-time CPFSK. --modem-num-tones 16 means "16 possible frequencies to pick from per symbol", not "16 frequencies playing simultaneously". The transmitter emits exactly one sinusoid at any instant, hopping between frequencies at the symbol clock. Constant envelope (PAPR = 3 dB, the peak-to-RMS of a pure sine) regardless of N.

Time-frequency view of a 4-FSK burst — each column is one symbol, each row is one of the 4 available frequencies:

freq
 ↑
 F₃ │  .  ▓  .  .  .  ▓  .  .
 F₂ │  .  .  .  ▓  .  .  .  .
 F₁ │  ▓  .  .  .  .  .  ▓  .
 F₀ │  .  .  ▓  .  ▓  .  .  ▓
    └────────────────────────→ time
      s₀ s₁ s₂ s₃ s₄ s₅ s₆ s₇

Exactly one ▓ per column: at any instant, one tone at full amplitude.
Over 8 symbols the modem has visited all 4 slots (that's what you see
on an SDR waterfall over time), but never more than one at once.

vs. what parallel multi-tone (OFDM-style; not this modem) would do:

freq
 ↑
 F₃ │  ▓  .  ▓  ▓  .  ▓  .  ▓
 F₂ │  .  ▓  ▓  .  ▓  .  ▓  ▓   Multiple ▓ per column: N tones
 F₁ │  ▓  ▓  .  ▓  .  ▓  ▓  .   summed at each instant. PAPR grows
 F₀ │  ▓  .  ▓  .  ▓  ▓  .  ▓   with N (~10·log₁₀(N)).
    └────────────────────────→ time
      s₀ s₁ s₂ s₃ s₄ s₅ s₆ s₇

Why single-tone:

  • All transmit power in one tone at a time — maximum per-symbol SNR, no 1/N power split across a stack.
  • Higher N buys log₂(N) bits/symbol (more throughput) without any PAPR cost. 16-FSK carries 4× the bits of 2-FSK at the same baud, same peak power.

On an SDR waterfall you'll see all N frequency slots "lit up" during a long transmission — that's the display integrating over time, showing every slot the modem visited. Zoom the FFT window below one symbol duration (~3.3 ms at 300 baud) and you'll see the transmitter chasing one tone across the slots instead.


Debugging live audio

weaklink-modem rx --modem-debug > out.txt writes diagnostics to log.txt (stdout stays clean for piping). Watch for:

  • audio: peak +X dBFS below −40 dBFS → wrong mic or gain too low.
  • RS corrected — outer code saved a block.
  • N slot(s) failed CRC/RS — unrecoverable, data lost.
  • macOS mic AGC / voice-isolation destroys tones; disable in System Settings.

Full SNR sweep

Every baud × num_tones × RS × repeats combo is measured in results.md. Re-run poetry run weaklink-modem-benchmark to refresh.


Supported audio backends

Backend Linux macOS Windows Notes
sounddevice / PortAudio ✓ ✓ ✓* Default. Index (5) or name substring (USB). WASAPI / CoreAudio / ALSA under the hood.
Pulse / PipeWire subprocess ✓ — — paplay / parec. Fires on pulse:<id>, pulse:<name>, a bare Pulse sink id resolvable by pactl, or a name PortAudio doesn't know (e.g. virt.monitor).
WAV via soundfile ✓ ✓ ✓ File I/O only, via --modem-wav.

* Windows is untested — PortAudio supports it so it should work, but no CI on that platform yet.


CLI reference

Flag Default Description
--modem-baud N 300 Symbol rate. Only 45, 300, 1200 supported.
--modem-num-tones N 4 N-FSK order: 2 / 4 / 8 / 16. Higher packs more bits per symbol at wider bandwidth and worse cliff. 2 halves throughput but fits narrow audio paths (e.g. FM voice via SignaLink). TX and RX must match.
--modem-rs-data-bytes N preset Reed-Solomon data bytes per block.
--modem-rs-parity-bytes N preset RS parity bytes. Corrects up to N/2 byte errors per block.
--modem-no-rs-crc CRC on Skip the CRC-32 inside each RS block.
--modem-block-repeats N preset N copies per block, each permuted differently; RX soft-combines LLRs.
--modem-wav PATH live WAV file instead of live audio.
--modem-audio-output NAME OS default tx audio target: sounddevice index, name substring, or Pulse sink.
--modem-audio-input NAME OS default rx audio source: same syntax; Pulse sources like virt.monitor supported.
--modem-debug off Verbose DEBUG chatter in the log file.
--modem-log-file PATH ./log.txt Diagnostics land here.

How it works

TX signal chain

flowchart LR
    A[stdin bytes] --> B[Chunk<br/>rs_data − 3 B<br/>per block]
    B --> C[Frame<br/>len · idx · payload · pad]
    C --> D[RS + CRC-32]
    D --> E[Conv encode<br/>K=7, r=1/2]
    E --> F[Per-block<br/>interleaver<br/>32-cycle PN]
    F --> G[Bits → 4-FSK<br/>symbols]
    G --> H[CPFSK<br/>modulator]
    H --> I[+ preamble<br/>+ pilot]
    I --> J[audio out]

RX signal chain

flowchart LR
    A[audio in] --> B[Non-coherent<br/>4-FSK demod]
    B --> C[Coarse FFT<br/>LO offset]
    C --> D[Preamble<br/>correlator]
    D --> E[Peak detect<br/>+ non-max<br/>suppression]
    E --> F[Per-preamble<br/>fine offset]
    F --> G[Slot soft LLRs]
    G --> H[Deinterleave<br/>seed brute-force]
    H --> I[Soft Viterbi]
    I --> J{RS + CRC<br/>ok?}
    J -- yes --> K[Assemble<br/>by block_index]
    J -- no --> L[Buffer LLRs;<br/>combine across<br/>R copies]
    L --> I
    K --> M[Strip zero-pad<br/>via length]
    M --> N[stdout bytes]

Every slot is bracketed by a preamble, so any single slot decodes standalone. Spurious mid-stream peaks get dropped. Message boundaries between separate tx sessions are inferred from non-block-length spans between preambles — one rx pipe can watch many tx sessions in a row.

Wire format

One tx session (live audio):

  ┌────────┬─────┬────────┬─────┬────────┬─────┬─────┬────────┬─────┬────────┐
  │ pilot  │ pre │ slot 0 │ pre │ slot 1 │ pre │ ... │slot N-1│ pre │ pilot  │
  └────────┴─────┴────────┴─────┴────────┴─────┴─────┴────────┴─────┴────────┘

One RS block, data area (before conv + interleave + FSK):

  ┌── 1B ──┬── 2B ────┬──── rs_data − 3 B ────┬── 4B CRC ──┬── rs_parity B ──┐
  │ length │block_idx │ payload (zero-padded) │  CRC-32    │  RS parity      │
  └────────┴──────────┴───────────────────────┴────────────┴─────────────────┘

block_idx is 2 bytes → one tx session is bounded at 65 535 slots.


Testing

poetry run pytest -q            # ~2 min, full suite

Every batch-decode test has an e2e-streaming companion that drives audio through the same _StreamingRxPump the CLI uses.


Glossary

  • N-FSK / CPFSK — Continuous-phase FSK with an N-value alphabet: N possible tone frequencies, exactly one on the air at any instant (never a stack). Each symbol picks one → log₂(N) bits/symbol. Default N=4.
  • Single-tone-at-a-time — The whole family from OOK through 16-FSK emits at most one sinusoid at any instant. Envelope stays constant (PAPR = 3 dB, same as a pure sine) regardless of --modem-num-tones.
  • OOK — On-off keying. num_tones=1 mode: single carrier, symbol 0 = silence, symbol 1 = tone. 1 bit/symbol like 2-FSK but at the narrowest possible bandwidth (only the carrier + modulation sidelobes). Pays a few dB vs 2-FSK in AWGN.
  • PAPR — Peak-to-average power ratio. 0 dB = pure DC; 3 dB = pure sine; ~10·log₁₀(N) dB = N tones summed with random phases. This modem stays at 3 dB.
  • Preamble — Fixed 32-symbol PN sequence bracketing every slot; RX locks timing / frequency / amplitude from it.
  • Slot — Preamble + one RS-encoded block.
  • Block — RS-encoded chunk carrying header + payload.
  • RS(n,k) — Reed-Solomon outer code. k data + parity → n wire bytes; corrects (n-k)/2 byte errors.
  • CRC-32 — Catches errors past RS correction.
  • Convolutional code (K=7, r=1/2) + soft Viterbi — Inner FEC and its decoder, driven by per-bit LLRs.
  • LLR — Log-likelihood ratio; a soft (real-valued) confidence per bit instead of a hard 0/1.
  • Interleaver — Bit shuffle so bursts become isolated errors. Ours changes every block (32-permutation cycle).
  • Non-coherent demod — Tone detection by energy; ~3 dB behind coherent.
  • LO offset — Radio frequency error; we correct up to ±500 Hz.
  • Pilot — Short random N-FSK burst before / after every live TX.
  • SNR (dB) — Signal-to-noise ratio. Negative = noise louder than signal.
  • AWGN — Additive white Gaussian noise; the standard clean-channel noise model used by the benchmark.
  • Shannon limit — Theoretical lowest SNR at which a given data rate can be decoded error-free. Every FEC decoder sits some dB above it — that gap is what "Gap" columns report.
  • Best SNR / cliff — Lowest SNR at which decode still works for a given config. Below it, everything breaks.
  • Nyquist theorem — A signal is only recoverable if sampled above twice its highest frequency. In practice: tones must sit below sample_rate/2, and N-FSK tone spacing must be at least 1/T_symbol for non-coherent orthogonality.

Roadmap

  • Coherent detection — Costas-loop demod, ~3 dB gain. Big DSP lift.
  • LDPC — Closes ~2–4 dB of the Shannon gap. Needs a proper construction.

License

MIT. See LICENSE.

Reed-Solomon via reedsolo. Convolutional code uses the standard NASA/CCSDS (171, 133) generator polynomials. Audio via sounddevice and soundfile.

Metadata

Release files for weaklink-modem 0.7.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for weaklink-modem 0.7.2
File Size Uploaded
weaklink_modem-0.7.2.tar.gz 53.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for weaklink-modem 0.7.2
File Interpreter ABI Platform
weaklink_modem-0.7.2-py3-none-any.whl Python 3 none any Details

Total release size: 107.7 kB

Release history Release notifications | RSS feed

1.1.0

21 release files

1.0.0

21 release files

0.8.4

2 release files

0.8.3

2 release files

0.8.2

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.5

2 release files

0.7.4

2 release files

0.7.3

2 release files

This release

0.7.2 This release

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.2

2 release files

0.6.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page