ka9q-python 3.14.2__tar.gz → 3.15.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 (107) hide show
  1. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/CHANGELOG.md +61 -0
  2. {ka9q_python-3.14.2/ka9q_python.egg-info → ka9q_python-3.15.1}/PKG-INFO +18 -2
  3. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/README.md +15 -1
  4. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/API_REFERENCE.md +66 -15
  5. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/ARCHITECTURE.md +11 -0
  6. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/INSTALLATION.md +20 -5
  7. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/MULTI_STREAM.md +10 -4
  8. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/__init__.py +9 -1
  9. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/compat.py +1 -1
  10. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/control.py +18 -146
  11. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/multi_stream.py +10 -1
  12. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/stream.py +149 -15
  13. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/types.py +1 -1
  14. {ka9q_python-3.14.2 → ka9q_python-3.15.1/ka9q_python.egg-info}/PKG-INFO +18 -2
  15. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q_python.egg-info/SOURCES.txt +1 -0
  16. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q_python.egg-info/requires.txt +3 -0
  17. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q_radio_compat +1 -1
  18. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/pyproject.toml +4 -1
  19. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_filter_edges.py +1 -0
  20. ka9q_python-3.15.1/tests/test_parse_rtp_samples_iq.py +118 -0
  21. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/LICENSE +0 -0
  22. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/MANIFEST.in +0 -0
  23. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/CLI_GUIDE.md +0 -0
  24. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/GETTING_STARTED.md +0 -0
  25. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/RECIPES.md +0 -0
  26. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/RTP_TIMING_SUPPORT.md +0 -0
  27. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/SECURITY.md +0 -0
  28. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/TESTING_GUIDE.md +0 -0
  29. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/docs/TUI_GUIDE.md +0 -0
  30. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/advanced_features_demo.py +0 -0
  31. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/channel_cleanup_example.py +0 -0
  32. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/codar_oceanography.py +0 -0
  33. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/diagnostics/diagnose_packets.py +0 -0
  34. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/diagnostics/repro_utc_bug.py +0 -0
  35. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/discover_example.py +0 -0
  36. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/grape_integration_example.py +0 -0
  37. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/hf_band_scanner.py +0 -0
  38. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/multi_stream_smoke.py +0 -0
  39. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/rtp_recorder_example.py +0 -0
  40. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/simple_am_radio.py +0 -0
  41. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/spectrum_example.py +0 -0
  42. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/stream_example.py +0 -0
  43. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/superdarn_recorder.py +0 -0
  44. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/test_channel_operations.py +0 -0
  45. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/test_improvements.py +0 -0
  46. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/test_timing_fields.py +0 -0
  47. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/tune.py +0 -0
  48. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/examples/tune_example.py +0 -0
  49. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/_multicast.py +0 -0
  50. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/addressing.py +0 -0
  51. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/cli.py +0 -0
  52. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/discovery.py +0 -0
  53. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/exceptions.py +0 -0
  54. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/managed_stream.py +0 -0
  55. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/monitor.py +0 -0
  56. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/pps_calibrator.py +0 -0
  57. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/resequencer.py +0 -0
  58. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/rtp_recorder.py +0 -0
  59. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/spectrum_stream.py +0 -0
  60. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/status.py +0 -0
  61. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/stream_quality.py +0 -0
  62. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/tui.py +0 -0
  63. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q/utils.py +0 -0
  64. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q_python.egg-info/dependency_links.txt +0 -0
  65. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q_python.egg-info/entry_points.txt +0 -0
  66. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/ka9q_python.egg-info/top_level.txt +0 -0
  67. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/scripts/check_upstream_drift.py +0 -0
  68. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/scripts/sync_types.py +0 -0
  69. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/setup.cfg +0 -0
  70. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/setup.py +0 -0
  71. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/__init__.py +0 -0
  72. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/conftest.py +0 -0
  73. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_addressing.py +0 -0
  74. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_channel_verification.py +0 -0
  75. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_client_id_destination.py +0 -0
  76. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_create_split_encoding.py +0 -0
  77. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_decode_description.py +0 -0
  78. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_decode_functions.py +0 -0
  79. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_encode_functions.py +0 -0
  80. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_encode_socket.py +0 -0
  81. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_ensure_channel_encoding.py +0 -0
  82. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_integration.py +0 -0
  83. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_iq_20khz_f32.py +0 -0
  84. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_lifetime.py +0 -0
  85. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_listen_multicast.py +0 -0
  86. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_managed_stream_recovery.py +0 -0
  87. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_monitor.py +0 -0
  88. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_multicast_helpers.py +0 -0
  89. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_multihomed.py +0 -0
  90. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_native_discovery.py +0 -0
  91. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_performance_fixes.py +0 -0
  92. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_protocol_compat.py +0 -0
  93. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_remove_channel.py +0 -0
  94. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_rtp_recorder.py +0 -0
  95. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_security_features.py +0 -0
  96. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_spectrum.py +0 -0
  97. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_ssrc_dest_unit.py +0 -0
  98. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_ssrc_encoding_unit.py +0 -0
  99. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_ssrc_radiod_host_unit.py +0 -0
  100. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_status_decoder.py +0 -0
  101. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_ttl_warning.py +0 -0
  102. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_tune.py +0 -0
  103. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_tune_cli.py +0 -0
  104. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_tune_debug.py +0 -0
  105. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_tune_live.py +0 -0
  106. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_tune_method.py +0 -0
  107. {ka9q_python-3.14.2 → ka9q_python-3.15.1}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.15.1] - 2026-05-21
