ka9q-python 3.15.0__tar.gz → 3.16.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.
Files changed (109) hide show
  1. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/CHANGELOG.md +122 -0
  2. {ka9q_python-3.15.0/ka9q_python.egg-info → ka9q_python-3.16.1}/PKG-INFO +16 -2
  3. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/README.md +15 -1
  4. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/API_REFERENCE.md +66 -15
  5. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/ARCHITECTURE.md +11 -0
  6. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/INSTALLATION.md +20 -5
  7. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/MULTI_STREAM.md +10 -4
  8. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/__init__.py +14 -1
  9. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/control.py +202 -117
  10. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/discovery.py +86 -1
  11. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/multi_stream.py +7 -2
  12. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/rtp_recorder.py +15 -3
  13. ka9q_python-3.16.1/ka9q/status_listener.py +439 -0
  14. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/stream.py +15 -2
  15. {ka9q_python-3.15.0 → ka9q_python-3.16.1/ka9q_python.egg-info}/PKG-INFO +16 -2
  16. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q_python.egg-info/SOURCES.txt +2 -0
  17. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/pyproject.toml +1 -1
  18. ka9q_python-3.16.1/tests/test_status_listener.py +347 -0
  19. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/LICENSE +0 -0
  20. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/MANIFEST.in +0 -0
  21. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/CLI_GUIDE.md +0 -0
  22. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/GETTING_STARTED.md +0 -0
  23. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/RECIPES.md +0 -0
  24. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
  25. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/SECURITY.md +0 -0
  26. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/TESTING_GUIDE.md +0 -0
  27. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/docs/TUI_GUIDE.md +0 -0
  28. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/advanced_features_demo.py +0 -0
  29. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/channel_cleanup_example.py +0 -0
  30. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/codar_oceanography.py +0 -0
  31. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/diagnostics/diagnose_packets.py +0 -0
  32. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/diagnostics/repro_utc_bug.py +0 -0
  33. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/discover_example.py +0 -0
  34. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/grape_integration_example.py +0 -0
  35. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/hf_band_scanner.py +0 -0
  36. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/multi_stream_smoke.py +0 -0
  37. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/rtp_recorder_example.py +0 -0
  38. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/simple_am_radio.py +0 -0
  39. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/spectrum_example.py +0 -0
  40. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/stream_example.py +0 -0
  41. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/superdarn_recorder.py +0 -0
  42. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/test_channel_operations.py +0 -0
  43. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/test_improvements.py +0 -0
  44. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/test_timing_fields.py +0 -0
  45. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/tune.py +0 -0
  46. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/examples/tune_example.py +0 -0
  47. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/_multicast.py +0 -0
  48. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/addressing.py +0 -0
  49. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/cli.py +0 -0
  50. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/compat.py +0 -0
  51. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/exceptions.py +0 -0
  52. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/managed_stream.py +0 -0
  53. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/monitor.py +0 -0
  54. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/pps_calibrator.py +0 -0
  55. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/resequencer.py +0 -0
  56. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/spectrum_stream.py +0 -0
  57. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/status.py +0 -0
  58. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/stream_quality.py +0 -0
  59. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/tui.py +0 -0
  60. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/types.py +0 -0
  61. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q/utils.py +0 -0
  62. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
  63. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q_python.egg-info/entry_points.txt +0 -0
  64. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q_python.egg-info/requires.txt +0 -0
  65. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q_python.egg-info/top_level.txt +0 -0
  66. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/ka9q_radio_compat +0 -0
  67. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/scripts/check_upstream_drift.py +0 -0
  68. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/scripts/sync_types.py +0 -0
  69. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/setup.cfg +0 -0
  70. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/setup.py +0 -0
  71. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/__init__.py +0 -0
  72. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/conftest.py +0 -0
  73. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_addressing.py +0 -0
  74. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_channel_verification.py +0 -0
  75. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_client_id_destination.py +0 -0
  76. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_create_split_encoding.py +0 -0
  77. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_decode_description.py +0 -0
  78. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_decode_functions.py +0 -0
  79. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_encode_functions.py +0 -0
  80. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_encode_socket.py +0 -0
  81. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_ensure_channel_encoding.py +0 -0
  82. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_filter_edges.py +0 -0
  83. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_integration.py +0 -0
  84. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_iq_20khz_f32.py +0 -0
  85. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_lifetime.py +0 -0
  86. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_listen_multicast.py +0 -0
  87. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_managed_stream_recovery.py +0 -0
  88. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_monitor.py +0 -0
  89. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_multicast_helpers.py +0 -0
  90. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_multihomed.py +0 -0
  91. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_native_discovery.py +0 -0
  92. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_parse_rtp_samples_iq.py +0 -0
  93. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_performance_fixes.py +0 -0
  94. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_protocol_compat.py +0 -0
  95. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_remove_channel.py +0 -0
  96. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_rtp_recorder.py +0 -0
  97. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_security_features.py +0 -0
  98. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_spectrum.py +0 -0
  99. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_ssrc_dest_unit.py +0 -0
  100. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_ssrc_encoding_unit.py +0 -0
  101. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
  102. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_status_decoder.py +0 -0
  103. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_ttl_warning.py +0 -0
  104. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_tune.py +0 -0
  105. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_tune_cli.py +0 -0
  106. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_tune_debug.py +0 -0
  107. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_tune_live.py +0 -0
  108. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_tune_method.py +0 -0
  109. {ka9q_python-3.15.0 → ka9q_python-3.16.1}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,127 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.16.1] - 2026-05-24
