ka9q-python 3.14.2__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.
Files changed (107) hide show
  1. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/CHANGELOG.md +46 -0
  2. {ka9q_python-3.14.2/ka9q_python.egg-info → ka9q_python-3.15.0}/PKG-INFO +3 -1
  3. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/compat.py +1 -1
  4. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/control.py +18 -146
  5. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/multi_stream.py +10 -1
  6. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/stream.py +149 -15
  7. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/types.py +1 -1
  8. {ka9q_python-3.14.2 → ka9q_python-3.15.0/ka9q_python.egg-info}/PKG-INFO +3 -1
  9. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q_python.egg-info/SOURCES.txt +1 -0
  10. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q_python.egg-info/requires.txt +3 -0
  11. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q_radio_compat +1 -1
  12. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/pyproject.toml +4 -1
  13. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_filter_edges.py +1 -0
  14. ka9q_python-3.15.0/tests/test_parse_rtp_samples_iq.py +118 -0
  15. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/LICENSE +0 -0
  16. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/MANIFEST.in +0 -0
  17. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/README.md +0 -0
  18. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/API_REFERENCE.md +0 -0
  19. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/ARCHITECTURE.md +0 -0
  20. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/CLI_GUIDE.md +0 -0
  21. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/GETTING_STARTED.md +0 -0
  22. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/INSTALLATION.md +0 -0
  23. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/MULTI_STREAM.md +0 -0
  24. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/RECIPES.md +0 -0
  25. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  26. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/SECURITY.md +0 -0
  27. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/TESTING_GUIDE.md +0 -0
  28. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/docs/TUI_GUIDE.md +0 -0
  29. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/advanced_features_demo.py +0 -0
  30. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/channel_cleanup_example.py +0 -0
  31. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/codar_oceanography.py +0 -0
  32. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/diagnostics/diagnose_packets.py +0 -0
  33. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  34. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/discover_example.py +0 -0
  35. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/grape_integration_example.py +0 -0
  36. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/hf_band_scanner.py +0 -0
  37. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/multi_stream_smoke.py +0 -0
  38. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/rtp_recorder_example.py +0 -0
  39. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/simple_am_radio.py +0 -0
  40. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/spectrum_example.py +0 -0
  41. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/stream_example.py +0 -0
  42. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/superdarn_recorder.py +0 -0
  43. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/test_channel_operations.py +0 -0
  44. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/test_improvements.py +0 -0
  45. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/test_timing_fields.py +0 -0
  46. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/tune.py +0 -0
  47. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/examples/tune_example.py +0 -0
  48. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/__init__.py +0 -0
  49. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/_multicast.py +0 -0
  50. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/addressing.py +0 -0
  51. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/cli.py +0 -0
  52. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/discovery.py +0 -0
  53. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/exceptions.py +0 -0
  54. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/managed_stream.py +0 -0
  55. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/monitor.py +0 -0
  56. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/pps_calibrator.py +0 -0
  57. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/resequencer.py +0 -0
  58. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/rtp_recorder.py +0 -0
  59. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/spectrum_stream.py +0 -0
  60. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/status.py +0 -0
  61. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/stream_quality.py +0 -0
  62. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/tui.py +0 -0
  63. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q/utils.py +0 -0
  64. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  65. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q_python.egg-info/entry_points.txt +0 -0
  66. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/ka9q_python.egg-info/top_level.txt +0 -0
  67. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/scripts/check_upstream_drift.py +0 -0
  68. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/scripts/sync_types.py +0 -0
  69. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/setup.cfg +0 -0
  70. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/setup.py +0 -0
  71. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/__init__.py +0 -0
  72. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/conftest.py +0 -0
  73. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_addressing.py +0 -0
  74. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_channel_verification.py +0 -0
  75. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_client_id_destination.py +0 -0
  76. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_create_split_encoding.py +0 -0
  77. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_decode_description.py +0 -0
  78. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_decode_functions.py +0 -0
  79. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_encode_functions.py +0 -0
  80. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_encode_socket.py +0 -0
  81. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_ensure_channel_encoding.py +0 -0
  82. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_integration.py +0 -0
  83. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_iq_20khz_f32.py +0 -0
  84. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_lifetime.py +0 -0
  85. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_listen_multicast.py +0 -0
  86. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_managed_stream_recovery.py +0 -0
  87. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_monitor.py +0 -0
  88. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_multicast_helpers.py +0 -0
  89. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_multihomed.py +0 -0
  90. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_native_discovery.py +0 -0
  91. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_performance_fixes.py +0 -0
  92. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_protocol_compat.py +0 -0
  93. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_remove_channel.py +0 -0
  94. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_rtp_recorder.py +0 -0
  95. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_security_features.py +0 -0
  96. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_spectrum.py +0 -0
  97. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_ssrc_dest_unit.py +0 -0
  98. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_ssrc_encoding_unit.py +0 -0
  99. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  100. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_status_decoder.py +0 -0
  101. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_ttl_warning.py +0 -0
  102. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_tune.py +0 -0
  103. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_tune_cli.py +0 -0
  104. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_tune_debug.py +0 -0
  105. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_tune_live.py +0 -0
  106. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_tune_method.py +0 -0
  107. {ka9q_python-3.14.2 → ka9q_python-3.15.0}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,51 @@
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
+
3
49
  ## [3.14.2] - 2026-05-14
4
50
 
5
51
  ### 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.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
@@ -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,
@@ -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 (0=none/F32LE, 1=S16LE, 2=S16BE, 4=F32LE, 8=F32BE).
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
- floats = np.frombuffer(payload, dtype=np.float32)
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 floats in IQ payload: {len(floats)}")
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
- else:
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: f78cff9cc8c7
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.14.2
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
@@ -85,6 +85,7 @@ tests/test_monitor.py
85
85
  tests/test_multicast_helpers.py
86
86
  tests/test_multihomed.py
87
87
  tests/test_native_discovery.py
88
+ tests/test_parse_rtp_samples_iq.py
88
89
  tests/test_performance_fixes.py
89
90
  tests/test_protocol_compat.py
90
91
  tests/test_remove_channel.py
@@ -4,5 +4,8 @@ numpy>=1.24.0
4
4
  pytest>=7.0.0
5
5
  pytest-cov>=4.0.0
6
6
 
7
+ [opus]
8
+ opuslib>=3.0
9
+
7
10
  [tui]
8
11
  textual>=0.50
@@ -1,3 +1,3 @@
1
1
  # ka9q-radio commit that ka9q/types.py was last validated against
2
2
  # Updated by: scripts/sync_types.py --apply
3
- f78cff9cc8c7fa33ea49aac9176d9263c535a332
3
+ d555f1853422ecc551ac7e4064b9d9cfdb743764
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.14.2"
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"
@@ -38,6 +38,7 @@ def _bare_control() -> RadiodControl:
38
38
  c._command_window_start = time.time()
39
39
  c._rate_limit_lock = threading.Lock()
40
40
  c.metrics = MagicMock()
41
+ c.client_id = None
41
42
  return c
42
43
 
43
44
 
@@ -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"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes