ka9q-python 3.14.1__tar.gz → 3.15.0__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.
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/CHANGELOG.md +67 -0
- {ka9q_python-3.14.1/ka9q_python.egg-info → ka9q_python-3.15.0}/PKG-INFO +3 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/API_REFERENCE.md +16 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/__init__.py +1 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/compat.py +1 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/control.py +18 -146
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/multi_stream.py +10 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/rtp_recorder.py +22 -6
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/stream.py +149 -15
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/types.py +1 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0/ka9q_python.egg-info}/PKG-INFO +3 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q_python.egg-info/SOURCES.txt +1 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q_python.egg-info/requires.txt +3 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q_radio_compat +1 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/pyproject.toml +4 -1
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_filter_edges.py +1 -0
- ka9q_python-3.15.0/tests/test_parse_rtp_samples_iq.py +118 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_rtp_recorder.py +64 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/LICENSE +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/MANIFEST.in +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/README.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/tune.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/status.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/setup.cfg +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/setup.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/__init__.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/conftest.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_multicast_helpers.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.14.1 → ka9q_python-3.15.0}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.15.0] - 2026-05-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **F16LE / F16BE RTP payload decoding** in `parse_rtp_samples` for radiod's
|
|
8
|
+
float16 output mode (encodings 6 and 9). Both audio and IQ paths.
|
|
9
|
+
- **G.711 µ-law / A-law decoders** for encodings 10 and 11 — pure-numpy
|
|
10
|
+
table-based, no `audioop` dependency (which was removed in Python 3.13).
|
|
11
|
+
- **`OpusDecoder` class** in `ka9q.stream` for OPUS / OPUS_VOIP payloads
|
|
12
|
+
(encodings 3 and 7). Lazy-imports `opuslib`; install via
|
|
13
|
+
`pip install ka9q-python[opus]`. Maintains codec state across calls so
|
|
14
|
+
packet-loss concealment works end-to-end — one instance per stream SSRC.
|
|
15
|
+
- New `opus` optional dependency in `pyproject.toml` (`opuslib>=3.0`).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`RadiodControl.set_agc(attack_rate=...)` was unreachable.** It encoded
|
|
20
|
+
`StatusType.AGC_ATTACK_RATE`, which does not exist in ka9q-radio's
|
|
21
|
+
`status.h` or in `ka9q/types.py` — any call passing `attack_rate=` raised
|
|
22
|
+
`AttributeError`. Replaced with a working `threshold:` kwarg backed by
|
|
23
|
+
`AGC_THRESHOLD` (which radiod actually decodes in
|
|
24
|
+
`decode_radio_commands()`).
|
|
25
|
+
- **Removed 6 stale duplicate `set_*` methods.** In a single class, second
|
|
26
|
+
defs silently shadow firsts — so callers were already using the correct
|
|
27
|
+
versions further down the file. The dead first defs of `set_squelch`,
|
|
28
|
+
`set_pll`, `set_output_channels`, `set_independent_sideband`,
|
|
29
|
+
`set_envelope_detection`, and `set_opus_bitrate` are gone. One of them
|
|
30
|
+
(`set_opus_bitrate`) referenced a non-existent `StatusType.OPUS_BITRATE`
|
|
31
|
+
— the working second def uses the correct `OPUS_BIT_RATE`.
|
|
32
|
+
|
|
33
|
+
### Tooling / pinning
|
|
34
|
+
|
|
35
|
+
- Pin advanced from ka9q-radio `f78cff9c` (1.0.0-22) to `d555f1853422`.
|
|
36
|
+
Drift report confirms zero TLV-tag, encoding-enum, demod-type, or
|
|
37
|
+
window-type changes across the 68 intervening upstream commits — only
|
|
38
|
+
packaging, hydrasdr-driver, and internal C-struct refactors. Existing
|
|
39
|
+
ka9q-python state (`types.py`, `decode_status_packet`, `SpectrumStream`,
|
|
40
|
+
every `set_*` method) was already covering the full HEAD protocol
|
|
41
|
+
surface; this release brings the recorded compatibility tag in line.
|
|
42
|
+
|
|
43
|
+
### Tests
|
|
44
|
+
|
|
45
|
+
- Fixed pre-existing `tests/test_filter_edges.py::_bare_control` helper
|
|
46
|
+
that did not initialise the `client_id` attribute added with v3.14.0
|
|
47
|
+
per-(client,radiod) multicast destinations.
|
|
48
|
+
|
|
49
|
+
## [3.14.2] - 2026-05-14
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **`rtp_to_wallclock()` gains optional `wallclock_hint_sec` parameter.**
|
|
54
|
+
When supplied, the function uses the hint to disambiguate the 32-bit
|
|
55
|
+
RTP wrap epoch instead of calling `time.time()`. Authority-aware
|
|
56
|
+
callers (those with access to an hf-timestd `rtp_to_utc_offset_ns`)
|
|
57
|
+
can now keep the labeling path off the chrony-disciplined system
|
|
58
|
+
clock, per the METROLOGY.md §4.5 RTP-reference invariant. The hint
|
|
59
|
+
only needs ±period/2 accuracy (≥6 hours at typical sample rates).
|
|
60
|
+
When omitted, the function falls back to `time.time()` for backward
|
|
61
|
+
compatibility — existing callers are unaffected.
|
|
62
|
+
|
|
63
|
+
### Tests
|
|
64
|
+
|
|
65
|
+
- 3 new in `tests/test_rtp_recorder.py`: hint bypasses `time.time()`
|
|
66
|
+
entirely; hint at a different wrap epoch correctly overrides system
|
|
67
|
+
clock; default path still consults `time.time()` when no hint is
|
|
68
|
+
given.
|
|
69
|
+
|
|
3
70
|
## [3.14.1] - 2026-05-14
|
|
4
71
|
|
|
5
72
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ka9q-python
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.15.0
|
|
4
4
|
Summary: Python interface for ka9q-radio control and monitoring
|
|
5
5
|
Home-page: https://github.com/mijahauan/ka9q-python
|
|
6
6
|
Author: Michael Hauan AC0G
|
|
@@ -30,6 +30,8 @@ Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
|
30
30
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
31
31
|
Provides-Extra: tui
|
|
32
32
|
Requires-Dist: textual>=0.50; extra == "tui"
|
|
33
|
+
Provides-Extra: opus
|
|
34
|
+
Requires-Dist: opuslib>=3.0; extra == "opus"
|
|
33
35
|
Dynamic: author
|
|
34
36
|
Dynamic: home-page
|
|
35
37
|
Dynamic: license-file
|
|
@@ -727,7 +727,11 @@ Dataclass: `packets_received`, `packets_dropped`,
|
|
|
727
727
|
|
|
728
728
|
```python
|
|
729
729
|
parse_rtp_header(data: bytes) -> Optional[RTPHeader]
|
|
730
|
-
rtp_to_wallclock(
|
|
730
|
+
rtp_to_wallclock(
|
|
731
|
+
rtp_timestamp: int,
|
|
732
|
+
channel: ChannelInfo,
|
|
733
|
+
wallclock_hint_sec: Optional[float] = None,
|
|
734
|
+
) -> Optional[float]
|
|
731
735
|
```
|
|
732
736
|
|
|
733
737
|
`rtp_to_wallclock()` returns `None` unless `channel.gps_time` and
|
|
@@ -735,6 +739,17 @@ rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]
|
|
|
735
739
|
`channel.chain_delay_correction_ns` is set (see below), it is
|
|
736
740
|
subtracted from the computed wallclock.
|
|
737
741
|
|
|
742
|
+
The `wallclock_hint_sec` parameter (v3.14.2+) lets callers supply an
|
|
743
|
+
approximate UTC reference for 32-bit RTP wrap-epoch disambiguation
|
|
744
|
+
without falling back to `time.time()`. Authority-aware callers
|
|
745
|
+
(e.g. those reading `rtp_to_utc_offset_ns` from an `hf-timestd`
|
|
746
|
+
authority.json) should pass this hint to keep the labeling path off
|
|
747
|
+
the chrony-disciplined system clock (METROLOGY.md §4.5 RTP-reference
|
|
748
|
+
invariant). The hint only needs ±period/2 accuracy (≥6 hours at
|
|
749
|
+
typical sample rates), so even a coarse value is sufficient. When
|
|
750
|
+
omitted, the function falls back to `time.time()` for backward
|
|
751
|
+
compatibility.
|
|
752
|
+
|
|
738
753
|
---
|
|
739
754
|
|
|
740
755
|
## L6 BPSK PPS Calibration
|
|
@@ -973,23 +973,27 @@ class RadiodControl:
|
|
|
973
973
|
logger.info(f"Setting sample rate for SSRC {ssrc} to {sample_rate} Hz")
|
|
974
974
|
self.send_command(cmdbuffer)
|
|
975
975
|
|
|
976
|
-
def set_agc(self, ssrc: int, enable: bool, hangtime: Optional[float] = None,
|
|
976
|
+
def set_agc(self, ssrc: int, enable: bool, hangtime: Optional[float] = None,
|
|
977
977
|
headroom: Optional[float] = None, recovery_rate: Optional[float] = None,
|
|
978
|
-
|
|
978
|
+
threshold: Optional[float] = None):
|
|
979
979
|
"""
|
|
980
|
-
Configure AGC (Automatic Gain Control) for a channel
|
|
981
|
-
|
|
980
|
+
Configure AGC (Automatic Gain Control) for a channel.
|
|
981
|
+
|
|
982
|
+
radiod exposes four AGC knobs: enable, hangtime, headroom, recovery_rate,
|
|
983
|
+
and threshold. There is no separate "attack rate" — see radio_status.c
|
|
984
|
+
decode_radio_commands().
|
|
985
|
+
|
|
982
986
|
Args:
|
|
983
987
|
ssrc: SSRC of the channel
|
|
984
988
|
enable: Enable/disable AGC (True=enabled, False=manual gain)
|
|
985
|
-
hangtime: AGC hang time in seconds
|
|
986
|
-
headroom: Target headroom in dB (
|
|
987
|
-
recovery_rate: AGC recovery rate
|
|
988
|
-
|
|
989
|
+
hangtime: AGC hang time in seconds
|
|
990
|
+
headroom: Target headroom in dB (positive; radiod negates internally)
|
|
991
|
+
recovery_rate: AGC recovery rate in dB/sec
|
|
992
|
+
threshold: AGC threshold in dB
|
|
989
993
|
"""
|
|
990
994
|
cmdbuffer = bytearray()
|
|
991
995
|
cmdbuffer.append(CMD)
|
|
992
|
-
|
|
996
|
+
|
|
993
997
|
encode_int(cmdbuffer, StatusType.AGC_ENABLE, 1 if enable else 0)
|
|
994
998
|
if hangtime is not None:
|
|
995
999
|
encode_float(cmdbuffer, StatusType.AGC_HANGTIME, hangtime)
|
|
@@ -997,14 +1001,14 @@ class RadiodControl:
|
|
|
997
1001
|
encode_float(cmdbuffer, StatusType.HEADROOM, headroom)
|
|
998
1002
|
if recovery_rate is not None:
|
|
999
1003
|
encode_float(cmdbuffer, StatusType.AGC_RECOVERY_RATE, recovery_rate)
|
|
1000
|
-
if
|
|
1001
|
-
encode_float(cmdbuffer, StatusType.
|
|
1002
|
-
|
|
1004
|
+
if threshold is not None:
|
|
1005
|
+
encode_float(cmdbuffer, StatusType.AGC_THRESHOLD, threshold)
|
|
1006
|
+
|
|
1003
1007
|
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1004
1008
|
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1005
1009
|
encode_eol(cmdbuffer)
|
|
1006
|
-
|
|
1007
|
-
logger.info(f"Setting AGC for SSRC {ssrc}: enable={enable}
|
|
1010
|
+
|
|
1011
|
+
logger.info(f"Setting AGC for SSRC {ssrc}: enable={enable}")
|
|
1008
1012
|
self.send_command(cmdbuffer)
|
|
1009
1013
|
|
|
1010
1014
|
def set_gain(self, ssrc: int, gain_db: float):
|
|
@@ -1710,138 +1714,6 @@ class RadiodControl:
|
|
|
1710
1714
|
logger.info(f"Setting LIFETIME for SSRC {ssrc} to {lifetime} frames")
|
|
1711
1715
|
self.send_command(cmdbuffer)
|
|
1712
1716
|
|
|
1713
|
-
def set_squelch(self, ssrc: int, open_threshold: Optional[float] = None,
|
|
1714
|
-
close_threshold: Optional[float] = None, snr_squelch: Optional[bool] = None):
|
|
1715
|
-
"""
|
|
1716
|
-
Set squelch parameters for a channel
|
|
1717
|
-
|
|
1718
|
-
Args:
|
|
1719
|
-
ssrc: SSRC of the channel
|
|
1720
|
-
open_threshold: Squelch open threshold in dB (optional)
|
|
1721
|
-
close_threshold: Squelch close threshold in dB (optional)
|
|
1722
|
-
snr_squelch: Enable SNR-based squelch (optional)
|
|
1723
|
-
"""
|
|
1724
|
-
cmdbuffer = bytearray()
|
|
1725
|
-
cmdbuffer.append(CMD)
|
|
1726
|
-
|
|
1727
|
-
if open_threshold is not None:
|
|
1728
|
-
encode_float(cmdbuffer, StatusType.SQUELCH_OPEN, open_threshold)
|
|
1729
|
-
if close_threshold is not None:
|
|
1730
|
-
encode_float(cmdbuffer, StatusType.SQUELCH_CLOSE, close_threshold)
|
|
1731
|
-
if snr_squelch is not None:
|
|
1732
|
-
encode_int(cmdbuffer, StatusType.SNR_SQUELCH, 1 if snr_squelch else 0)
|
|
1733
|
-
|
|
1734
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1735
|
-
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1736
|
-
encode_eol(cmdbuffer)
|
|
1737
|
-
|
|
1738
|
-
logger.info(f"Setting squelch for SSRC {ssrc}")
|
|
1739
|
-
self.send_command(cmdbuffer)
|
|
1740
|
-
|
|
1741
|
-
def set_pll(self, ssrc: int, enable: Optional[bool] = None,
|
|
1742
|
-
bandwidth: Optional[float] = None, square: Optional[bool] = None):
|
|
1743
|
-
"""
|
|
1744
|
-
Set PLL parameters for a channel
|
|
1745
|
-
|
|
1746
|
-
Args:
|
|
1747
|
-
ssrc: SSRC of the channel
|
|
1748
|
-
enable: Enable/disable PLL (optional)
|
|
1749
|
-
bandwidth: PLL bandwidth in Hz (optional)
|
|
1750
|
-
square: Enable square-wave PLL output (optional)
|
|
1751
|
-
"""
|
|
1752
|
-
cmdbuffer = bytearray()
|
|
1753
|
-
cmdbuffer.append(CMD)
|
|
1754
|
-
|
|
1755
|
-
if enable is not None:
|
|
1756
|
-
encode_int(cmdbuffer, StatusType.PLL_ENABLE, 1 if enable else 0)
|
|
1757
|
-
if bandwidth is not None:
|
|
1758
|
-
encode_float(cmdbuffer, StatusType.PLL_BW, bandwidth)
|
|
1759
|
-
if square is not None:
|
|
1760
|
-
encode_int(cmdbuffer, StatusType.PLL_SQUARE, 1 if square else 0)
|
|
1761
|
-
|
|
1762
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1763
|
-
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1764
|
-
encode_eol(cmdbuffer)
|
|
1765
|
-
|
|
1766
|
-
logger.info(f"Setting PLL for SSRC {ssrc}")
|
|
1767
|
-
self.send_command(cmdbuffer)
|
|
1768
|
-
|
|
1769
|
-
def set_output_channels(self, ssrc: int, channels: int):
|
|
1770
|
-
"""
|
|
1771
|
-
Set number of output channels (mono/stereo)
|
|
1772
|
-
|
|
1773
|
-
Args:
|
|
1774
|
-
ssrc: SSRC of the channel
|
|
1775
|
-
channels: Number of channels (1=mono, 2=stereo)
|
|
1776
|
-
"""
|
|
1777
|
-
cmdbuffer = bytearray()
|
|
1778
|
-
cmdbuffer.append(CMD)
|
|
1779
|
-
|
|
1780
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_CHANNELS, channels)
|
|
1781
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1782
|
-
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1783
|
-
encode_eol(cmdbuffer)
|
|
1784
|
-
|
|
1785
|
-
logger.info(f"Setting output channels for SSRC {ssrc} to {channels}")
|
|
1786
|
-
self.send_command(cmdbuffer)
|
|
1787
|
-
|
|
1788
|
-
def set_independent_sideband(self, ssrc: int, enable: bool):
|
|
1789
|
-
"""
|
|
1790
|
-
Enable/disable independent sideband (ISB) mode
|
|
1791
|
-
|
|
1792
|
-
Args:
|
|
1793
|
-
ssrc: SSRC of the channel
|
|
1794
|
-
enable: Enable ISB mode
|
|
1795
|
-
"""
|
|
1796
|
-
cmdbuffer = bytearray()
|
|
1797
|
-
cmdbuffer.append(CMD)
|
|
1798
|
-
|
|
1799
|
-
encode_int(cmdbuffer, StatusType.INDEPENDENT_SIDEBAND, 1 if enable else 0)
|
|
1800
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1801
|
-
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1802
|
-
encode_eol(cmdbuffer)
|
|
1803
|
-
|
|
1804
|
-
logger.info(f"Setting ISB for SSRC {ssrc} to {enable}")
|
|
1805
|
-
self.send_command(cmdbuffer)
|
|
1806
|
-
|
|
1807
|
-
def set_envelope_detection(self, ssrc: int, enable: bool):
|
|
1808
|
-
"""
|
|
1809
|
-
Enable/disable envelope detection for AM
|
|
1810
|
-
|
|
1811
|
-
Args:
|
|
1812
|
-
ssrc: SSRC of the channel
|
|
1813
|
-
enable: Enable envelope detection
|
|
1814
|
-
"""
|
|
1815
|
-
cmdbuffer = bytearray()
|
|
1816
|
-
cmdbuffer.append(CMD)
|
|
1817
|
-
|
|
1818
|
-
encode_int(cmdbuffer, StatusType.ENVELOPE, 1 if enable else 0)
|
|
1819
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1820
|
-
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1821
|
-
encode_eol(cmdbuffer)
|
|
1822
|
-
|
|
1823
|
-
logger.info(f"Setting envelope detection for SSRC {ssrc} to {enable}")
|
|
1824
|
-
self.send_command(cmdbuffer)
|
|
1825
|
-
|
|
1826
|
-
def set_opus_bitrate(self, ssrc: int, bitrate: int):
|
|
1827
|
-
"""
|
|
1828
|
-
Set Opus codec bitrate
|
|
1829
|
-
|
|
1830
|
-
Args:
|
|
1831
|
-
ssrc: SSRC of the channel
|
|
1832
|
-
bitrate: Bitrate in bits per second (6000-510000)
|
|
1833
|
-
"""
|
|
1834
|
-
cmdbuffer = bytearray()
|
|
1835
|
-
cmdbuffer.append(CMD)
|
|
1836
|
-
|
|
1837
|
-
encode_int(cmdbuffer, StatusType.OPUS_BITRATE, bitrate)
|
|
1838
|
-
encode_int(cmdbuffer, StatusType.OUTPUT_SSRC, ssrc)
|
|
1839
|
-
encode_int(cmdbuffer, StatusType.COMMAND_TAG, secrets.randbits(31))
|
|
1840
|
-
encode_eol(cmdbuffer)
|
|
1841
|
-
|
|
1842
|
-
logger.info(f"Setting Opus bitrate for SSRC {ssrc} to {bitrate}")
|
|
1843
|
-
self.send_command(cmdbuffer)
|
|
1844
|
-
|
|
1845
1717
|
def _setup_status_listener(self):
|
|
1846
1718
|
"""Set up socket to listen for status responses"""
|
|
1847
1719
|
# Create a separate socket for receiving status messages
|
|
@@ -201,12 +201,21 @@ class MultiStream:
|
|
|
201
201
|
ssrc = channel_info.ssrc
|
|
202
202
|
is_iq = preset.lower() in ("iq", "spectrum")
|
|
203
203
|
|
|
204
|
+
# Use the encoding radiod actually granted (channel_info.encoding)
|
|
205
|
+
# rather than the encoding we requested. radiod silently downgrades
|
|
206
|
+
# F32 → S16 for some IQ-channel configurations; storing the requested
|
|
207
|
+
# value caused parse_rtp_samples to decode upstream bytes with the
|
|
208
|
+
# wrong dtype and produce NaN-poisoned garbage. The granted encoding
|
|
209
|
+
# is authoritative. Fall back to the caller's value if channel_info
|
|
210
|
+
# doesn't carry an encoding (or carries 0 = "none/default").
|
|
211
|
+
granted_encoding = getattr(channel_info, 'encoding', 0) or encoding
|
|
212
|
+
|
|
204
213
|
slot = _ChannelSlot(
|
|
205
214
|
channel_info=channel_info,
|
|
206
215
|
frequency_hz=frequency_hz,
|
|
207
216
|
preset=preset,
|
|
208
217
|
sample_rate=sample_rate,
|
|
209
|
-
encoding=
|
|
218
|
+
encoding=granted_encoding,
|
|
210
219
|
is_iq=is_iq,
|
|
211
220
|
resequencer=PacketResequencer(
|
|
212
221
|
buffer_size=self._resequence_buffer_size,
|
|
@@ -124,7 +124,11 @@ def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
|
|
|
124
124
|
)
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
def rtp_to_wallclock(
|
|
127
|
+
def rtp_to_wallclock(
|
|
128
|
+
rtp_timestamp: int,
|
|
129
|
+
channel: ChannelInfo,
|
|
130
|
+
wallclock_hint_sec: Optional[float] = None,
|
|
131
|
+
) -> Optional[float]:
|
|
128
132
|
"""
|
|
129
133
|
Convert RTP timestamp to Unix wall-clock time
|
|
130
134
|
|
|
@@ -133,6 +137,15 @@ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float
|
|
|
133
137
|
Args:
|
|
134
138
|
rtp_timestamp: RTP timestamp from packet header
|
|
135
139
|
channel: ChannelInfo with gps_time, rtp_timesnap, sample_rate
|
|
140
|
+
wallclock_hint_sec: Approximate UTC seconds, used solely to
|
|
141
|
+
disambiguate the 32-bit RTP wrap epoch (see below). Must be
|
|
142
|
+
within ±period/2 of true UTC (period = 2**32 / sample_rate
|
|
143
|
+
seconds, ≥6 hours for typical sample rates). When omitted,
|
|
144
|
+
falls back to ``time.time()`` — convenient but couples the
|
|
145
|
+
result to the host system clock. Callers that have an
|
|
146
|
+
hf-timestd authority offset available should pass it
|
|
147
|
+
explicitly to keep the labeling path off the chrony-disciplined
|
|
148
|
+
system clock (METROLOGY.md §4.5 RTP-reference invariant).
|
|
136
149
|
|
|
137
150
|
Returns:
|
|
138
151
|
Unix timestamp (seconds) or None if timing info unavailable
|
|
@@ -149,10 +162,10 @@ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float
|
|
|
149
162
|
|
|
150
163
|
We disambiguate by picking the wrap-epoch count ``k`` (full
|
|
151
164
|
2**32-sample periods elapsed since the snapshot) that places
|
|
152
|
-
the resulting wall-clock time closest to
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
165
|
+
the resulting wall-clock time closest to ``wallclock_hint_sec``
|
|
166
|
+
(or ``time.time()`` if no hint was given). Either source needs
|
|
167
|
+
only ±period/2 accuracy, so this stays robust even when the
|
|
168
|
+
hinting reference is loose.
|
|
156
169
|
|
|
157
170
|
Observed on bee1 2026-05-08: long-running SSRCs caused TSL3
|
|
158
171
|
SHM samples stuck at the snapshot's wall-clock time, ~12.4 h
|
|
@@ -181,7 +194,10 @@ def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float
|
|
|
181
194
|
# value closest to the system clock. Exact when sys clock is
|
|
182
195
|
# within ±period/2 of true UTC.
|
|
183
196
|
period_ns = BILLION * 0x100000000 // channel.sample_rate
|
|
184
|
-
|
|
197
|
+
if wallclock_hint_sec is not None:
|
|
198
|
+
sys_now_ns = int(wallclock_hint_sec * BILLION)
|
|
199
|
+
else:
|
|
200
|
+
sys_now_ns = int(time.time() * BILLION)
|
|
185
201
|
diff_ns = sys_now_ns - base_wall_ns
|
|
186
202
|
if period_ns > 0:
|
|
187
203
|
# Round-to-nearest of diff_ns / period_ns (Python `//` is
|
|
@@ -41,6 +41,7 @@ from .discovery import ChannelInfo
|
|
|
41
41
|
from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_wallclock
|
|
42
42
|
from .resequencer import PacketResequencer, RTPPacket
|
|
43
43
|
from .stream_quality import GapSource, GapEvent, StreamQuality
|
|
44
|
+
from .types import Encoding
|
|
44
45
|
|
|
45
46
|
logger = logging.getLogger(__name__)
|
|
46
47
|
|
|
@@ -48,16 +49,54 @@ logger = logging.getLogger(__name__)
|
|
|
48
49
|
SampleCallback = Callable[[np.ndarray, StreamQuality], None]
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
# G.711 µ-law and A-law decode tables (ITU-T G.711). Each maps a uint8 byte
|
|
53
|
+
# to its signed 16-bit linear PCM equivalent. Precomputed once at import.
|
|
54
|
+
def _build_mulaw_table() -> np.ndarray:
|
|
55
|
+
out = np.empty(256, dtype=np.int16)
|
|
56
|
+
for i in range(256):
|
|
57
|
+
u = ~i & 0xFF
|
|
58
|
+
sign = u & 0x80
|
|
59
|
+
exponent = (u >> 4) & 0x07
|
|
60
|
+
mantissa = u & 0x0F
|
|
61
|
+
sample = ((mantissa << 3) + 0x84) << exponent
|
|
62
|
+
sample -= 0x84
|
|
63
|
+
out[i] = -sample if sign else sample
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_alaw_table() -> np.ndarray:
|
|
68
|
+
out = np.empty(256, dtype=np.int16)
|
|
69
|
+
for i in range(256):
|
|
70
|
+
a = i ^ 0x55
|
|
71
|
+
sign = a & 0x80
|
|
72
|
+
exponent = (a >> 4) & 0x07
|
|
73
|
+
mantissa = a & 0x0F
|
|
74
|
+
if exponent == 0:
|
|
75
|
+
sample = (mantissa << 4) + 8
|
|
76
|
+
else:
|
|
77
|
+
sample = ((mantissa << 4) + 0x108) << (exponent - 1)
|
|
78
|
+
out[i] = -sample if sign else sample
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_MULAW_TABLE = _build_mulaw_table()
|
|
83
|
+
_ALAW_TABLE = _build_alaw_table()
|
|
84
|
+
|
|
85
|
+
|
|
51
86
|
def parse_rtp_samples(
|
|
52
87
|
payload: bytes, encoding: int, is_iq: bool
|
|
53
88
|
) -> Optional[np.ndarray]:
|
|
54
89
|
"""Parse RTP payload samples based on encoding.
|
|
55
90
|
|
|
56
|
-
Shared by RadiodStream and MultiStream.
|
|
91
|
+
Shared by RadiodStream and MultiStream. Covers every linear-PCM encoding
|
|
92
|
+
radiod can grant: NO_ENCODING/S16LE/S16BE/F32LE/F32BE/F16LE/F16BE plus
|
|
93
|
+
G.711 MULAW/ALAW. OPUS (3/7) requires libopus and is handled by
|
|
94
|
+
``OpusDecoder`` in this module — not by this raw-sample helper. AX25 (5)
|
|
95
|
+
is a framed protocol, not audio samples, and is also out of scope here.
|
|
57
96
|
|
|
58
97
|
Args:
|
|
59
98
|
payload: Raw RTP payload bytes (after header).
|
|
60
|
-
encoding: Channel encoding
|
|
99
|
+
encoding: Channel encoding from ``ka9q.types.Encoding``.
|
|
61
100
|
is_iq: True for IQ (complex) mode, False for audio (real) mode.
|
|
62
101
|
|
|
63
102
|
Returns:
|
|
@@ -65,29 +104,124 @@ def parse_rtp_samples(
|
|
|
65
104
|
or None on error.
|
|
66
105
|
"""
|
|
67
106
|
try:
|
|
107
|
+
floats = _decode_to_float32(payload, encoding, is_iq=is_iq)
|
|
108
|
+
if floats is None:
|
|
109
|
+
return None
|
|
68
110
|
if is_iq:
|
|
69
|
-
|
|
111
|
+
# radiod silently downgrades F32 → S16 for some IQ-channel
|
|
112
|
+
# configurations (high sample rate + wide filter); confirmed on
|
|
113
|
+
# bee1 2026-05-15 for T6/TSL3 BPSK PPS channel (encoding=4
|
|
114
|
+
# requested, encoding=2 granted, decoded as F32LE → NaN-poisoned
|
|
115
|
+
# input, TSL3 dark). Honor the granted encoding above.
|
|
70
116
|
if len(floats) % 2 != 0:
|
|
71
|
-
logger.warning(f"Odd number of
|
|
117
|
+
logger.warning(f"Odd number of samples in IQ payload: {len(floats)}")
|
|
72
118
|
return None
|
|
73
119
|
samples = floats[0::2] + 1j * floats[1::2]
|
|
74
120
|
return samples.astype(np.complex64)
|
|
75
|
-
|
|
76
|
-
if encoding == 2:
|
|
77
|
-
int16_samples = np.frombuffer(payload, dtype='>i2')
|
|
78
|
-
return (int16_samples / 32768.0).astype(np.float32)
|
|
79
|
-
elif encoding == 1:
|
|
80
|
-
int16_samples = np.frombuffer(payload, dtype='<i2')
|
|
81
|
-
return (int16_samples / 32768.0).astype(np.float32)
|
|
82
|
-
elif encoding == 8:
|
|
83
|
-
return np.frombuffer(payload, dtype='>f4').astype(np.float32)
|
|
84
|
-
else:
|
|
85
|
-
return np.frombuffer(payload, dtype=np.float32)
|
|
121
|
+
return floats
|
|
86
122
|
except Exception as e:
|
|
87
123
|
logger.error(f"Failed to parse payload: {e}")
|
|
88
124
|
return None
|
|
89
125
|
|
|
90
126
|
|
|
127
|
+
def _decode_to_float32(payload: bytes, encoding: int, *, is_iq: bool = False) -> Optional[np.ndarray]:
|
|
128
|
+
"""Decode raw RTP payload bytes to a flat float32 sample array.
|
|
129
|
+
|
|
130
|
+
For IQ the caller interleaves; for audio the array is the output directly.
|
|
131
|
+
Returns None for encodings this helper does not cover (OPUS, AX25).
|
|
132
|
+
The ``is_iq`` flag only affects the wording of the fallback warning so
|
|
133
|
+
callers can grep for IQ-specific decode anomalies (the bee1 TSL3 case).
|
|
134
|
+
"""
|
|
135
|
+
if encoding in (Encoding.NO_ENCODING, Encoding.F32LE):
|
|
136
|
+
return np.frombuffer(payload, dtype='<f4').astype(np.float32, copy=False)
|
|
137
|
+
if encoding == Encoding.F32BE:
|
|
138
|
+
return np.frombuffer(payload, dtype='>f4').astype(np.float32, copy=False)
|
|
139
|
+
if encoding == Encoding.S16LE:
|
|
140
|
+
return np.frombuffer(payload, dtype='<i2').astype(np.float32) / 32768.0
|
|
141
|
+
if encoding == Encoding.S16BE:
|
|
142
|
+
return np.frombuffer(payload, dtype='>i2').astype(np.float32) / 32768.0
|
|
143
|
+
if encoding == Encoding.F16LE:
|
|
144
|
+
return np.frombuffer(payload, dtype='<f2').astype(np.float32)
|
|
145
|
+
if encoding == Encoding.F16BE:
|
|
146
|
+
return np.frombuffer(payload, dtype='>f2').astype(np.float32)
|
|
147
|
+
if encoding == Encoding.MULAW:
|
|
148
|
+
idx = np.frombuffer(payload, dtype=np.uint8)
|
|
149
|
+
return (_MULAW_TABLE[idx].astype(np.float32) / 32768.0)
|
|
150
|
+
if encoding == Encoding.ALAW:
|
|
151
|
+
idx = np.frombuffer(payload, dtype=np.uint8)
|
|
152
|
+
return (_ALAW_TABLE[idx].astype(np.float32) / 32768.0)
|
|
153
|
+
if encoding in (Encoding.OPUS, Encoding.OPUS_VOIP):
|
|
154
|
+
# Opus payloads are codec frames, not raw samples — caller must use
|
|
155
|
+
# ka9q.stream.OpusDecoder (requires libopus / opuslib).
|
|
156
|
+
return None
|
|
157
|
+
if encoding == Encoding.AX25:
|
|
158
|
+
# AX25 is a framed protocol — bytes are the payload, not samples.
|
|
159
|
+
return None
|
|
160
|
+
kind = "IQ" if is_iq else "audio"
|
|
161
|
+
logger.warning(f"Unsupported {kind} encoding {encoding}, falling back to F32LE")
|
|
162
|
+
return np.frombuffer(payload, dtype='<f4').astype(np.float32, copy=False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class OpusDecoder:
|
|
166
|
+
"""Optional Opus → float32 decoder for radiod OPUS / OPUS_VOIP streams.
|
|
167
|
+
|
|
168
|
+
Requires the ``opuslib`` package (``pip install ka9q-python[opus]`` or
|
|
169
|
+
``pip install opuslib``). Maintains internal codec state across calls so
|
|
170
|
+
packet-loss concealment works end-to-end; create one instance per stream
|
|
171
|
+
SSRC and feed it each RTP payload in order.
|
|
172
|
+
|
|
173
|
+
Example::
|
|
174
|
+
|
|
175
|
+
dec = OpusDecoder(sample_rate=48000, channels=1)
|
|
176
|
+
for payload in opus_payloads:
|
|
177
|
+
samples = dec.decode(payload) # float32, mono
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, sample_rate: int = 48000, channels: int = 1):
|
|
181
|
+
try:
|
|
182
|
+
import opuslib # type: ignore
|
|
183
|
+
except ImportError as exc:
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
"Opus decoding requires opuslib — install with "
|
|
186
|
+
"'pip install ka9q-python[opus]' or 'pip install opuslib'"
|
|
187
|
+
) from exc
|
|
188
|
+
if sample_rate not in (8000, 12000, 16000, 24000, 48000):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"Opus sample_rate must be one of 8000/12000/16000/24000/48000; got {sample_rate}"
|
|
191
|
+
)
|
|
192
|
+
if channels not in (1, 2):
|
|
193
|
+
raise ValueError(f"Opus channels must be 1 or 2; got {channels}")
|
|
194
|
+
self._sample_rate = sample_rate
|
|
195
|
+
self._channels = channels
|
|
196
|
+
self._dec = opuslib.Decoder(sample_rate, channels)
|
|
197
|
+
# 120 ms is the largest Opus frame; allocate per-call.
|
|
198
|
+
self._max_frame_samples = (sample_rate * 120) // 1000
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def sample_rate(self) -> int:
|
|
202
|
+
return self._sample_rate
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def channels(self) -> int:
|
|
206
|
+
return self._channels
|
|
207
|
+
|
|
208
|
+
def decode(self, payload: bytes, *, fec: bool = False) -> np.ndarray:
|
|
209
|
+
"""Decode one Opus RTP payload to float32 PCM samples.
|
|
210
|
+
|
|
211
|
+
For stereo streams the result is interleaved L,R,L,R,… Empty payload
|
|
212
|
+
triggers packet-loss concealment (one frame of silence/extrapolation).
|
|
213
|
+
"""
|
|
214
|
+
if not payload and not fec:
|
|
215
|
+
# Generate one PLC frame of typical Opus duration (20 ms).
|
|
216
|
+
n_samples = (self._sample_rate * 20) // 1000
|
|
217
|
+
else:
|
|
218
|
+
n_samples = self._max_frame_samples
|
|
219
|
+
pcm = self._dec.decode(payload, n_samples, decode_fec=fec)
|
|
220
|
+
# opuslib returns bytes of int16 little-endian; convert to float32 [-1,1].
|
|
221
|
+
int16s = np.frombuffer(pcm, dtype='<i2')
|
|
222
|
+
return int16s.astype(np.float32) / 32768.0
|
|
223
|
+
|
|
224
|
+
|
|
91
225
|
class RadiodStream:
|
|
92
226
|
"""
|
|
93
227
|
High-level interface to a radiod IQ/audio stream.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
ka9q-radio protocol types and constants
|
|
3
3
|
|
|
4
4
|
Auto-generated by scripts/sync_types.py from ka9q-radio C headers.
|
|
5
|
-
Validated against ka9q-radio commit:
|
|
5
|
+
Validated against ka9q-radio commit: d555f1853422
|
|
6
6
|
|
|
7
7
|
DO NOT EDIT MANUALLY — run: python scripts/sync_types.py --apply
|
|
8
8
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ka9q-python
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.15.0
|
|
4
4
|
Summary: Python interface for ka9q-radio control and monitoring
|
|
5
5
|
Home-page: https://github.com/mijahauan/ka9q-python
|
|
6
6
|
Author: Michael Hauan AC0G
|
|
@@ -30,6 +30,8 @@ Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
|
30
30
|
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
31
31
|
Provides-Extra: tui
|
|
32
32
|
Requires-Dist: textual>=0.50; extra == "tui"
|
|
33
|
+
Provides-Extra: opus
|
|
34
|
+
Requires-Dist: opuslib>=3.0; extra == "opus"
|
|
33
35
|
Dynamic: author
|
|
34
36
|
Dynamic: home-page
|
|
35
37
|
Dynamic: license-file
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ka9q-python"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.15.0"
|
|
8
8
|
description = "Python interface for ka9q-radio control and monitoring"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -37,6 +37,9 @@ dev = [
|
|
|
37
37
|
tui = [
|
|
38
38
|
"textual>=0.50",
|
|
39
39
|
]
|
|
40
|
+
opus = [
|
|
41
|
+
"opuslib>=3.0",
|
|
42
|
+
]
|
|
40
43
|
|
|
41
44
|
[project.scripts]
|
|
42
45
|
ka9q = "ka9q.cli:main"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Tests for parse_rtp_samples IQ-encoding handling.
|
|
2
|
+
|
|
3
|
+
Regression coverage for the 2026-05-15 fix where the IQ branch of
|
|
4
|
+
parse_rtp_samples ignored the encoding parameter and always decoded as
|
|
5
|
+
F32LE — so when radiod silently downgraded a channel to S16 the bytes
|
|
6
|
+
mis-decoded into ~1e34 garbage and occasional NaN. See discussion in
|
|
7
|
+
hf-timestd's TSL3-dark investigation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from ka9q.stream import parse_rtp_samples
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _iq_payload(samples_re, samples_im, *, dtype: str, byteorder: str) -> bytes:
|
|
19
|
+
"""Interleaved (re, im) sample bytes in the requested dtype/byteorder."""
|
|
20
|
+
interleaved = np.empty(2 * len(samples_re), dtype=dtype)
|
|
21
|
+
interleaved[0::2] = samples_re
|
|
22
|
+
interleaved[1::2] = samples_im
|
|
23
|
+
# numpy dtype string already encodes byteorder in dtype param;
|
|
24
|
+
# the wire format is whatever np.frombuffer reads with the same dtype.
|
|
25
|
+
return interleaved.tobytes()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_iq_f32le_default_encoding_0():
|
|
29
|
+
re = np.array([0.5, -0.25, 0.0], dtype='<f4')
|
|
30
|
+
im = np.array([-0.5, 0.25, 1.0], dtype='<f4')
|
|
31
|
+
payload = _iq_payload(re, im, dtype='<f4', byteorder='<')
|
|
32
|
+
samples = parse_rtp_samples(payload, encoding=0, is_iq=True)
|
|
33
|
+
assert samples is not None
|
|
34
|
+
np.testing.assert_allclose(samples.real, re, rtol=0, atol=1e-7)
|
|
35
|
+
np.testing.assert_allclose(samples.imag, im, rtol=0, atol=1e-7)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_iq_f32le_encoding_4():
|
|
39
|
+
re = np.array([0.1, 0.2], dtype='<f4')
|
|
40
|
+
im = np.array([-0.1, -0.2], dtype='<f4')
|
|
41
|
+
payload = _iq_payload(re, im, dtype='<f4', byteorder='<')
|
|
42
|
+
samples = parse_rtp_samples(payload, encoding=4, is_iq=True)
|
|
43
|
+
assert samples is not None
|
|
44
|
+
np.testing.assert_allclose(samples.real, re, rtol=0, atol=1e-7)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_iq_f32be_encoding_8():
|
|
48
|
+
re = np.array([0.5, -0.5], dtype='>f4')
|
|
49
|
+
im = np.array([0.25, -0.25], dtype='>f4')
|
|
50
|
+
payload = _iq_payload(re, im, dtype='>f4', byteorder='>')
|
|
51
|
+
samples = parse_rtp_samples(payload, encoding=8, is_iq=True)
|
|
52
|
+
assert samples is not None
|
|
53
|
+
np.testing.assert_allclose(samples.real, [0.5, -0.5], rtol=0, atol=1e-7)
|
|
54
|
+
np.testing.assert_allclose(samples.imag, [0.25, -0.25], rtol=0, atol=1e-7)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_iq_s16le_encoding_1():
|
|
58
|
+
re = np.array([16384, -16384], dtype='<i2') # ±0.5
|
|
59
|
+
im = np.array([8192, -8192], dtype='<i2') # ±0.25
|
|
60
|
+
payload = _iq_payload(re, im, dtype='<i2', byteorder='<')
|
|
61
|
+
samples = parse_rtp_samples(payload, encoding=1, is_iq=True)
|
|
62
|
+
assert samples is not None
|
|
63
|
+
np.testing.assert_allclose(samples.real, [0.5, -0.5], rtol=0, atol=1e-4)
|
|
64
|
+
np.testing.assert_allclose(samples.imag, [0.25, -0.25], rtol=0, atol=1e-4)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_iq_s16be_encoding_2_does_not_produce_nan_or_huge():
|
|
68
|
+
"""The bug we're fixing: a real-world S16BE IQ payload like radiod's
|
|
69
|
+
T6/TSL3 channel was producing NaN and ~1e34 values when decoded as
|
|
70
|
+
F32LE. Confirm decoding the same bytes as S16BE gives sane samples.
|
|
71
|
+
"""
|
|
72
|
+
re = np.array([23876, -10000, 32767], dtype='>i2')
|
|
73
|
+
im = np.array([13433, 25000, -32768], dtype='>i2')
|
|
74
|
+
payload = _iq_payload(re, im, dtype='>i2', byteorder='>')
|
|
75
|
+
samples = parse_rtp_samples(payload, encoding=2, is_iq=True)
|
|
76
|
+
assert samples is not None
|
|
77
|
+
assert not np.any(np.isnan(samples.real))
|
|
78
|
+
assert not np.any(np.isnan(samples.imag))
|
|
79
|
+
assert np.max(np.abs(samples)) <= 2.0 # all should be in [-1.0, +1.0]
|
|
80
|
+
np.testing.assert_allclose(samples.real, [23876 / 32768.0, -10000 / 32768.0, 32767 / 32768.0], rtol=0, atol=1e-4)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_iq_odd_sample_count_returns_none():
|
|
84
|
+
"""An odd number of int16 values can't form complete IQ pairs."""
|
|
85
|
+
payload = np.array([1, 2, 3], dtype='>i2').tobytes() # 3 int16 = 6 bytes
|
|
86
|
+
samples = parse_rtp_samples(payload, encoding=2, is_iq=True)
|
|
87
|
+
assert samples is None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_iq_unknown_encoding_falls_back_and_warns(caplog):
|
|
91
|
+
re = np.array([0.1], dtype='<f4')
|
|
92
|
+
im = np.array([-0.1], dtype='<f4')
|
|
93
|
+
payload = _iq_payload(re, im, dtype='<f4', byteorder='<')
|
|
94
|
+
import logging
|
|
95
|
+
with caplog.at_level(logging.WARNING):
|
|
96
|
+
samples = parse_rtp_samples(payload, encoding=99, is_iq=True)
|
|
97
|
+
assert samples is not None
|
|
98
|
+
assert any('Unsupported IQ encoding' in rec.message for rec in caplog.records)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_iq_smoking_gun_bit_pattern():
|
|
102
|
+
"""The empirical fingerprint of the bug on bee1 2026-05-15:
|
|
103
|
+
a 4-byte block 5d 44 34 79 (which is S16BE int16 pair [23876, 13433]
|
|
104
|
+
— sensible loud-signal IQ) was being decoded as F32LE → 5.85e34.
|
|
105
|
+
Verify S16BE decoding gives back the original int16 values normalised.
|
|
106
|
+
"""
|
|
107
|
+
payload = bytes.fromhex('5d44 3479'.replace(' ', ''))
|
|
108
|
+
samples = parse_rtp_samples(payload, encoding=2, is_iq=True)
|
|
109
|
+
assert samples is not None
|
|
110
|
+
assert len(samples) == 1
|
|
111
|
+
np.testing.assert_allclose(samples.real, [23876 / 32768.0], rtol=0, atol=1e-4)
|
|
112
|
+
np.testing.assert_allclose(samples.imag, [13433 / 32768.0], rtol=0, atol=1e-4)
|
|
113
|
+
# The "bug behaviour" reference: decoding the same bytes as F32LE
|
|
114
|
+
# gives 5.85e34. Document this in the test so a future regression
|
|
115
|
+
# that re-introduces F32LE-always behaviour will fail with a meaningful
|
|
116
|
+
# signal.
|
|
117
|
+
as_f32le = np.frombuffer(payload, dtype='<f4')[0]
|
|
118
|
+
assert as_f32le > 1e34, "sanity check: bug fingerprint matches"
|
|
@@ -118,3 +118,67 @@ def test_rtp_to_wallclock_returns_none_when_timing_missing():
|
|
|
118
118
|
assert rtp_to_wallclock(1000, channel) is None
|
|
119
119
|
channel = _channel(48000, None, 1000)
|
|
120
120
|
assert rtp_to_wallclock(1000, channel) is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_rtp_to_wallclock_hint_bypasses_system_clock():
|
|
124
|
+
"""When `wallclock_hint_sec` is provided, time.time() must NOT be
|
|
125
|
+
consulted. This is the RTP-reference invariant: authority-aware
|
|
126
|
+
callers can disambiguate the wrap epoch without coupling to the
|
|
127
|
+
host system clock."""
|
|
128
|
+
sample_rate = 96000
|
|
129
|
+
gps_time_ns = 1234567890000000000
|
|
130
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
131
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
132
|
+
|
|
133
|
+
period_sec = 0x100000000 / sample_rate
|
|
134
|
+
target_rtp = (1000 + 0x100000000) & 0xFFFFFFFF
|
|
135
|
+
expected = snapshot_wall + period_sec
|
|
136
|
+
|
|
137
|
+
# Pin time.time() to something WAY OFF (1 year earlier) — if the
|
|
138
|
+
# function ignores the hint and consults the system clock anyway,
|
|
139
|
+
# the wrap-epoch math will pick k=0 and the result will be wrong.
|
|
140
|
+
bogus_now = expected - 365 * 86400.0
|
|
141
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=bogus_now) as mock_time:
|
|
142
|
+
result = rtp_to_wallclock(target_rtp, channel, wallclock_hint_sec=expected)
|
|
143
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
144
|
+
# Strict: hint path must not call time.time() at all.
|
|
145
|
+
mock_time.assert_not_called()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_rtp_to_wallclock_hint_picks_correct_epoch():
|
|
149
|
+
"""Hint at a different wrap epoch from the system clock → result
|
|
150
|
+
follows the hint, proving the function uses the hint for k-selection."""
|
|
151
|
+
sample_rate = 12000 # WSPR-band rate; period ≈ 99 hours
|
|
152
|
+
gps_time_ns = 1234567890000000000
|
|
153
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
154
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
155
|
+
period_sec = 0x100000000 / sample_rate
|
|
156
|
+
|
|
157
|
+
# RTP value aliased to ~snapshot, but actually 2 wraps later.
|
|
158
|
+
target_rtp = (1000 + 2 * 0x100000000) & 0xFFFFFFFF
|
|
159
|
+
expected = snapshot_wall + 2 * period_sec
|
|
160
|
+
|
|
161
|
+
# System clock at the snapshot wall (would pick k=0); hint says
|
|
162
|
+
# ~2 periods later (correct k=2).
|
|
163
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=snapshot_wall):
|
|
164
|
+
result = rtp_to_wallclock(target_rtp, channel, wallclock_hint_sec=expected)
|
|
165
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_rtp_to_wallclock_hint_omitted_falls_back_to_system_clock():
|
|
169
|
+
"""Default path (no hint) preserves legacy behavior for
|
|
170
|
+
backwards compatibility — the hint parameter is purely additive."""
|
|
171
|
+
sample_rate = 48000
|
|
172
|
+
gps_time_ns = 1234567890000000000
|
|
173
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
174
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
175
|
+
period_sec = 0x100000000 / sample_rate
|
|
176
|
+
|
|
177
|
+
target_rtp = (1000 + 0x100000000) & 0xFFFFFFFF
|
|
178
|
+
expected = snapshot_wall + period_sec
|
|
179
|
+
|
|
180
|
+
# No hint → must consult time.time() to land on k=1.
|
|
181
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected) as mock_time:
|
|
182
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
183
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
184
|
+
mock_time.assert_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|