4
+
5
+ ### Fixed
6
+
7
+ - **`ka9q.__version__` was stale.** v3.15.0's wheel correctly identified
8
+ itself as `3.15.0` in dist-info, but the `__version__` string baked
9
+ into `ka9q/__init__.py` still read `'3.14.2'` — the in-package
10
+ introspection answer was wrong for every consumer. The fix replaces
11
+ the hardcoded literal with `importlib.metadata.version("ka9q-python")`,
12
+ so `__version__` now always tracks the installed dist-info — drift
13
+ between release version and code-reported version is no longer
14
+ possible. Falls back to `"0.0.0+unknown"` on import error or when
15
+ the package is not installed (e.g. running from a source tree
16
+ without an editable install).
17
+
18
+ ## [3.15.0] - 2026-05-21
19
+
20
+ ### Added
21
+
22
+ - **F16LE / F16BE RTP payload decoding** in `parse_rtp_samples` for radiod's
23
+ float16 output mode (encodings 6 and 9). Both audio and IQ paths.
24
+ - **G.711 µ-law / A-law decoders** for encodings 10 and 11 — pure-numpy
25
+ table-based, no `audioop` dependency (which was removed in Python 3.13).
26
+ - **`OpusDecoder` class** in `ka9q.stream` for OPUS / OPUS_VOIP payloads
27
+ (encodings 3 and 7). Lazy-imports `opuslib`; install via
28
+ `pip install ka9q-python[opus]`. Maintains codec state across calls so
29
+ packet-loss concealment works end-to-end — one instance per stream SSRC.
30
+ - New `opus` optional dependency in `pyproject.toml` (`opuslib>=3.0`).
31
+
32
+ ### Fixed
33
+
34
+ - **`RadiodControl.set_agc(attack_rate=...)` was unreachable.** It encoded
35
+ `StatusType.AGC_ATTACK_RATE`, which does not exist in ka9q-radio's
36
+ `status.h` or in `ka9q/types.py` — any call passing `attack_rate=` raised
37
+ `AttributeError`. Replaced with a working `threshold:` kwarg backed by
38
+ `AGC_THRESHOLD` (which radiod actually decodes in
39
+ `decode_radio_commands()`).
40
+ - **Removed 6 stale duplicate `set_*` methods.** In a single class, second
41
+ defs silently shadow firsts — so callers were already using the correct
42
+ versions further down the file. The dead first defs of `set_squelch`,
43
+ `set_pll`, `set_output_channels`, `set_independent_sideband`,
44
+ `set_envelope_detection`, and `set_opus_bitrate` are gone. One of them
45
+ (`set_opus_bitrate`) referenced a non-existent `StatusType.OPUS_BITRATE`
46
+ — the working second def uses the correct `OPUS_BIT_RATE`.
47
+
48
+ ### Tooling / pinning
49
+
50
+ - Pin advanced from ka9q-radio `f78cff9c` (1.0.0-22) to `d555f1853422`.
51
+ Drift report confirms zero TLV-tag, encoding-enum, demod-type, or
52
+ window-type changes across the 68 intervening upstream commits — only
53
+ packaging, hydrasdr-driver, and internal C-struct refactors. Existing
54
+ ka9q-python state (`types.py`, `decode_status_packet`, `SpectrumStream`,
55
+ every `set_*` method) was already covering the full HEAD protocol
56
+ surface; this release brings the recorded compatibility tag in line.
57
+
58
+ ### Tests
59
+
60
+ - Fixed pre-existing `tests/test_filter_edges.py::_bare_control` helper
61
+ that did not initialise the `client_id` attribute added with v3.14.0
62
+ per-(client,radiod) multicast destinations.
63
+
3
64
  ## [3.14.2] - 2026-05-14
4
65
 
5
66
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.14.2
3
+ Version: 3.15.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
@@ -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
@@ -60,7 +62,8 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
60
62
 
61
63
  ## Features
62
64
 
63
- - **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)
64
67
  - **Four stream abstractions** — `RTPRecorder` (raw packets), `RadiodStream` (samples + gap handling), `ManagedStream` (self-healing single channel), `MultiStream` (shared socket, many SSRCs)
65
68
  - **Typed status decoder** — `ChannelStatus`, `FrontendStatus`, `PllStatus`, etc. with dotted-path field access
66
69
  - **Precise RTP timing** — GPS_TIME / RTP_TIMESNAP for sample-accurate wallclock timestamps
@@ -76,6 +79,19 @@ Control radiod channels for any application: AM/FM/SSB radio, WSPR monitoring, S
76
79
  pip install ka9q-python
77
80
  ```
78
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
+
79
95
  Or install from source:
80
96
 
81
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
@@ -12,4 +12,4 @@ Usage:
12
12
  print(f"Compatible with ka9q-radio at {KA9Q_RADIO_COMMIT}")
13
13
  """
14
14
 
15
- KA9Q_RADIO_COMMIT: str = "f78cff9cc8c7fa33ea49aac9176d9263c535a332"
15
+ KA9Q_RADIO_COMMIT: str = "d555f1853422ecc551ac7e4064b9d9cfdb743764"
@@ -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
- attack_rate: Optional[float] = None):
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 (optional)
986
- headroom: Target headroom in dB (optional)
987
- recovery_rate: AGC recovery rate (optional)
988
- attack_rate: AGC attack rate (optional)
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 attack_rate is not None:
1001
- encode_float(cmdbuffer, StatusType.AGC_ATTACK_RATE, attack_rate)
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}, hangtime={hangtime}, headroom={headroom}")
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=encoding,
218
+ encoding=granted_encoding,
210
219
  is_iq=is_iq,
211
220
  resequencer=PacketResequencer(
212
221
  buffer_size=self._resequence_buffer_size,