weaklink-modem 0.6.1__py3-none-any.whl
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/__init__.py +31 -0
- weaklink/modem/api.py +348 -0
- weaklink/modem/audio.py +394 -0
- weaklink/modem/benchmark.py +319 -0
- weaklink/modem/cli.py +299 -0
- weaklink/modem/codec.py +856 -0
- weaklink/modem/constants.py +62 -0
- weaklink/modem/exceptions.py +31 -0
- weaklink/modem/fec.py +153 -0
- weaklink/modem/interleaver.py +108 -0
- weaklink/modem/ptt.py +66 -0
- weaklink/modem/py.typed +0 -0
- weaklink/modem/rs.py +67 -0
- weaklink/modem/streaming.py +215 -0
- weaklink/modem/waveform.py +265 -0
- weaklink_modem-0.6.1.dist-info/LICENSE +21 -0
- weaklink_modem-0.6.1.dist-info/METADATA +273 -0
- weaklink_modem-0.6.1.dist-info/RECORD +20 -0
- weaklink_modem-0.6.1.dist-info/WHEEL +4 -0
- weaklink_modem-0.6.1.dist-info/entry_points.txt +4 -0
|
@@ -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
|
+
]
|
weaklink/modem/api.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Public Python API for weaklink.modem.
|
|
2
|
+
|
|
3
|
+
Mirrors the CLI 1:1 -- every ``--modem-*`` flag has a matching kwarg
|
|
4
|
+
and every runtime mode (batch samples, WAV read/write, live audio
|
|
5
|
+
in/out, PTT, tune) is available end-to-end. No caller ever needs to
|
|
6
|
+
shuffle audio through their own sounddevice code to reach the modem;
|
|
7
|
+
pass the device name/id and the API drives it.
|
|
8
|
+
|
|
9
|
+
Both ``tx()`` and ``rx()`` route ``weaklink.*`` log records into an
|
|
10
|
+
optional ``logger=`` kwarg for callers who want to stream signal-level
|
|
11
|
+
events (peak / rms, coarse offset, per-slot decode outcomes, RS
|
|
12
|
+
corrections) without wiring their own handlers.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable, Iterable, Iterator
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from weaklink.modem.audio import play_stream, read_wav_chunks, write_wav_stream
|
|
27
|
+
from weaklink.modem.codec import ModemConfig, decode, encode, encode_stream
|
|
28
|
+
from weaklink.modem.constants import (
|
|
29
|
+
BAUD_PRESETS,
|
|
30
|
+
LIVE_TX_PILOT_MIN_SECONDS,
|
|
31
|
+
LIVE_TX_PILOT_MIN_SYMBOLS,
|
|
32
|
+
)
|
|
33
|
+
from weaklink.modem.exceptions import ConfigError
|
|
34
|
+
from weaklink.modem.ptt import hamlib_ptt
|
|
35
|
+
from weaklink.modem.streaming import (
|
|
36
|
+
StreamingRxDecoder,
|
|
37
|
+
live_stream_decode,
|
|
38
|
+
pilot_signal,
|
|
39
|
+
)
|
|
40
|
+
from weaklink.modem.waveform import WaveformConfig, modulate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ModemOptions:
|
|
45
|
+
"""Shared modem-layer parameters used by both ``tx`` and ``rx``.
|
|
46
|
+
Fields left as ``None`` fall back to the ``BAUD_PRESETS`` entry
|
|
47
|
+
for the selected baud."""
|
|
48
|
+
baud: float = 300.0
|
|
49
|
+
num_tones: int = 4
|
|
50
|
+
rs_data_bytes: int | None = None
|
|
51
|
+
rs_parity_bytes: int | None = None
|
|
52
|
+
rs_crc_enabled: bool = True
|
|
53
|
+
block_repeats: int | None = None
|
|
54
|
+
sync_every_blocks: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def _routed_loggers(logger: logging.Logger | None) -> Iterator[None]:
|
|
59
|
+
"""Temporarily route every ``weaklink.*`` log record into ``logger``.
|
|
60
|
+
|
|
61
|
+
Attaches a forwarder to the ``weaklink`` root; children propagate up
|
|
62
|
+
to it by default, so this catches ``weaklink.cli``, ``weaklink.audio``,
|
|
63
|
+
``weaklink.decode``, and any future descendant without a hard-coded
|
|
64
|
+
name list. When ``logger`` is ``None`` this is a no-op.
|
|
65
|
+
"""
|
|
66
|
+
if logger is None:
|
|
67
|
+
yield
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
class _Forwarder(logging.Handler):
|
|
71
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
72
|
+
logger.handle(record)
|
|
73
|
+
|
|
74
|
+
root = logging.getLogger("weaklink")
|
|
75
|
+
handler = _Forwarder()
|
|
76
|
+
prior_handlers = root.handlers.copy()
|
|
77
|
+
prior_propagate = root.propagate
|
|
78
|
+
prior_level = root.level
|
|
79
|
+
try:
|
|
80
|
+
root.handlers = [handler]
|
|
81
|
+
root.propagate = False
|
|
82
|
+
wanted_level = logger.level or logging.DEBUG
|
|
83
|
+
if root.level == logging.NOTSET or root.level > wanted_level:
|
|
84
|
+
root.setLevel(wanted_level)
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
root.handlers = prior_handlers
|
|
88
|
+
root.propagate = prior_propagate
|
|
89
|
+
root.setLevel(prior_level)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_config(
|
|
93
|
+
*,
|
|
94
|
+
baud: float = 300.0,
|
|
95
|
+
num_tones: int = 4,
|
|
96
|
+
rs_data_bytes: int | None = None,
|
|
97
|
+
rs_parity_bytes: int | None = None,
|
|
98
|
+
rs_crc_enabled: bool = True,
|
|
99
|
+
block_repeats: int | None = None,
|
|
100
|
+
sync_every_blocks: int | None = None,
|
|
101
|
+
tone_spacing_hz: float | None = None,
|
|
102
|
+
tx_volume: int = 100,
|
|
103
|
+
) -> ModemConfig:
|
|
104
|
+
"""Assemble a ``ModemConfig`` from CLI-equivalent parameters,
|
|
105
|
+
filling unset preset-driven knobs from ``BAUD_PRESETS``. Same
|
|
106
|
+
resolution the CLI does."""
|
|
107
|
+
if baud not in BAUD_PRESETS:
|
|
108
|
+
raise ConfigError(f"baud {baud} is not supported; use one of {sorted(BAUD_PRESETS.keys())}")
|
|
109
|
+
preset = BAUD_PRESETS[baud]
|
|
110
|
+
if not 0 <= tx_volume <= 100:
|
|
111
|
+
raise ConfigError(f"tx_volume must be 0-100 (got {tx_volume})")
|
|
112
|
+
return ModemConfig(
|
|
113
|
+
waveform=WaveformConfig(
|
|
114
|
+
baud=baud,
|
|
115
|
+
tone_spacing_hz=tone_spacing_hz if tone_spacing_hz is not None else preset["tone_spacing_hz"],
|
|
116
|
+
num_tones=num_tones,
|
|
117
|
+
amplitude=tx_volume / 100.0,
|
|
118
|
+
),
|
|
119
|
+
rs_data_bytes=rs_data_bytes if rs_data_bytes is not None else int(preset["rs_data_bytes"]),
|
|
120
|
+
rs_parity_bytes=rs_parity_bytes if rs_parity_bytes is not None else int(preset["rs_parity_bytes"]),
|
|
121
|
+
rs_crc_enabled=rs_crc_enabled,
|
|
122
|
+
sync_every_blocks=sync_every_blocks if sync_every_blocks is not None else int(preset["sync_every_blocks"]),
|
|
123
|
+
block_repeats=block_repeats if block_repeats is not None else int(preset["block_repeats"]),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _tx_sample_iterator(config: ModemConfig, data: bytes | Iterable[bytes]) -> Iterator[np.ndarray]:
|
|
128
|
+
"""Leading pilot -> encoded blocks -> trailing pilot. Same pilot
|
|
129
|
+
sizing rules as the CLI."""
|
|
130
|
+
leading_pilot_seconds = max(
|
|
131
|
+
LIVE_TX_PILOT_MIN_SECONDS,
|
|
132
|
+
LIVE_TX_PILOT_MIN_SYMBOLS / config.waveform.baud,
|
|
133
|
+
)
|
|
134
|
+
leading_pilot = pilot_signal(config, leading_pilot_seconds).astype(np.float32)
|
|
135
|
+
trailing_pilot = leading_pilot
|
|
136
|
+
if isinstance(data, (bytes, bytearray)):
|
|
137
|
+
chunks: Iterable[bytes] = [bytes(data)]
|
|
138
|
+
else:
|
|
139
|
+
chunks = data
|
|
140
|
+
yield leading_pilot
|
|
141
|
+
for audio in encode_stream(iter(chunks), config):
|
|
142
|
+
yield audio
|
|
143
|
+
yield trailing_pilot
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def tx(
|
|
147
|
+
data: bytes | Iterable[bytes] | None = None,
|
|
148
|
+
*,
|
|
149
|
+
baud: float = 300.0,
|
|
150
|
+
num_tones: int = 4,
|
|
151
|
+
rs_data_bytes: int | None = None,
|
|
152
|
+
rs_parity_bytes: int | None = None,
|
|
153
|
+
rs_crc_enabled: bool = True,
|
|
154
|
+
block_repeats: int | None = None,
|
|
155
|
+
sync_every_blocks: int | None = None,
|
|
156
|
+
tone_spacing_hz: float | None = None,
|
|
157
|
+
tx_volume: int = 100,
|
|
158
|
+
wav: str | Path | None = None,
|
|
159
|
+
audio_output: str | None = None,
|
|
160
|
+
ptt: str | None = None,
|
|
161
|
+
tune: bool = False,
|
|
162
|
+
logger: logging.Logger | None = None,
|
|
163
|
+
) -> np.ndarray | None:
|
|
164
|
+
"""Encode ``data`` and dispatch to the requested sink.
|
|
165
|
+
|
|
166
|
+
Sink selection (pick one; mirrors CLI):
|
|
167
|
+
* ``wav=<path>`` write to WAV, return ``None``.
|
|
168
|
+
* ``audio_output=<device>`` stream live to the device, return ``None``.
|
|
169
|
+
* ``tune=True`` ignore ``data``, emit tone cycle to the
|
|
170
|
+
audio device until interrupted, return ``None``.
|
|
171
|
+
* *(none of the above)* return the encoded samples as ``ndarray``.
|
|
172
|
+
|
|
173
|
+
``audio_output`` accepts the same syntax as ``--modem-audio-output``:
|
|
174
|
+
sounddevice index, name substring, Pulse sink name, ``pulse:<id>``,
|
|
175
|
+
or a bare numeric Pulse id resolvable by pactl. ``ptt`` takes the
|
|
176
|
+
same rigctld ``host:port`` (or ``None`` to skip PTT) that
|
|
177
|
+
``--hamlib-ptt`` does.
|
|
178
|
+
"""
|
|
179
|
+
if wav is not None and audio_output is not None:
|
|
180
|
+
raise ConfigError("pass either wav= or audio_output=, not both")
|
|
181
|
+
if wav is not None and ptt is not None:
|
|
182
|
+
raise ConfigError("ptt is only valid with live audio TX; drop wav or ptt")
|
|
183
|
+
if tune and wav is not None:
|
|
184
|
+
raise ConfigError("tune=True is a live-audio-only operation; drop wav")
|
|
185
|
+
|
|
186
|
+
config = build_config(
|
|
187
|
+
baud=baud,
|
|
188
|
+
num_tones=num_tones,
|
|
189
|
+
rs_data_bytes=rs_data_bytes,
|
|
190
|
+
rs_parity_bytes=rs_parity_bytes,
|
|
191
|
+
rs_crc_enabled=rs_crc_enabled,
|
|
192
|
+
block_repeats=block_repeats,
|
|
193
|
+
sync_every_blocks=sync_every_blocks,
|
|
194
|
+
tone_spacing_hz=tone_spacing_hz,
|
|
195
|
+
tx_volume=tx_volume,
|
|
196
|
+
)
|
|
197
|
+
with _routed_loggers(logger):
|
|
198
|
+
if tune:
|
|
199
|
+
_tx_tune(config, audio_output=audio_output, ptt=ptt)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
if data is None:
|
|
203
|
+
raise ConfigError("data= is required unless tune=True")
|
|
204
|
+
|
|
205
|
+
if wav is not None:
|
|
206
|
+
write_wav_stream(str(wav), _tx_sample_iterator(config, data), config.waveform.sample_rate)
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
if audio_output is not None:
|
|
210
|
+
with hamlib_ptt(ptt):
|
|
211
|
+
play_stream(
|
|
212
|
+
_tx_sample_iterator(config, data),
|
|
213
|
+
config.waveform.sample_rate,
|
|
214
|
+
device=audio_output,
|
|
215
|
+
)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# Batch: no sink chosen, return the encoded ndarray.
|
|
219
|
+
payload = data if isinstance(data, (bytes, bytearray)) else b"".join(data)
|
|
220
|
+
return encode(bytes(payload), config)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _tx_tune(
|
|
224
|
+
config: ModemConfig,
|
|
225
|
+
*,
|
|
226
|
+
audio_output: str | None,
|
|
227
|
+
ptt: str | None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Emit every tone of the mode in round-robin, cycling until Ctrl-C."""
|
|
230
|
+
cycle_symbols = np.arange(config.waveform.num_tones, dtype=np.int64)
|
|
231
|
+
|
|
232
|
+
def _cycles() -> Iterator[np.ndarray]:
|
|
233
|
+
while True:
|
|
234
|
+
yield modulate(cycle_symbols, config.waveform).astype(np.float32)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
with hamlib_ptt(ptt):
|
|
238
|
+
play_stream(_cycles(), config.waveform.sample_rate, device=audio_output)
|
|
239
|
+
except KeyboardInterrupt:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class _CallbackWriter:
|
|
244
|
+
"""Duck-typed file-like: `.write(bytes)` -> callback. Used to feed
|
|
245
|
+
``_StreamingRxDecoder``'s decoded bytes to an ``on_bytes`` callable."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, callback: Callable[[bytes], None]) -> None:
|
|
248
|
+
self._callback = callback
|
|
249
|
+
|
|
250
|
+
def write(self, data: bytes) -> int:
|
|
251
|
+
if data:
|
|
252
|
+
self._callback(bytes(data))
|
|
253
|
+
return len(data)
|
|
254
|
+
|
|
255
|
+
def flush(self) -> None: # noqa: D401
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def rx(
|
|
260
|
+
samples: np.ndarray | None = None,
|
|
261
|
+
*,
|
|
262
|
+
baud: float = 300.0,
|
|
263
|
+
num_tones: int = 4,
|
|
264
|
+
rs_data_bytes: int | None = None,
|
|
265
|
+
rs_parity_bytes: int | None = None,
|
|
266
|
+
rs_crc_enabled: bool = True,
|
|
267
|
+
block_repeats: int | None = None,
|
|
268
|
+
sync_every_blocks: int | None = None,
|
|
269
|
+
tone_spacing_hz: float | None = None,
|
|
270
|
+
wav: str | Path | None = None,
|
|
271
|
+
audio_input: str | None = None,
|
|
272
|
+
on_bytes: Callable[[bytes], None] | None = None,
|
|
273
|
+
logger: logging.Logger | None = None,
|
|
274
|
+
) -> bytes | None:
|
|
275
|
+
"""Decode from the requested source.
|
|
276
|
+
|
|
277
|
+
Source selection (pick one; mirrors CLI):
|
|
278
|
+
* ``samples=<ndarray>`` batch decode, return ``bytes``.
|
|
279
|
+
* ``wav=<path>`` read the WAV, decode, return ``bytes``.
|
|
280
|
+
If ``on_bytes=`` is given, chunks are
|
|
281
|
+
also fed to the callback as they land.
|
|
282
|
+
* ``audio_input=<device>`` stream live from the device; blocks
|
|
283
|
+
until KeyboardInterrupt. Decoded bytes
|
|
284
|
+
go to ``on_bytes(chunk)`` if set, else
|
|
285
|
+
``sys.stdout.buffer``. Return value is
|
|
286
|
+
``None`` (streaming has no batch return).
|
|
287
|
+
|
|
288
|
+
``audio_input`` accepts the same syntax as ``--modem-audio-input``.
|
|
289
|
+
``on_bytes`` receives ``bytes`` chunks in stream order.
|
|
290
|
+
"""
|
|
291
|
+
config = build_config(
|
|
292
|
+
baud=baud,
|
|
293
|
+
num_tones=num_tones,
|
|
294
|
+
rs_data_bytes=rs_data_bytes,
|
|
295
|
+
rs_parity_bytes=rs_parity_bytes,
|
|
296
|
+
rs_crc_enabled=rs_crc_enabled,
|
|
297
|
+
block_repeats=block_repeats,
|
|
298
|
+
sync_every_blocks=sync_every_blocks,
|
|
299
|
+
tone_spacing_hz=tone_spacing_hz,
|
|
300
|
+
)
|
|
301
|
+
with _routed_loggers(logger):
|
|
302
|
+
if samples is not None:
|
|
303
|
+
if wav is not None or audio_input is not None:
|
|
304
|
+
raise ConfigError("pass at most one of samples=, wav=, audio_input=")
|
|
305
|
+
decoded = decode(samples, config)
|
|
306
|
+
if on_bytes and decoded:
|
|
307
|
+
on_bytes(bytes(decoded))
|
|
308
|
+
return decoded
|
|
309
|
+
|
|
310
|
+
if wav is not None:
|
|
311
|
+
if audio_input is not None:
|
|
312
|
+
raise ConfigError("pass at most one of samples=, wav=, audio_input=")
|
|
313
|
+
return _rx_from_wav(config, wav, on_bytes=on_bytes)
|
|
314
|
+
|
|
315
|
+
if audio_input is not None:
|
|
316
|
+
sink = _CallbackWriter(on_bytes) if on_bytes is not None else sys.stdout.buffer
|
|
317
|
+
live_stream_decode(config, audio_input=audio_input, output=sink)
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
raise ConfigError("rx() requires one of samples=, wav=, audio_input=")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _rx_from_wav(
|
|
324
|
+
config: ModemConfig,
|
|
325
|
+
wav_path: str | Path,
|
|
326
|
+
*,
|
|
327
|
+
on_bytes: Callable[[bytes], None] | None,
|
|
328
|
+
) -> bytes:
|
|
329
|
+
"""WAV read -> streaming pump -> bytes. Also feeds each chunk to
|
|
330
|
+
``on_bytes`` as it lands so callers get incremental output."""
|
|
331
|
+
collected = bytearray()
|
|
332
|
+
|
|
333
|
+
def _emit(data: bytes) -> None:
|
|
334
|
+
collected.extend(data)
|
|
335
|
+
if on_bytes:
|
|
336
|
+
on_bytes(bytes(data))
|
|
337
|
+
|
|
338
|
+
pump = StreamingRxDecoder(config, output=_CallbackWriter(_emit))
|
|
339
|
+
for chunk in read_wav_chunks(
|
|
340
|
+
str(wav_path),
|
|
341
|
+
chunk_seconds=0.1,
|
|
342
|
+
expected_sample_rate=config.waveform.sample_rate,
|
|
343
|
+
):
|
|
344
|
+
pump.push(chunk)
|
|
345
|
+
pump.drain()
|
|
346
|
+
return bytes(collected)
|
|
347
|
+
|
|
348
|
+
|