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.
@@ -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
+