weaklink-modem 0.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weaklink_modem-0.6.1/LICENSE +21 -0
- weaklink_modem-0.6.1/PKG-INFO +273 -0
- weaklink_modem-0.6.1/pyproject.toml +88 -0
- weaklink_modem-0.6.1/readme-pypi.md +243 -0
- weaklink_modem-0.6.1/weaklink/modem/__init__.py +31 -0
- weaklink_modem-0.6.1/weaklink/modem/api.py +348 -0
- weaklink_modem-0.6.1/weaklink/modem/audio.py +394 -0
- weaklink_modem-0.6.1/weaklink/modem/benchmark.py +319 -0
- weaklink_modem-0.6.1/weaklink/modem/cli.py +299 -0
- weaklink_modem-0.6.1/weaklink/modem/codec.py +856 -0
- weaklink_modem-0.6.1/weaklink/modem/constants.py +62 -0
- weaklink_modem-0.6.1/weaklink/modem/exceptions.py +31 -0
- weaklink_modem-0.6.1/weaklink/modem/fec.py +153 -0
- weaklink_modem-0.6.1/weaklink/modem/interleaver.py +108 -0
- weaklink_modem-0.6.1/weaklink/modem/ptt.py +66 -0
- weaklink_modem-0.6.1/weaklink/modem/py.typed +0 -0
- weaklink_modem-0.6.1/weaklink/modem/rs.py +67 -0
- weaklink_modem-0.6.1/weaklink/modem/streaming.py +215 -0
- weaklink_modem-0.6.1/weaklink/modem/waveform.py +265 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivica Matic (9A3ICE)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: weaklink-modem
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: N-FSK streaming modem for HF SSB and wider channels: Reed-Solomon framing, soft Viterbi, round-robin block repetition, byte-pipe semantics.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: ivica3730k
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Topic :: Communications :: Ham Radio
|
|
20
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
21
|
+
Requires-Dist: numpy (>=2.0,<3.0)
|
|
22
|
+
Requires-Dist: reedsolo (>=1.7.0,<2.0.0)
|
|
23
|
+
Requires-Dist: sounddevice (>=0.5,<0.6)
|
|
24
|
+
Requires-Dist: soundfile (>=0.12,<0.13)
|
|
25
|
+
Project-URL: Bug Tracker, https://github.com/ivica3730k/weaklink-9a3ice/issues
|
|
26
|
+
Project-URL: Homepage, https://github.com/ivica3730k/weaklink-9a3ice
|
|
27
|
+
Project-URL: Repository, https://github.com/ivica3730k/weaklink-9a3ice
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<!-- generated from README.md at tag v0.6.1 by scripts/build_pypi_readme.py; do not hand-edit -->
|
|
31
|
+
|
|
32
|
+
# weaklink
|
|
33
|
+
|
|
34
|
+
Streaming digital modem: bytes on stdin → audio → bytes on stdout. Works
|
|
35
|
+
with `tail -f`; no memory buffering, no wait-for-EOF.
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
Distribution: `weaklink-modem` (PyPI + release binaries).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
Portable Linux binary:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
sudo apt install libportaudio2 libsndfile1
|
|
49
|
+
curl -L -O https://github.com/ivica3730k/weaklink-9a3ice/releases/latest/download/weaklink-modem-linux-x86_64-latest
|
|
50
|
+
chmod +x weaklink-modem-linux-x86_64-latest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Debian / Ubuntu `.deb`:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl -L -O https://github.com/ivica3730k/weaklink-9a3ice/releases/latest/download/weaklink-modem_amd64-latest.deb
|
|
57
|
+
sudo dpkg -i weaklink-modem_amd64-latest.deb
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
From source:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
poetry install
|
|
64
|
+
poetry run weaklink-modem --version
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
PyPI:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install weaklink-modem
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Quickstart
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# WAV roundtrip
|
|
79
|
+
echo -n "hello weaklink" | weaklink-modem tx --modem-wav /tmp/hello.wav
|
|
80
|
+
weaklink-modem rx --modem-wav /tmp/hello.wav
|
|
81
|
+
|
|
82
|
+
# Live speaker → mic
|
|
83
|
+
weaklink-modem rx > out.txt &
|
|
84
|
+
echo -n "over the room" | weaklink-modem tx
|
|
85
|
+
|
|
86
|
+
# Long-lived stream
|
|
87
|
+
tail -f /var/log/syslog | weaklink-modem tx --modem-baud 300
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Both sides must use the same `--modem-baud` (no handshake).
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Presets
|
|
95
|
+
|
|
96
|
+
Three baud rates. Every preset carries 13 B of payload per RS block
|
|
97
|
+
(RS(16,8) + CRC-32), so message sizes map identically across bauds.
|
|
98
|
+
|
|
99
|
+
| Baud | CLI (both tx / rx) | 4-FSK tones (Hz) | Bandwidth | Default repeats | Measured best SNR | Min live tx (13 B payload) |
|
|
100
|
+
|---:|---|---|---:|---:|---:|---:|
|
|
101
|
+
| 45 | `--modem-baud 45` | 1200 / 1400 / 1600 / 1800 | 600 Hz | 4× | ≈ −14 dB | 28 s |
|
|
102
|
+
| 300 | `--modem-baud 300` | 1050 / 1350 / 1650 / 1950 | 900 Hz | 2× | ≈ −5 dB | 2.4 s |
|
|
103
|
+
| 1200 | `--modem-baud 1200` | 500 / 1700 / 2900 / 4100 | 3600 Hz | 2× | ≈ +2 dB | 1.0 s |
|
|
104
|
+
|
|
105
|
+
SNR is measured with AWGN normalised to a 3 kHz reference band — a
|
|
106
|
+
cross-baud comparison convention, not a physical channel filter.
|
|
107
|
+
|
|
108
|
+
Override `--modem-block-repeats N` on both sides for more copies. Each
|
|
109
|
+
doubling buys ~2–3 dB via soft-LLR combining, at proportional air time.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Debugging live audio
|
|
114
|
+
|
|
115
|
+
`weaklink-modem rx --modem-debug > out.txt` writes diagnostics to
|
|
116
|
+
`log.txt` (stdout stays clean for piping). Watch for:
|
|
117
|
+
|
|
118
|
+
- `audio: peak +X dBFS` below −40 dBFS → wrong mic or gain too low.
|
|
119
|
+
- `RS corrected` — outer code saved a block.
|
|
120
|
+
- `N slot(s) failed CRC/RS` — unrecoverable, data lost.
|
|
121
|
+
- macOS mic AGC / voice-isolation destroys tones; disable in System Settings.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Full SNR sweep
|
|
126
|
+
|
|
127
|
+
Every baud × num_tones × RS × repeats combo is measured in
|
|
128
|
+
[`results.md`](https://github.com/ivica3730k/weaklink-9a3ice/blob/v0.6.1/results.md). Re-run `poetry run weaklink-modem-benchmark` to
|
|
129
|
+
refresh.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Supported audio backends
|
|
134
|
+
|
|
135
|
+
| Backend | Linux | macOS | Windows | Notes |
|
|
136
|
+
|---|:---:|:---:|:---:|---|
|
|
137
|
+
| sounddevice / PortAudio | ✓ | ✓ | ✓* | Default. Index (`5`) or name substring (`USB`). WASAPI / CoreAudio / ALSA under the hood. |
|
|
138
|
+
| 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`). |
|
|
139
|
+
| WAV via soundfile | ✓ | ✓ | ✓ | File I/O only, via `--modem-wav`. |
|
|
140
|
+
|
|
141
|
+
`*` Windows is untested — PortAudio supports it so it should work, but no CI on that platform yet.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## CLI reference
|
|
146
|
+
|
|
147
|
+
| Flag | Default | Description |
|
|
148
|
+
|------|---------|-------------|
|
|
149
|
+
| `--modem-baud N` | `300` | Symbol rate. Only `45`, `300`, `1200` supported. |
|
|
150
|
+
| `--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. |
|
|
151
|
+
| `--modem-rs-data-bytes N` | preset | Reed-Solomon data bytes per block. |
|
|
152
|
+
| `--modem-rs-parity-bytes N` | preset | RS parity bytes. Corrects up to N/2 byte errors per block. |
|
|
153
|
+
| `--modem-no-rs-crc` | CRC on | Skip the CRC-32 inside each RS block. |
|
|
154
|
+
| `--modem-block-repeats N` | preset | N copies per block, each permuted differently; RX soft-combines LLRs. |
|
|
155
|
+
| `--modem-wav PATH` | live | WAV file instead of live audio. |
|
|
156
|
+
| `--modem-audio-output NAME` | OS default | tx audio target: sounddevice index, name substring, or Pulse sink. |
|
|
157
|
+
| `--modem-audio-input NAME` | OS default | rx audio source: same syntax; Pulse sources like `virt.monitor` supported. |
|
|
158
|
+
| `--modem-debug` | off | Verbose DEBUG chatter in the log file. |
|
|
159
|
+
| `--modem-log-file PATH` | `./log.txt` | Diagnostics land here. |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## How it works
|
|
164
|
+
|
|
165
|
+
### TX signal chain
|
|
166
|
+
|
|
167
|
+
```mermaid
|
|
168
|
+
flowchart LR
|
|
169
|
+
A[stdin bytes] --> B[Chunk<br/>rs_data − 3 B<br/>per block]
|
|
170
|
+
B --> C[Frame<br/>len · idx · payload · pad]
|
|
171
|
+
C --> D[RS + CRC-32]
|
|
172
|
+
D --> E[Conv encode<br/>K=7, r=1/2]
|
|
173
|
+
E --> F[Per-block<br/>interleaver<br/>32-cycle PN]
|
|
174
|
+
F --> G[Bits → 4-FSK<br/>symbols]
|
|
175
|
+
G --> H[CPFSK<br/>modulator]
|
|
176
|
+
H --> I[+ preamble<br/>+ pilot]
|
|
177
|
+
I --> J[audio out]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### RX signal chain
|
|
181
|
+
|
|
182
|
+
```mermaid
|
|
183
|
+
flowchart LR
|
|
184
|
+
A[audio in] --> B[Non-coherent<br/>4-FSK demod]
|
|
185
|
+
B --> C[Coarse FFT<br/>LO offset]
|
|
186
|
+
C --> D[Preamble<br/>correlator]
|
|
187
|
+
D --> E[Peak detect<br/>+ non-max<br/>suppression]
|
|
188
|
+
E --> F[Per-preamble<br/>fine offset]
|
|
189
|
+
F --> G[Slot soft LLRs]
|
|
190
|
+
G --> H[Deinterleave<br/>seed brute-force]
|
|
191
|
+
H --> I[Soft Viterbi]
|
|
192
|
+
I --> J{RS + CRC<br/>ok?}
|
|
193
|
+
J -- yes --> K[Assemble<br/>by block_index]
|
|
194
|
+
J -- no --> L[Buffer LLRs;<br/>combine across<br/>R copies]
|
|
195
|
+
L --> I
|
|
196
|
+
K --> M[Strip zero-pad<br/>via length]
|
|
197
|
+
M --> N[stdout bytes]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Every slot is bracketed by a preamble, so any single slot decodes
|
|
201
|
+
standalone. Spurious mid-stream peaks get dropped. Message boundaries
|
|
202
|
+
between separate tx sessions are inferred from non-block-length spans
|
|
203
|
+
between preambles — one rx pipe can watch many tx sessions in a row.
|
|
204
|
+
|
|
205
|
+
### Wire format
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
One tx session (live audio):
|
|
209
|
+
|
|
210
|
+
┌────────┬─────┬────────┬─────┬────────┬─────┬─────┬────────┬─────┬────────┐
|
|
211
|
+
│ pilot │ pre │ slot 0 │ pre │ slot 1 │ pre │ ... │slot N-1│ pre │ pilot │
|
|
212
|
+
└────────┴─────┴────────┴─────┴────────┴─────┴─────┴────────┴─────┴────────┘
|
|
213
|
+
|
|
214
|
+
One RS block, data area (before conv + interleave + FSK):
|
|
215
|
+
|
|
216
|
+
┌── 1B ──┬── 2B ────┬──── rs_data − 3 B ────┬── 4B CRC ──┬── rs_parity B ──┐
|
|
217
|
+
│ length │block_idx │ payload (zero-padded) │ CRC-32 │ RS parity │
|
|
218
|
+
└────────┴──────────┴───────────────────────┴────────────┴─────────────────┘
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`block_idx` is 2 bytes → one tx session is bounded at 65 535 slots.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Testing
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
poetry run pytest -q # ~2 min, full suite
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Every batch-decode test has an e2e-streaming companion that drives audio
|
|
232
|
+
through the same `_StreamingRxPump` the CLI uses.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Glossary
|
|
237
|
+
|
|
238
|
+
- **N-FSK / CPFSK** — N continuous-phase tones, log₂(N) bits per symbol. Default N=4.
|
|
239
|
+
- **Preamble** — Fixed 32-symbol PN sequence bracketing every slot; RX locks timing / frequency / amplitude from it.
|
|
240
|
+
- **Slot** — Preamble + one RS-encoded block.
|
|
241
|
+
- **Block** — RS-encoded chunk carrying header + payload.
|
|
242
|
+
- **RS(n,k)** — Reed-Solomon outer code. k data + parity → n wire bytes; corrects (n-k)/2 byte errors.
|
|
243
|
+
- **CRC-32** — Catches errors past RS correction.
|
|
244
|
+
- **Convolutional code (K=7, r=1/2) + soft Viterbi** — Inner FEC and its decoder, driven by per-bit LLRs.
|
|
245
|
+
- **LLR** — Log-likelihood ratio; a soft (real-valued) confidence per bit instead of a hard 0/1.
|
|
246
|
+
- **Interleaver** — Bit shuffle so bursts become isolated errors. Ours changes every block (32-permutation cycle).
|
|
247
|
+
- **Non-coherent demod** — Tone detection by energy; ~3 dB behind coherent.
|
|
248
|
+
- **LO offset** — Radio frequency error; we correct up to ±500 Hz.
|
|
249
|
+
- **Pilot** — Short random N-FSK burst before / after every live TX.
|
|
250
|
+
- **SNR (dB)** — Signal-to-noise ratio. Negative = noise louder than signal.
|
|
251
|
+
- **AWGN** — Additive white Gaussian noise; the standard clean-channel noise model used by the benchmark.
|
|
252
|
+
- **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.
|
|
253
|
+
- **Best SNR / cliff** — Lowest SNR at which decode still works for a given config. Below it, everything breaks.
|
|
254
|
+
- **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.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Roadmap
|
|
259
|
+
|
|
260
|
+
- **Coherent detection** — Costas-loop demod, ~3 dB gain. Big DSP lift.
|
|
261
|
+
- **LDPC** — Closes ~2–4 dB of the Shannon gap. Needs a proper construction.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT. See `LICENSE`.
|
|
268
|
+
|
|
269
|
+
Reed-Solomon via [`reedsolo`](https://github.com/tomerfiliba-org/reedsolomon).
|
|
270
|
+
Convolutional code uses the standard NASA/CCSDS (171, 133) generator
|
|
271
|
+
polynomials. Audio via [`sounddevice`](https://github.com/spatialaudio/python-sounddevice)
|
|
272
|
+
and [`soundfile`](https://github.com/bastibe/python-soundfile).
|
|
273
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "weaklink-modem"
|
|
3
|
+
version = "0.6.1"
|
|
4
|
+
description = "N-FSK streaming modem for HF SSB and wider channels: Reed-Solomon framing, soft Viterbi, round-robin block repetition, byte-pipe semantics."
|
|
5
|
+
authors = ["ivica3730k"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "readme-pypi.md"
|
|
8
|
+
packages = [{ include = "weaklink/modem" }]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Telecommunications Industry",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: POSIX :: Linux",
|
|
14
|
+
"Operating System :: MacOS",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Communications :: Ham Radio",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.poetry.urls]
|
|
24
|
+
Homepage = "https://github.com/ivica3730k/weaklink-9a3ice"
|
|
25
|
+
Repository = "https://github.com/ivica3730k/weaklink-9a3ice"
|
|
26
|
+
"Bug Tracker" = "https://github.com/ivica3730k/weaklink-9a3ice/issues"
|
|
27
|
+
|
|
28
|
+
[tool.poetry.dependencies]
|
|
29
|
+
python = "^3.10"
|
|
30
|
+
reedsolo = "^1.7.0"
|
|
31
|
+
numpy = "^2.0"
|
|
32
|
+
soundfile = "^0.12"
|
|
33
|
+
sounddevice = "^0.5"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.group.dev.dependencies]
|
|
36
|
+
black = "26.3.1"
|
|
37
|
+
pre-commit = "4.6.0"
|
|
38
|
+
pytest = "9.0.3"
|
|
39
|
+
mypy = "^2.1.0"
|
|
40
|
+
pylint = "^4.0.5"
|
|
41
|
+
setuptools = "82.0.1"
|
|
42
|
+
pytest-xdist = "^3.8.0"
|
|
43
|
+
|
|
44
|
+
[tool.poetry.scripts]
|
|
45
|
+
weaklink-modem = "weaklink.modem.cli:main"
|
|
46
|
+
weaklink-modem-benchmark = "weaklink.modem.benchmark:main"
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["poetry-core>=1.0.0"]
|
|
50
|
+
build-backend = "poetry.core.masonry.api"
|
|
51
|
+
|
|
52
|
+
[tool.black]
|
|
53
|
+
line-length = 120
|
|
54
|
+
target-version = ["py312"]
|
|
55
|
+
include = '\.pyi?$'
|
|
56
|
+
exclude = '''
|
|
57
|
+
/(
|
|
58
|
+
\.git
|
|
59
|
+
| \.venv
|
|
60
|
+
| build
|
|
61
|
+
| venv
|
|
62
|
+
| dist
|
|
63
|
+
)/
|
|
64
|
+
'''
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
addopts = "-n auto"
|
|
68
|
+
|
|
69
|
+
[tool.ruff]
|
|
70
|
+
line-length = 120
|
|
71
|
+
target-version = "py312"
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
# F = pyflakes (unused imports F401, undefined names F821, unused vars, ...)
|
|
75
|
+
# I = isort-compatible import ordering
|
|
76
|
+
select = ["F", "I001"]
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.isort]
|
|
79
|
+
known-first-party = ["weaklink"]
|
|
80
|
+
|
|
81
|
+
[tool.mypy]
|
|
82
|
+
python_version = "3.12"
|
|
83
|
+
files = ["weaklink", "tests"]
|
|
84
|
+
ignore_missing_imports = true
|
|
85
|
+
check_untyped_defs = true
|
|
86
|
+
warn_redundant_casts = true
|
|
87
|
+
warn_unused_ignores = true
|
|
88
|
+
show_error_codes = true
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<!-- generated from README.md at tag v0.6.1 by scripts/build_pypi_readme.py; do not hand-edit -->
|
|
2
|
+
|
|
3
|
+
# weaklink
|
|
4
|
+
|
|
5
|
+
Streaming digital modem: bytes on stdin → audio → bytes on stdout. Works
|
|
6
|
+
with `tail -f`; no memory buffering, no wait-for-EOF.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
Distribution: `weaklink-modem` (PyPI + release binaries).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Portable Linux binary:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
sudo apt install libportaudio2 libsndfile1
|
|
20
|
+
curl -L -O https://github.com/ivica3730k/weaklink-9a3ice/releases/latest/download/weaklink-modem-linux-x86_64-latest
|
|
21
|
+
chmod +x weaklink-modem-linux-x86_64-latest
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Debian / Ubuntu `.deb`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
curl -L -O https://github.com/ivica3730k/weaklink-9a3ice/releases/latest/download/weaklink-modem_amd64-latest.deb
|
|
28
|
+
sudo dpkg -i weaklink-modem_amd64-latest.deb
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From source:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
poetry install
|
|
35
|
+
poetry run weaklink-modem --version
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
PyPI:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install weaklink-modem
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# WAV roundtrip
|
|
50
|
+
echo -n "hello weaklink" | weaklink-modem tx --modem-wav /tmp/hello.wav
|
|
51
|
+
weaklink-modem rx --modem-wav /tmp/hello.wav
|
|
52
|
+
|
|
53
|
+
# Live speaker → mic
|
|
54
|
+
weaklink-modem rx > out.txt &
|
|
55
|
+
echo -n "over the room" | weaklink-modem tx
|
|
56
|
+
|
|
57
|
+
# Long-lived stream
|
|
58
|
+
tail -f /var/log/syslog | weaklink-modem tx --modem-baud 300
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both sides must use the same `--modem-baud` (no handshake).
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Presets
|
|
66
|
+
|
|
67
|
+
Three baud rates. Every preset carries 13 B of payload per RS block
|
|
68
|
+
(RS(16,8) + CRC-32), so message sizes map identically across bauds.
|
|
69
|
+
|
|
70
|
+
| Baud | CLI (both tx / rx) | 4-FSK tones (Hz) | Bandwidth | Default repeats | Measured best SNR | Min live tx (13 B payload) |
|
|
71
|
+
|---:|---|---|---:|---:|---:|---:|
|
|
72
|
+
| 45 | `--modem-baud 45` | 1200 / 1400 / 1600 / 1800 | 600 Hz | 4× | ≈ −14 dB | 28 s |
|
|
73
|
+
| 300 | `--modem-baud 300` | 1050 / 1350 / 1650 / 1950 | 900 Hz | 2× | ≈ −5 dB | 2.4 s |
|
|
74
|
+
| 1200 | `--modem-baud 1200` | 500 / 1700 / 2900 / 4100 | 3600 Hz | 2× | ≈ +2 dB | 1.0 s |
|
|
75
|
+
|
|
76
|
+
SNR is measured with AWGN normalised to a 3 kHz reference band — a
|
|
77
|
+
cross-baud comparison convention, not a physical channel filter.
|
|
78
|
+
|
|
79
|
+
Override `--modem-block-repeats N` on both sides for more copies. Each
|
|
80
|
+
doubling buys ~2–3 dB via soft-LLR combining, at proportional air time.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Debugging live audio
|
|
85
|
+
|
|
86
|
+
`weaklink-modem rx --modem-debug > out.txt` writes diagnostics to
|
|
87
|
+
`log.txt` (stdout stays clean for piping). Watch for:
|
|
88
|
+
|
|
89
|
+
- `audio: peak +X dBFS` below −40 dBFS → wrong mic or gain too low.
|
|
90
|
+
- `RS corrected` — outer code saved a block.
|
|
91
|
+
- `N slot(s) failed CRC/RS` — unrecoverable, data lost.
|
|
92
|
+
- macOS mic AGC / voice-isolation destroys tones; disable in System Settings.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Full SNR sweep
|
|
97
|
+
|
|
98
|
+
Every baud × num_tones × RS × repeats combo is measured in
|
|
99
|
+
[`results.md`](https://github.com/ivica3730k/weaklink-9a3ice/blob/v0.6.1/results.md). Re-run `poetry run weaklink-modem-benchmark` to
|
|
100
|
+
refresh.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Supported audio backends
|
|
105
|
+
|
|
106
|
+
| Backend | Linux | macOS | Windows | Notes |
|
|
107
|
+
|---|:---:|:---:|:---:|---|
|
|
108
|
+
| sounddevice / PortAudio | ✓ | ✓ | ✓* | Default. Index (`5`) or name substring (`USB`). WASAPI / CoreAudio / ALSA under the hood. |
|
|
109
|
+
| 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`). |
|
|
110
|
+
| WAV via soundfile | ✓ | ✓ | ✓ | File I/O only, via `--modem-wav`. |
|
|
111
|
+
|
|
112
|
+
`*` Windows is untested — PortAudio supports it so it should work, but no CI on that platform yet.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## CLI reference
|
|
117
|
+
|
|
118
|
+
| Flag | Default | Description |
|
|
119
|
+
|------|---------|-------------|
|
|
120
|
+
| `--modem-baud N` | `300` | Symbol rate. Only `45`, `300`, `1200` supported. |
|
|
121
|
+
| `--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. |
|
|
122
|
+
| `--modem-rs-data-bytes N` | preset | Reed-Solomon data bytes per block. |
|
|
123
|
+
| `--modem-rs-parity-bytes N` | preset | RS parity bytes. Corrects up to N/2 byte errors per block. |
|
|
124
|
+
| `--modem-no-rs-crc` | CRC on | Skip the CRC-32 inside each RS block. |
|
|
125
|
+
| `--modem-block-repeats N` | preset | N copies per block, each permuted differently; RX soft-combines LLRs. |
|
|
126
|
+
| `--modem-wav PATH` | live | WAV file instead of live audio. |
|
|
127
|
+
| `--modem-audio-output NAME` | OS default | tx audio target: sounddevice index, name substring, or Pulse sink. |
|
|
128
|
+
| `--modem-audio-input NAME` | OS default | rx audio source: same syntax; Pulse sources like `virt.monitor` supported. |
|
|
129
|
+
| `--modem-debug` | off | Verbose DEBUG chatter in the log file. |
|
|
130
|
+
| `--modem-log-file PATH` | `./log.txt` | Diagnostics land here. |
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## How it works
|
|
135
|
+
|
|
136
|
+
### TX signal chain
|
|
137
|
+
|
|
138
|
+
```mermaid
|
|
139
|
+
flowchart LR
|
|
140
|
+
A[stdin bytes] --> B[Chunk<br/>rs_data − 3 B<br/>per block]
|
|
141
|
+
B --> C[Frame<br/>len · idx · payload · pad]
|
|
142
|
+
C --> D[RS + CRC-32]
|
|
143
|
+
D --> E[Conv encode<br/>K=7, r=1/2]
|
|
144
|
+
E --> F[Per-block<br/>interleaver<br/>32-cycle PN]
|
|
145
|
+
F --> G[Bits → 4-FSK<br/>symbols]
|
|
146
|
+
G --> H[CPFSK<br/>modulator]
|
|
147
|
+
H --> I[+ preamble<br/>+ pilot]
|
|
148
|
+
I --> J[audio out]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### RX signal chain
|
|
152
|
+
|
|
153
|
+
```mermaid
|
|
154
|
+
flowchart LR
|
|
155
|
+
A[audio in] --> B[Non-coherent<br/>4-FSK demod]
|
|
156
|
+
B --> C[Coarse FFT<br/>LO offset]
|
|
157
|
+
C --> D[Preamble<br/>correlator]
|
|
158
|
+
D --> E[Peak detect<br/>+ non-max<br/>suppression]
|
|
159
|
+
E --> F[Per-preamble<br/>fine offset]
|
|
160
|
+
F --> G[Slot soft LLRs]
|
|
161
|
+
G --> H[Deinterleave<br/>seed brute-force]
|
|
162
|
+
H --> I[Soft Viterbi]
|
|
163
|
+
I --> J{RS + CRC<br/>ok?}
|
|
164
|
+
J -- yes --> K[Assemble<br/>by block_index]
|
|
165
|
+
J -- no --> L[Buffer LLRs;<br/>combine across<br/>R copies]
|
|
166
|
+
L --> I
|
|
167
|
+
K --> M[Strip zero-pad<br/>via length]
|
|
168
|
+
M --> N[stdout bytes]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Every slot is bracketed by a preamble, so any single slot decodes
|
|
172
|
+
standalone. Spurious mid-stream peaks get dropped. Message boundaries
|
|
173
|
+
between separate tx sessions are inferred from non-block-length spans
|
|
174
|
+
between preambles — one rx pipe can watch many tx sessions in a row.
|
|
175
|
+
|
|
176
|
+
### Wire format
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
One tx session (live audio):
|
|
180
|
+
|
|
181
|
+
┌────────┬─────┬────────┬─────┬────────┬─────┬─────┬────────┬─────┬────────┐
|
|
182
|
+
│ pilot │ pre │ slot 0 │ pre │ slot 1 │ pre │ ... │slot N-1│ pre │ pilot │
|
|
183
|
+
└────────┴─────┴────────┴─────┴────────┴─────┴─────┴────────┴─────┴────────┘
|
|
184
|
+
|
|
185
|
+
One RS block, data area (before conv + interleave + FSK):
|
|
186
|
+
|
|
187
|
+
┌── 1B ──┬── 2B ────┬──── rs_data − 3 B ────┬── 4B CRC ──┬── rs_parity B ──┐
|
|
188
|
+
│ length │block_idx │ payload (zero-padded) │ CRC-32 │ RS parity │
|
|
189
|
+
└────────┴──────────┴───────────────────────┴────────────┴─────────────────┘
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`block_idx` is 2 bytes → one tx session is bounded at 65 535 slots.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
poetry run pytest -q # ~2 min, full suite
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Every batch-decode test has an e2e-streaming companion that drives audio
|
|
203
|
+
through the same `_StreamingRxPump` the CLI uses.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Glossary
|
|
208
|
+
|
|
209
|
+
- **N-FSK / CPFSK** — N continuous-phase tones, log₂(N) bits per symbol. Default N=4.
|
|
210
|
+
- **Preamble** — Fixed 32-symbol PN sequence bracketing every slot; RX locks timing / frequency / amplitude from it.
|
|
211
|
+
- **Slot** — Preamble + one RS-encoded block.
|
|
212
|
+
- **Block** — RS-encoded chunk carrying header + payload.
|
|
213
|
+
- **RS(n,k)** — Reed-Solomon outer code. k data + parity → n wire bytes; corrects (n-k)/2 byte errors.
|
|
214
|
+
- **CRC-32** — Catches errors past RS correction.
|
|
215
|
+
- **Convolutional code (K=7, r=1/2) + soft Viterbi** — Inner FEC and its decoder, driven by per-bit LLRs.
|
|
216
|
+
- **LLR** — Log-likelihood ratio; a soft (real-valued) confidence per bit instead of a hard 0/1.
|
|
217
|
+
- **Interleaver** — Bit shuffle so bursts become isolated errors. Ours changes every block (32-permutation cycle).
|
|
218
|
+
- **Non-coherent demod** — Tone detection by energy; ~3 dB behind coherent.
|
|
219
|
+
- **LO offset** — Radio frequency error; we correct up to ±500 Hz.
|
|
220
|
+
- **Pilot** — Short random N-FSK burst before / after every live TX.
|
|
221
|
+
- **SNR (dB)** — Signal-to-noise ratio. Negative = noise louder than signal.
|
|
222
|
+
- **AWGN** — Additive white Gaussian noise; the standard clean-channel noise model used by the benchmark.
|
|
223
|
+
- **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.
|
|
224
|
+
- **Best SNR / cliff** — Lowest SNR at which decode still works for a given config. Below it, everything breaks.
|
|
225
|
+
- **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.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Roadmap
|
|
230
|
+
|
|
231
|
+
- **Coherent detection** — Costas-loop demod, ~3 dB gain. Big DSP lift.
|
|
232
|
+
- **LDPC** — Closes ~2–4 dB of the Shannon gap. Needs a proper construction.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
MIT. See `LICENSE`.
|
|
239
|
+
|
|
240
|
+
Reed-Solomon via [`reedsolo`](https://github.com/tomerfiliba-org/reedsolomon).
|
|
241
|
+
Convolutional code uses the standard NASA/CCSDS (171, 133) generator
|
|
242
|
+
polynomials. Audio via [`sounddevice`](https://github.com/spatialaudio/python-sounddevice)
|
|
243
|
+
and [`soundfile`](https://github.com/bastibe/python-soundfile).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Weaklink modem: N-FSK + convolutional FEC + soft Viterbi, audio I/O."""
|
|
2
|
+
|
|
3
|
+
from weaklink.modem.api import ModemOptions, build_config, rx, tx
|
|
4
|
+
from weaklink.modem.codec import ModemConfig
|
|
5
|
+
from weaklink.modem.constants import BAUD_PRESETS
|
|
6
|
+
from weaklink.modem.exceptions import (
|
|
7
|
+
ConfigError,
|
|
8
|
+
EncodeError,
|
|
9
|
+
NyquistError,
|
|
10
|
+
PTTError,
|
|
11
|
+
WeaklinkError,
|
|
12
|
+
)
|
|
13
|
+
from weaklink.modem.waveform import WaveformConfig
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# Public API
|
|
17
|
+
"tx",
|
|
18
|
+
"rx",
|
|
19
|
+
"build_config",
|
|
20
|
+
"BAUD_PRESETS",
|
|
21
|
+
# Config objects
|
|
22
|
+
"ModemOptions",
|
|
23
|
+
"ModemConfig",
|
|
24
|
+
"WaveformConfig",
|
|
25
|
+
# Exceptions
|
|
26
|
+
"WeaklinkError",
|
|
27
|
+
"ConfigError",
|
|
28
|
+
"NyquistError",
|
|
29
|
+
"EncodeError",
|
|
30
|
+
"PTTError",
|
|
31
|
+
]
|