4
+
5
+ ### Fixed
6
+
7
+ - **`ChannelInfo` anchor pair is now atomic** (`channel-info`). Adds
8
+ `ChannelInfo.get_anchor()` / `update_anchor()` — a tuple-based
9
+ atomic snapshot of `(gps_time, rtp_timesnap)`. `rtp_to_wallclock`
10
+ now reads the pair via `get_anchor` (single GIL-atomic attribute
11
+ access) instead of two separate reads; `StatusListener` writes via
12
+ `update_anchor` (single tuple assignment).
13
+
14
+ Why: the `StatusListener` introduced in 3.16.0 refreshes the anchor
15
+ in place at sub-second cadence (~450 ms on a busy host). Direct
16
+ sequential reads of `channel.gps_time` followed by
17
+ `channel.rtp_timesnap` could land between the listener's two writes
18
+ and yield a torn pair off by one listener-cadence interval.
19
+ `rtp_to_wallclock` then returned a wall-time off by that much —
20
+ usually harmless, but consumers comparing against an external time
21
+ reference with a tight gate (e.g. hf-timestd's T5 LB-1421 NMEA
22
+ disambig at ±0.5 s) could be pushed across the threshold and fall
23
+ back to a chrony walk that itself fails during a post-restart
24
+ cascade.
25
+
26
+ Backward compatible: constructor kwargs (`gps_time=`,
27
+ `rtp_timesnap=`) and direct field reads are unchanged; consumers
28
+ that need the pair transactionally must call `get_anchor`. Adds
29
+ 5 new tests (atomic update, construction-time seed, mixed-None
30
+ handling, listener path, 50-iteration consistency smoke test).
31
+
32
+ ### Performance
33
+
34
+ - **`RadiodStream`: `SO_RCVBUF` raised 0 → 64 MB** (`stream.py`).
35
+ Mirrors the 3.16.0-cycle `multi_stream.py` change on the
36
+ single-channel path. Previously `RadiodStream` sockets fell back
37
+ to the kernel default (`rmem_default`, typically 16 MB on hosts
38
+ with sigmond's `rule_kernel_rcvbuf_adequate` provisioning) and were
39
+ vulnerable to GIL-stall packet loss with no other consumer competing
40
+ to drain the buffer. 64 MB matches the `MultiStream` cap; sigmond
41
+ provisions `net.core.rmem_max=128 MB` (after kernel doubling), so
42
+ the request is honored. Observed on bee1 2026-05-23 closing a
43
+ 140 ms-stall-induced `gap=13440` resequencer event on hf-timestd's
44
+ T6 dedicated stream.
45
+
46
+ - **`MultiStream`: `SO_RCVBUF` raised 8 MB → 64 MB** (`multi_stream.py`).
47
+ Observed 412 M UDP `RcvbufErrors` on B4-100 since boot, driven by
48
+ GIL contention preventing Python receiver threads from draining the
49
+ kernel-doubled 16 MB sockets. Bigger absorber → more headroom
50
+ across GIL stalls before packets are dropped. After applying:
51
+ socket `rb` shows 134217728 (128 MB visible after kernel doubling)
52
+ and recv-Q sits at ~50 KB in steady state (was hitting 14 MB / 16
53
+ MB before). Requires `net.core.rmem_max >= 64 MB` to be honored —
54
+ provisioned by sigmond in
55
+ `/etc/sysctl.d/99-wspr-recorder.conf` alongside this change.
56
+
57
+ ## [3.16.0] - 2026-05-23
58
+
59
+ ### Added
60
+
61
+ - **`StatusListener` — continuous STATUS multicast listener.** New
62
+ `ka9q.status_listener.StatusListener` class subscribes to radiod's
63
+ STATUS multicast (port 5006) in a background thread and refreshes
64
+ `ChannelInfo.gps_time` / `.rtp_timesnap` on every broadcast. Replaces
65
+ the previous one-shot `discover_channels` anchor capture, which
66
+ froze the timing anchor at SSRC discovery — leaving `rtp_to_wallclock`
67
+ to project forward from a host-clock value that drifts at the
68
+ chrony slew rate (~3.8 µs/s on a typical disciplined host).
69
+
70
+ Mutates the registered `ChannelInfo` in place so callers holding a
71
+ reference (e.g. hf-timestd's cached `_t6_channel_info`) see fresh
72
+ values immediately. Supports per-SSRC and wildcard callbacks for
73
+ explicit notification. Uses `SO_REUSEPORT` so it can coexist with
74
+ `RadiodControl`'s own status socket without stealing tune/discover
75
+ responses — multicast packets are delivered to every joined socket.
76
+
77
+ Opt-in via `RadiodControl.start_status_listener()`; closing the
78
+ control object stops the listener. See class docstring for usage.
79
+
80
+ - **`RadiodControl.start_status_listener(...)` / `.stop_status_listener(...)`
81
+ / `.status_listener` property** — convenience wiring to attach a
82
+ `StatusListener` to an existing control session. Listener is
83
+ stopped automatically by `RadiodControl.close()`.
84
+
85
+ ### Why this exists
86
+
87
+ Before this release, every ka9q-python consumer (hf-timestd, codar,
88
+ psk, hfdl, wspr, wsprdaemon-client, gpsdo-monitor) labeled data via
89
+ `rtp_to_wallclock(rtp, channel)` with a ChannelInfo whose
90
+ `gps_time`/`rtp_timesnap` were captured once at SSRC discovery.
91
+ For long-running services this meant labels drifted from GPSDO truth
92
+ at the host-clock slew rate — on bee1 (`chrony` slewing at
93
+ ~3.8 µs/s), labels accumulated ~330 ms of drift per day.
94
+
95
+ The chrony SHM-push side of hf-timestd's BPSK PPS path (HPPS / HFPS)
96
+ manifested this most visibly: TS-1 source reported tracking with
97
+ 1 ns standard deviation but drifted at the host slew rate, blocking
98
+ DASI2 grant deployment. Faster anchor refresh closes the drift.
99
+
100
+ ### Backwards compatibility
101
+
102
+ - All existing tests pass unchanged. The listener is **opt-in** —
103
+ consumers that don't call `start_status_listener()` see no behavior
104
+ change.
105
+ - `ChannelInfo` is unchanged structurally; only its mutability
106
+ contract is clarified (the timing fields were always documented as
107
+ "the latest snapshot", which is now true continuously rather than
108
+ once).
109
+
110
+ ## [3.15.1] - 2026-05-21
111
+
112
+ ### Fixed
113
+
114
+ - **`ka9q.__version__` was stale.** v3.15.0's wheel correctly identified
115
+ itself as `3.15.0` in dist-info, but the `__version__` string baked
116
+ into `ka9q/__init__.py` still read `'3.14.2'` — the in-package
117
+ introspection answer was wrong for every consumer. The fix replaces
118
+ the hardcoded literal with `importlib.metadata.version("ka9q-python")`,
119
+ so `__version__` now always tracks the installed dist-info — drift
120
+ between release version and code-reported version is no longer
121
+ possible. Falls back to `"0.0.0+unknown"` on import error or when
122
+ the package is not installed (e.g. running from a source tree
123
+ without an editable install).
124
+
3
125
  ## [3.15.0] - 2026-05-21
4
126
 
5
127
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.15.0
3
+ Version: 3.16.1
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
@@ -62,7 +62,8 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
62
62
 
63
63
  ## Features
64
64
 
65
- - **Complete radiod API** — all 110+ TLV status/command parameters exposed, generated from ka9q-radio's C headers
65
+ - **Complete radiod API** — all 117 TLV status/command parameters exposed, generated from ka9q-radio's C headers
66
+ - **Every radiod RTP encoding decoded** — `S16LE/BE`, `F32LE/BE`, `F16LE/BE`, `MULAW`, `ALAW` via pure-NumPy `parse_rtp_samples()`; `OPUS` / `OPUS_VOIP` via the optional `OpusDecoder` (install with `[opus]` extra)
66
67
  - **Four stream abstractions** — `RTPRecorder` (raw packets), `RadiodStream` (samples + gap handling), `ManagedStream` (self-healing single channel), `MultiStream` (shared socket, many SSRCs)
67
68
  - **Typed status decoder** — `ChannelStatus`, `FrontendStatus`, `PllStatus`, etc. with dotted-path field access
68
69
  - **Precise RTP timing** — GPS_TIME / RTP_TIMESNAP for sample-accurate wallclock timestamps
@@ -78,6 +79,19 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
78
79
  pip install ka9q-python
79
80
  ```
80
81
 
82
+ Optional extras:
83
+
84
+ | Extra | Adds | Needed for |
85
+ |-------|------|------------|
86
+ | `tui` | `textual` | `ka9q tui` interactive terminal UI |
87
+ | `opus` | `opuslib` | decoding `OPUS` / `OPUS_VOIP` RTP payloads via `OpusDecoder` |
88
+ | `dev` | `pytest`, `pytest-cov` | running the test suite |
89
+
90
+ ```bash
91
+ pip install "ka9q-python[opus]" # one extra
92
+ pip install "ka9q-python[tui,opus]" # multiple
93
+ ```
94
+
81
95
  Or install from source:
82
96
 
83
97
  ```bash
@@ -23,7 +23,8 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
23
23
 
24
24
  ## Features
25
25
 
26
- - **Complete radiod API** — all 110+ TLV status/command parameters exposed, generated from ka9q-radio's C headers
26
+ - **Complete radiod API** — all 117 TLV status/command parameters exposed, generated from ka9q-radio's C headers
27
+ - **Every radiod RTP encoding decoded** — `S16LE/BE`, `F32LE/BE`, `F16LE/BE`, `MULAW`, `ALAW` via pure-NumPy `parse_rtp_samples()`; `OPUS` / `OPUS_VOIP` via the optional `OpusDecoder` (install with `[opus]` extra)
27
28
  - **Four stream abstractions** — `RTPRecorder` (raw packets), `RadiodStream` (samples + gap handling), `ManagedStream` (self-healing single channel), `MultiStream` (shared socket, many SSRCs)
28
29
  - **Typed status decoder** — `ChannelStatus`, `FrontendStatus`, `PllStatus`, etc. with dotted-path field access
29
30
  - **Precise RTP timing** — GPS_TIME / RTP_TIMESNAP for sample-accurate wallclock timestamps
@@ -39,6 +40,19 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
39
40
  pip install ka9q-python
40
41
  ```
41
42
 
43
+ Optional extras:
44
+
45
+ | Extra | Adds | Needed for |
46
+ |-------|------|------------|
47
+ | `tui` | `textual` | `ka9q tui` interactive terminal UI |
48
+ | `opus` | `opuslib` | decoding `OPUS` / `OPUS_VOIP` RTP payloads via `OpusDecoder` |
49
+ | `dev` | `pytest`, `pytest-cov` | running the test suite |
50
+
51
+ ```bash
52
+ pip install "ka9q-python[opus]" # one extra
53
+ pip install "ka9q-python[tui,opus]" # multiple
54
+ ```
55
+
42
56
  Or install from source:
43
57
 
44
58
  ```bash
@@ -150,7 +150,7 @@ Tuning & filter:
150
150
 
151
151
  Gain / AGC:
152
152
  - `set_gain(ssrc, gain_db)`
153
- - `set_agc(ssrc, enable, hangtime=None, recovery_rate=None, threshold=None)`
153
+ - `set_agc(ssrc, enable, hangtime=None, headroom=None, recovery_rate=None, threshold=None)`
154
154
  - `set_agc_hangtime(ssrc, seconds)`
155
155
  - `set_agc_recovery_rate(ssrc, db_per_sec)`
156
156
  - `set_agc_threshold(ssrc, threshold_db)`
@@ -286,20 +286,20 @@ for the full list — they mirror ka9q-radio's `status.h`.
286
286
 
287
287
  RTP output encoding (integer values match ka9q-radio's `rtp.h`):
288
288
 
289
- | Name | Value | Notes |
290
- |---|---|---|
291
- | `NO_ENCODING` | 0 | radiod default |
292
- | `S16LE` | 1 | Signed 16-bit little-endian PCM |
293
- | `S16BE` | 2 | Signed 16-bit big-endian PCM |
294
- | `OPUS` | 3 | Opus audio |
295
- | `F32LE` | 4 | 32-bit float LE (also `Encoding.F32`) |
296
- | `AX25` | 5 | Packet radio |
297
- | `F16LE` | 6 | 16-bit float LE (also `Encoding.F16`) |
298
- | `OPUS_VOIP` | 7 | Opus with `APPLICATION_VOIP` |
299
- | `F32BE` | 8 | 32-bit float BE |
300
- | `F16BE` | 9 | 16-bit float BE |
301
- | `MULAW` | 10 | μ-law |
302
- | `ALAW` | 11 | A-law |
289
+ | Name | Value | Decoded by | Notes |
290
+ |---|---|---|---|
291
+ | `NO_ENCODING` | 0 | `parse_rtp_samples` | radiod default → treated as F32LE |
292
+ | `S16LE` | 1 | `parse_rtp_samples` | Signed 16-bit little-endian PCM |
293
+ | `S16BE` | 2 | `parse_rtp_samples` | Signed 16-bit big-endian PCM |
294
+ | `OPUS` | 3 | `OpusDecoder` (extra) | Opus audio — needs `[opus]` install extra |
295
+ | `F32LE` | 4 | `parse_rtp_samples` | 32-bit float LE (also `Encoding.F32`) |
296
+ | `AX25` | 5 | (caller handles bytes) | Packet radio — framed protocol, not samples |
297
+ | `F16LE` | 6 | `parse_rtp_samples` | 16-bit float LE (also `Encoding.F16`) |
298
+ | `OPUS_VOIP` | 7 | `OpusDecoder` (extra) | Opus with `APPLICATION_VOIP` |
299
+ | `F32BE` | 8 | `parse_rtp_samples` | 32-bit float BE |
300
+ | `F16BE` | 9 | `parse_rtp_samples` | 16-bit float BE |
301
+ | `MULAW` | 10 | `parse_rtp_samples` | G.711 µ-law (table-based, pure NumPy) |
302
+ | `ALAW` | 11 | `parse_rtp_samples` | G.711 A-law (table-based, pure NumPy) |
303
303
 
304
304
  ### `DemodType`
305
305
 
@@ -417,6 +417,57 @@ Derived properties:
417
417
 
418
418
  ## Streams
419
419
 
420
+ ### `parse_rtp_samples()`
421
+
422
+ Source: [stream.py](../ka9q/stream.py). Decodes one RTP payload to a
423
+ NumPy sample array. Shared by `RadiodStream` and `MultiStream`.
424
+
425
+ ```python
426
+ parse_rtp_samples(
427
+ payload: bytes,
428
+ encoding: int, # any Encoding.* value
429
+ is_iq: bool, # True → complex64 output; False → float32
430
+ ) -> np.ndarray | None
431
+ ```
432
+
433
+ Covers every linear-PCM encoding radiod's `pt_from_info()` can grant:
434
+ `NO_ENCODING`, `S16LE`, `S16BE`, `F32LE`, `F32BE`, `F16LE`, `F16BE`,
435
+ `MULAW`, `ALAW`. G.711 µ-law / A-law use table-based NumPy decoders
436
+ (no `audioop` dependency — that module is removed in Python 3.13).
437
+ Unsupported encodings log a warning and fall back to F32LE.
438
+
439
+ Returns `None` for `OPUS`, `OPUS_VOIP` (use `OpusDecoder` below), and
440
+ `AX25` (framed protocol data, handle the bytes yourself).
441
+
442
+ ### `OpusDecoder`
443
+
444
+ Source: [stream.py](../ka9q/stream.py). Optional Opus payload decoder
445
+ for radiod's `OPUS` / `OPUS_VOIP` streams. Requires the `[opus]`
446
+ install extra (`pip install ka9q-python[opus]`, which pulls
447
+ `opuslib`).
448
+
449
+ ```python
450
+ OpusDecoder(sample_rate: int = 48000, channels: int = 1)
451
+ ```
452
+
453
+ - `decode(payload: bytes, *, fec: bool = False) -> np.ndarray` —
454
+ one Opus frame → float32 PCM, normalised to ±1.0. Stereo output is
455
+ interleaved L,R,L,R,…. Empty payload triggers one frame of
456
+ packet-loss concealment.
457
+ - `sample_rate` / `channels` properties expose the codec config.
458
+
459
+ Maintain one `OpusDecoder` instance per stream SSRC so the internal
460
+ codec state — and therefore PLC — works correctly across packets.
461
+ `sample_rate` must be one of 8000/12000/16000/24000/48000.
462
+
463
+ ```python
464
+ from ka9q.stream import OpusDecoder
465
+
466
+ dec = OpusDecoder(sample_rate=48000, channels=1)
467
+ for payload in opus_payloads:
468
+ samples = dec.decode(payload) # float32, mono
469
+ ```
470
+
420
471
  ### `RadiodStream`
421
472
 
422
473
  Source: [stream.py](../ka9q/stream.py). Continuous-sample consumer
@@ -77,6 +77,17 @@ to handle out-of-order RTP. Hands each batch to an
77
77
  `on_samples(samples, quality)` callback. Does **not** heal radiod
78
78
  restarts — a restart produces a lasting silence.
79
79
 
80
+ `stream.py` also exposes two payload-decoder helpers shared by every
81
+ stream class:
82
+
83
+ - `parse_rtp_samples(payload, encoding, is_iq)` — decodes every
84
+ linear-PCM encoding radiod emits (`S16LE/BE`, `F32LE/BE`,
85
+ `F16LE/BE`, `MULAW`, `ALAW`) to NumPy in pure Python, no
86
+ `audioop` (gone in Py 3.13).
87
+ - `OpusDecoder(sample_rate, channels)` — decodes `OPUS` / `OPUS_VOIP`
88
+ via the optional `opuslib` dependency (`pip install ka9q-python[opus]`).
89
+ One instance per SSRC so codec state and PLC stay coherent.
90
+
80
91
  ### 3. `ManagedStream` ([managed_stream.py](../ka9q/managed_stream.py))
81
92
 
82
93
  High-level self-healing wrapper around `RadiodStream`. A background
@@ -41,6 +41,18 @@ pip install -e ".[dev]"
41
41
 
42
42
  This includes pytest and other testing tools.
43
43
 
44
+ ### Optional Extras
45
+
46
+ `ka9q-python` defines three optional dependency groups:
47
+
48
+ | Extra | Adds | Needed for |
49
+ |-------|------|------------|
50
+ | `dev` | `pytest`, `pytest-cov` | running the test suite |
51
+ | `tui` | `textual` | the `ka9q tui` interactive terminal UI |
52
+ | `opus` | `opuslib` | decoding `OPUS` / `OPUS_VOIP` RTP payloads with `ka9q.stream.OpusDecoder` |
53
+
54
+ Combine as needed, e.g. `pip install -e ".[dev,opus]"`.
55
+
44
56
  ## For Use in Other Applications
45
57
 
46
58
  ### In requirements.txt
@@ -98,7 +110,7 @@ python3 -c "import ka9q; print(f'ka9q version {ka9q.__version__} installed succe
98
110
 
99
111
  Expected output (version will match whatever is installed):
100
112
  ```
101
- ka9q version 3.9.0 installed successfully
113
+ ka9q version 3.15.0 installed successfully
102
114
  ```
103
115
 
104
116
  After installation, the `ka9q` command-line tool is also on your `PATH`:
@@ -131,7 +143,8 @@ Package works with Python's built-in networking. Use IP addresses instead of `.l
131
143
  - `numpy >= 1.24.0`
132
144
 
133
145
  ### Optional (for enhanced functionality)
134
- - `textual` — required for `ka9q tui`. Not pulled in by the base install; install with `pip install textual` (or bundle it into your deployment requirements).
146
+ - `textual` — required for `ka9q tui`. Install via the `[tui]` extra or `pip install textual` directly.
147
+ - `opuslib` — required to decode `OPUS` / `OPUS_VOIP` RTP payloads via `ka9q.stream.OpusDecoder`. Install via the `[opus]` extra or `pip install opuslib`. (Linear-PCM encodings — `S16LE/BE`, `F32LE/BE`, `F16LE/BE`, `MULAW`, `ALAW` — are decoded in pure NumPy and need no extra dependency.)
135
148
  - `avahi-utils` (Linux) — faster mDNS hostname resolution
136
149
  - `control` from ka9q-radio — fallback path for channel discovery
137
150
 
@@ -187,11 +200,13 @@ pip uninstall ka9q
187
200
  ```bash
188
201
  pip install build
189
202
  python3 -m build
203
+ # or, with uv:
204
+ uv build
190
205
  ```
191
206
 
192
- This creates:
193
- - `dist/ka9q-1.0.0-py3-none-any.whl` (wheel)
194
- - `dist/ka9q-1.0.0.tar.gz` (source distribution)
207
+ This creates (filenames track the current version in `pyproject.toml`):
208
+ - `dist/ka9q_python-X.Y.Z-py3-none-any.whl` (wheel)
209
+ - `dist/ka9q_python-X.Y.Z.tar.gz` (source distribution)
195
210
 
196
211
  ### Upload to PyPI (maintainers only)
197
212
  ```bash
@@ -254,10 +254,16 @@ Based on the current source ([`ka9q/multi_stream.py`](../ka9q/multi_stream.py)):
254
254
  - **No `remove_channel()` in the public API.** To remove a channel,
255
255
  stop the `MultiStream`, rebuild it with the remaining channels, and
256
256
  start again. For long-lived recorders this has been acceptable.
257
- - **Opus encoded streams** `parse_rtp_samples()` must understand
258
- the encoding. S16LE/S16BE (1/2) and F32LE (4) are the well-tested
259
- paths; if you need Opus, verify against
260
- [`ka9q/stream.py`](../ka9q/stream.py).
257
+ - **Linear-PCM encodings** are decoded by `parse_rtp_samples()` in
258
+ pure NumPy: `S16LE/BE`, `F32LE/BE`, `F16LE/BE`, `MULAW`, `ALAW`.
259
+ All radiod-emitted sample encodings work out of the box.
260
+ - **Opus** (`OPUS`, `OPUS_VOIP`) is a framed codec, not raw samples
261
+ — `parse_rtp_samples()` returns `None` for those payloads. Wrap
262
+ the stream with `ka9q.stream.OpusDecoder` (requires the `[opus]`
263
+ install extra) to recover float32 PCM, keeping one decoder
264
+ instance per SSRC so packet-loss concealment works.
265
+ - **AX25** (encoding 5) is framed protocol data, also not samples —
266
+ `parse_rtp_samples()` returns `None`. Handle the bytes yourself.
261
267
  - **`samples_per_packet=320` default** assumes the typical 12 kHz /
262
268
  26.67 ms RTP packetization used by radiod. If your channels run
263
269
  at a different packet cadence, set it explicitly.
@@ -56,7 +56,15 @@ Lower-level usage (explicit control):
56
56
  )
57
57
  print(f"Created channel with SSRC: {ssrc}")
58
58
  """
59
- __version__ = '3.14.2'
59
+ try:
60
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError as _PkgNotFound
61
+ try:
62
+ __version__ = _pkg_version("ka9q-python")
63
+ except _PkgNotFound:
64
+ __version__ = "0.0.0+unknown"
65
+ del _pkg_version, _PkgNotFound
66
+ except ImportError:
67
+ __version__ = "0.0.0+unknown"
60
68
  __author__ = 'Michael Hauan AC0G'
61
69
 
62
70
  from .control import RadiodControl, allocate_ssrc
@@ -107,6 +115,7 @@ from .managed_stream import (
107
115
  )
108
116
  from .multi_stream import MultiStream
109
117
  from .spectrum_stream import SpectrumStream
118
+ from .status_listener import StatusListener, StatusListenerStats
110
119
 
111
120
  __all__ = [
112
121
  # Control
@@ -170,6 +179,10 @@ __all__ = [
170
179
  # Spectrum Stream (FFT bin data receiver)
171
180
  'SpectrumStream',
172
181
 
182
+ # Continuous STATUS listener (live timing anchor refresh)
183
+ 'StatusListener',
184
+ 'StatusListenerStats',
185
+
173
186
  # Utilities
174
187
  'generate_multicast_ip',
175
188
  'ChannelMonitor',