ka9q-python 3.7.0__tar.gz → 3.8.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 (156) hide show
  1. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/CHANGELOG.md +34 -0
  2. {ka9q_python-3.7.0/ka9q_python.egg-info → ka9q_python-3.8.0}/PKG-INFO +1 -1
  3. ka9q_python-3.8.0/examples/multi_stream_smoke.py +106 -0
  4. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/__init__.py +7 -3
  5. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/managed_stream.py +6 -1
  6. ka9q_python-3.8.0/ka9q/multi_stream.py +452 -0
  7. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/stream.py +43 -18
  8. {ka9q_python-3.7.0 → ka9q_python-3.8.0/ka9q_python.egg-info}/PKG-INFO +1 -1
  9. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/SOURCES.txt +2 -0
  10. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/pyproject.toml +1 -1
  11. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/LICENSE +0 -0
  12. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/MANIFEST.in +0 -0
  13. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/README.md +0 -0
  14. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/RELEASE_NOTES_v3.5.0.md +0 -0
  15. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/API_REFERENCE.md +0 -0
  16. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/ARCHITECTURE.md +0 -0
  17. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/CHANGELOG.md +0 -0
  18. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/CROSS_PLATFORM_SUPPORT.md +0 -0
  19. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/DISTRIBUTION_RECOMMENDATION.md +0 -0
  20. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/GETTING_STARTED.md +0 -0
  21. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/INSTALLATION.md +0 -0
  22. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/MULTI_HOMED_QUICK_REF.md +0 -0
  23. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/NATIVE_DISCOVERY.md +0 -0
  24. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/PYPI_PUBLICATION_GUIDE.md +0 -0
  25. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/QUICK_REFERENCE.md +0 -0
  26. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/RTP_RECORDER_PASS_ALL_PACKETS.md +0 -0
  27. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/RTP_TIMING_IMPLEMENTATION.md +0 -0
  28. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  29. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/SECURITY.md +0 -0
  30. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/SSRC_COLLISION_PREVENTION.md +0 -0
  31. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/TESTING_GUIDE.md +0 -0
  32. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/TESTING_SUMMARY.md +0 -0
  33. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/TEST_RESULTS.md +0 -0
  34. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_ENHANCEMENT_GUIDE.md +0 -0
  35. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_ENHANCEMENT_IMPLEMENTED.md +0 -0
  36. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_ESCAPE_SEQUENCE_FIX.md +0 -0
  37. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_FUNCTIONALITY_REVIEW.md +0 -0
  38. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_IMPLEMENTATION_STATUS.md +0 -0
  39. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_INTERACTIVE_COMPLETE.md +0 -0
  40. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANGES_SUMMARY.md +0 -0
  41. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANNEL_CLEANUP_ADDITION.md +0 -0
  42. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANNEL_CLEANUP_COMPLETE.md +0 -0
  43. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANNEL_TUNING_DIAGNOSTICS.md +0 -0
  44. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CODE_REVIEW_RECOMMENDATIONS.md +0 -0
  45. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CODE_REVIEW_SUMMARY.md +0 -0
  46. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/COMMIT_SUMMARY.md +0 -0
  47. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CRITICAL_FIXES_CHECKLIST.md +0 -0
  48. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/FINAL_SUMMARY.md +0 -0
  49. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/FIXES_SUMMARY.md +0 -0
  50. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/GIT_STATUS_SUMMARY.md +0 -0
  51. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_COMPLETE.md +0 -0
  52. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_STATUS.md +0 -0
  53. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_SUMMARY.md +0 -0
  54. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_SUMMARY_v2.2.0.md +0 -0
  55. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPROVEMENTS_IMPLEMENTED.md +0 -0
  56. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_ACTION_PLAN.md +0 -0
  57. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_IMPLEMENTATION_COMPLETE.md +0 -0
  58. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_SUPPORT_REVIEW.md +0 -0
  59. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PACKAGE_STATUS.md +0 -0
  60. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PACKAGE_VERIFICATION.md +0 -0
  61. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_FIXES_APPLIED.md +0 -0
  62. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_REVIEW.md +0 -0
  63. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_REVIEW_V2.md +0 -0
  64. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_SUMMARY.md +0 -0
  65. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/QUICK_ACTION_ITEMS.md +0 -0
  66. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/QUICK_START_DIAGNOSIS.md +0 -0
  67. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/RELEASE_CHECKLIST_v3.0.0.md +0 -0
  68. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/SUMMARY.md +0 -0
  69. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/SUMMARY_WEB_UI_FIXES.md +0 -0
  70. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/TUNE_IMPLEMENTATION.md +0 -0
  71. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/WEBUI_SUMMARY.md +0 -0
  72. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/WEB_UI_ENHANCEMENTS_COMPLETE.md +0 -0
  73. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/CONTROL_COMPARISON.md +0 -0
  74. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/DESTINATION_AWARE_CHANNELS.md +0 -0
  75. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/NEW_FEATURES.md +0 -0
  76. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/RADIOD_FEATURES_SUMMARY.md +0 -0
  77. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/RTP_DESTINATION_FEATURE.md +0 -0
  78. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_INSTRUCTIONS.md +0 -0
  79. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.2.0.md +0 -0
  80. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.4.0.md +0 -0
  81. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.5.0.md +0 -0
  82. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.0.0.md +0 -0
  83. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.1.0.md +0 -0
  84. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.2.0.md +0 -0
  85. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES.md +0 -0
  86. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.1.0.md +0 -0
  87. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.2.0.md +0 -0
  88. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.3.0.md +0 -0
  89. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.4.0.md +0 -0
  90. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RTP_TIMING_RELEASE_NOTES.md +0 -0
  91. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/advanced_features_demo.py +0 -0
  92. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/channel_cleanup_example.py +0 -0
  93. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/codar_oceanography.py +0 -0
  94. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/diagnostics/diagnose_packets.py +0 -0
  95. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  96. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/discover_example.py +0 -0
  97. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/grape_integration_example.py +0 -0
  98. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/hf_band_scanner.py +0 -0
  99. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/rtp_recorder_example.py +0 -0
  100. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/simple_am_radio.py +0 -0
  101. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/stream_example.py +0 -0
  102. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/superdarn_recorder.py +0 -0
  103. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/test_channel_operations.py +0 -0
  104. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/test_improvements.py +0 -0
  105. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/test_timing_fields.py +0 -0
  106. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/tune.py +0 -0
  107. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/tune_example.py +0 -0
  108. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/addressing.py +0 -0
  109. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/compat.py +0 -0
  110. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/control.py +0 -0
  111. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/discovery.py +0 -0
  112. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/exceptions.py +0 -0
  113. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/monitor.py +0 -0
  114. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/pps_calibrator.py +0 -0
  115. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/resequencer.py +0 -0
  116. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/rtp_recorder.py +0 -0
  117. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/stream_quality.py +0 -0
  118. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/types.py +0 -0
  119. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/utils.py +0 -0
  120. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  121. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/requires.txt +0 -0
  122. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/top_level.txt +0 -0
  123. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_radio_compat +0 -0
  124. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/scripts/sync_types.py +0 -0
  125. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/setup.cfg +0 -0
  126. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/setup.py +0 -0
  127. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/__init__.py +0 -0
  128. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/conftest.py +0 -0
  129. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_addressing.py +0 -0
  130. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_channel_verification.py +0 -0
  131. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_create_split_encoding.py +0 -0
  132. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_decode_functions.py +0 -0
  133. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_encode_functions.py +0 -0
  134. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_encode_socket.py +0 -0
  135. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ensure_channel_encoding.py +0 -0
  136. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_integration.py +0 -0
  137. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_iq_20khz_f32.py +0 -0
  138. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_listen_multicast.py +0 -0
  139. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_managed_stream_recovery.py +0 -0
  140. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_monitor.py +0 -0
  141. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_multihomed.py +0 -0
  142. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_native_discovery.py +0 -0
  143. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_performance_fixes.py +0 -0
  144. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_protocol_compat.py +0 -0
  145. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_remove_channel.py +0 -0
  146. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_rtp_recorder.py +0 -0
  147. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_security_features.py +0 -0
  148. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ssrc_dest_unit.py +0 -0
  149. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ssrc_encoding_unit.py +0 -0
  150. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  151. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ttl_warning.py +0 -0
  152. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune.py +0 -0
  153. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_cli.py +0 -0
  154. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_debug.py +0 -0
  155. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_live.py +0 -0
  156. {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_method.py +0 -0
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.8.0] - 2026-04-12
4
+
5
+ ### Added
6
+
7
+ - **`MultiStream`**: Shared-socket multi-SSRC receiver. Opens a single UDP socket and receive thread for all channels on a given multicast group, demultiplexes RTP packets by SSRC, and dispatches per-channel sample callbacks identical in shape to `RadiodStream`/`ManagedStream`. Solves the kernel-copy scalability problem that arose when N single-channel streams each bound their own socket on the same multicast group — every packet was delivered to every socket, and each worker parsed/discarded 95% of traffic in Python. `MultiStream` drops that load to one socket and one full-header parse per received packet (SSRC is peeked pre-parse). Includes per-channel drop detection and automatic restoration via `ensure_channel()`.
8
+ - **`parse_rtp_samples()`** helper in `stream.py`: factored out of `RadiodStream._parse_samples` so `MultiStream` and any future receiver can share one implementation of encoding-aware payload decoding (F32LE/F32BE, S16LE/S16BE, IQ).
9
+
10
+ ### Client migration
11
+
12
+ - **Multi-channel clients on one radiod**: consider replacing a loop of `ManagedStream` instances with a single `MultiStream`. API: `multi.add_channel(frequency_hz, preset, sample_rate, encoding, on_samples=..., on_stream_dropped=..., on_stream_restored=...)` per channel, then `multi.start()`. All channels must resolve to the same multicast group (enforced; raises `ValueError` on mismatch — caller can bucket into multiple `MultiStream`s).
13
+ - **Single-channel clients**: no change. `RadiodStream` / `ManagedStream` behavior is unchanged.
14
+
15
+ ### Verified
16
+
17
+ - Live 2-channel smoke test (`examples/multi_stream_smoke.py`) against `bee3-status.local`: FT8+WSPR 20m on shared group, 99.1% sample completeness, zero gaps, no unknown-SSRC warnings.
18
+ - End-to-end validation via psk-recorder migration: 5 channels on one multicast group, identical per-sink sample counts, clean FT8/FT4 decodes.
19
+
20
+ ---
21
+
22
+ ## [3.7.1] - 2026-04-12
23
+
24
+ ### Fixed
25
+
26
+ - **S16BE/S16LE audio parsing in `RadiodStream`**: `_parse_samples()` always decoded audio-mode RTP payloads as float32, regardless of the channel's actual encoding. When encoding was S16BE (used by FT4/FT8 channels at 12 kHz), a 240-byte payload containing 120 int16 samples was misinterpreted as 60 float32 values. The `PacketResequencer` then reported a 120-sample gap on every packet (~2500 gaps/minute), reducing stream completeness to ~37% and making the stream unusable for downstream consumers like `decode_ft8`. Now dispatches on `channel.encoding`: S16BE (`>i2`), S16LE (`<i2`), F32BE (`>f4`), and default F32LE (`np.float32`). All int16 formats are normalized to float32 (÷32768) in the callback.
27
+ - **`ManagedStream` now accepts `encoding` parameter**: Added `encoding: int = 0` to `__init__()`, passed through to both `ensure_channel()` call sites (initial provisioning and stream restoration). Without this, `ManagedStream` would re-provision a channel without encoding on restore, causing format mismatches. Clients using non-default encoding (S16BE, S16LE, F32BE) should pass `encoding=` to the constructor. This eliminates the need for hf-timestd's `RobustManagedStream` workaround.
28
+
29
+ ### Client migration
30
+
31
+ - **Clients using `ManagedStream` with non-default encoding**: Pass `encoding=<int>` to the constructor. Example: `ManagedStream(control=ctl, frequency_hz=14.074e6, preset="usb", sample_rate=12000, encoding=2)` for S16BE.
32
+ - **Clients using bare `RadiodStream` with S16BE/S16LE**: No code change needed — samples are now correctly decoded to float32 in the callback. Note that if client code was compensating for the old bug (e.g., manually byte-swapping), that workaround should be removed.
33
+ - **hf-timestd**: Can replace `RobustManagedStream` (stream_recorder_v2.py lines 40-195) with `ka9q.ManagedStream(encoding=Encoding.F32)` and delete the wrapper class.
34
+
35
+ ---
36
+
3
37
  ## [3.7.0] - 2026-04-12
4
38
 
5
39
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.7.0
3
+ Version: 3.8.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
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """Live 2-channel smoke test for MultiStream against bee3-status.local.
3
+
4
+ Provisions two USB channels (FT8 + WSPR @ 20m) that share one multicast
5
+ group, runs MultiStream for ~20s, and prints per-SSRC packet/sample counts.
6
+
7
+ Success = both callbacks fire with non-zero samples and reasonable
8
+ completeness; no "unknown SSRC" warnings for our two SSRCs.
9
+ """
10
+
11
+ import argparse
12
+ import logging
13
+ import sys
14
+ import time
15
+ from collections import defaultdict
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ sys.path.insert(0, str(Path(__file__).parent.parent))
21
+
22
+ from ka9q import MultiStream, RadiodControl, StreamQuality
23
+
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
27
+ )
28
+ log = logging.getLogger("multi_smoke")
29
+
30
+
31
+ def main() -> int:
32
+ ap = argparse.ArgumentParser()
33
+ ap.add_argument("--host", default="bee3-status.local")
34
+ ap.add_argument("--duration", type=float, default=20.0)
35
+ ap.add_argument("--preset", default="usb")
36
+ ap.add_argument("--sample-rate", type=int, default=12000)
37
+ ap.add_argument("--encoding", type=int, default=2, help="S16BE=2")
38
+ args = ap.parse_args()
39
+
40
+ freqs = {
41
+ "FT8-20m": 14.074e6,
42
+ "WSPR-20m": 14.0956e6,
43
+ }
44
+
45
+ stats = defaultdict(lambda: {"callbacks": 0, "samples": 0, "gaps": 0, "rms": 0.0})
46
+
47
+ def make_cb(label):
48
+ def cb(samples: np.ndarray, q: StreamQuality):
49
+ s = stats[label]
50
+ s["callbacks"] += 1
51
+ s["samples"] += len(samples)
52
+ s["gaps"] += len(q.batch_gaps)
53
+ s["rms"] = float(np.sqrt(np.mean(samples.astype(np.float64) ** 2)))
54
+ return cb
55
+
56
+ log.info(f"Connecting to {args.host}")
57
+ with RadiodControl(args.host) as control:
58
+ multi = MultiStream(control=control)
59
+
60
+ infos = {}
61
+ for label, fhz in freqs.items():
62
+ info = multi.add_channel(
63
+ frequency_hz=fhz,
64
+ preset=args.preset,
65
+ sample_rate=args.sample_rate,
66
+ encoding=args.encoding,
67
+ on_samples=make_cb(label),
68
+ )
69
+ infos[label] = info
70
+ log.info(
71
+ f" {label}: {fhz/1e6:.4f} MHz SSRC={info.ssrc} "
72
+ f"{info.multicast_address}:{info.port}"
73
+ )
74
+
75
+ addrs = {(i.multicast_address, i.port) for i in infos.values()}
76
+ if len(addrs) != 1:
77
+ log.error(f"Channels did not share a multicast group: {addrs}")
78
+ return 2
79
+
80
+ multi.start()
81
+ log.info(f"Running for {args.duration:.0f}s ...")
82
+ try:
83
+ time.sleep(args.duration)
84
+ except KeyboardInterrupt:
85
+ pass
86
+ multi.stop()
87
+
88
+ print("\n=== Per-channel results ===")
89
+ ok = True
90
+ for label in freqs:
91
+ s = stats[label]
92
+ exp = args.duration * args.sample_rate
93
+ pct = 100.0 * s["samples"] / exp if exp else 0.0
94
+ print(
95
+ f" {label:10s} cbs={s['callbacks']:4d} "
96
+ f"samples={s['samples']:>7d} ({pct:5.1f}% of {int(exp)}) "
97
+ f"gaps={s['gaps']} rms={s['rms']:.4f}"
98
+ )
99
+ if s["callbacks"] == 0 or s["samples"] == 0:
100
+ ok = False
101
+ print("\nRESULT:", "PASS" if ok else "FAIL")
102
+ return 0 if ok else 1
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
@@ -56,7 +56,7 @@ Lower-level usage (explicit control):
56
56
  )
57
57
  print(f"Created channel with SSRC: {ssrc}")
58
58
  """
59
- __version__ = '3.7.0'
59
+ __version__ = '3.8.0'
60
60
  __author__ = 'Michael Hauan AC0G'
61
61
 
62
62
  from .control import RadiodControl, allocate_ssrc
@@ -95,6 +95,7 @@ from .managed_stream import (
95
95
  ManagedStreamStats,
96
96
  StreamState,
97
97
  )
98
+ from .multi_stream import MultiStream
98
99
 
99
100
  __all__ = [
100
101
  # Control
@@ -137,11 +138,14 @@ __all__ = [
137
138
  'RTPPacket',
138
139
  'ResequencerStats',
139
140
 
140
- # Managed Stream (self-healing)
141
+ # Managed Stream (self-healing, single channel)
141
142
  'ManagedStream',
142
143
  'ManagedStreamStats',
143
144
  'StreamState',
144
-
145
+
146
+ # Multi Stream (shared socket, multiple channels)
147
+ 'MultiStream',
148
+
145
149
  # Utilities
146
150
  'generate_multicast_ip',
147
151
  'ChannelMonitor',
@@ -123,6 +123,7 @@ class ManagedStream:
123
123
  agc_enable: int = 0,
124
124
  gain: float = 0.0,
125
125
  destination: Optional[str] = None,
126
+ encoding: int = 0,
126
127
  on_samples: Optional[SampleCallback] = None,
127
128
  on_stream_dropped: Optional[StreamDroppedCallback] = None,
128
129
  on_stream_restored: Optional[StreamRestoredCallback] = None,
@@ -135,7 +136,7 @@ class ManagedStream:
135
136
  ):
136
137
  """
137
138
  Initialize ManagedStream.
138
-
139
+
139
140
  Args:
140
141
  control: RadiodControl instance for channel management
141
142
  frequency_hz: Center frequency in Hz
@@ -144,6 +145,7 @@ class ManagedStream:
144
145
  agc_enable: Enable AGC (0=off, 1=on)
145
146
  gain: Manual gain in dB (when AGC off)
146
147
  destination: RTP destination multicast address (optional)
148
+ encoding: Output encoding (0=none, 1=S16LE, 2=S16BE, 4=F32LE, etc.)
147
149
  on_samples: Callback(samples, quality) for sample delivery
148
150
  on_stream_dropped: Callback(reason) when stream drops
149
151
  on_stream_restored: Callback(channel) when stream is restored
@@ -161,6 +163,7 @@ class ManagedStream:
161
163
  self._agc_enable = agc_enable
162
164
  self._gain = gain
163
165
  self._destination = destination
166
+ self._encoding = encoding
164
167
 
165
168
  # Callbacks
166
169
  self._on_samples = on_samples
@@ -232,6 +235,7 @@ class ManagedStream:
232
235
  agc_enable=self._agc_enable,
233
236
  gain=self._gain,
234
237
  destination=self._destination,
238
+ encoding=self._encoding,
235
239
  )
236
240
 
237
241
  # Start the underlying stream
@@ -414,6 +418,7 @@ class ManagedStream:
414
418
  agc_enable=self._agc_enable,
415
419
  gain=self._gain,
416
420
  destination=self._destination,
421
+ encoding=self._encoding,
417
422
  timeout=self._restore_interval_sec * 2, # Give it some time
418
423
  )
419
424
 
@@ -0,0 +1,452 @@
1
+ """
2
+ MultiStream - Shared-Socket Multi-SSRC Receiver
3
+
4
+ Receives RTP packets for multiple channels on a single UDP socket,
5
+ demultiplexes by SSRC, and delivers per-channel sample callbacks.
6
+
7
+ This solves the scalability problem where N ManagedStreams each open
8
+ their own socket on the same multicast group, causing the kernel to
9
+ copy every packet N times. MultiStream uses ONE socket and ONE
10
+ receive thread regardless of channel count.
11
+
12
+ Each channel gets its own PacketResequencer, StreamQuality, and
13
+ sample callback — the per-channel interface is identical to
14
+ ManagedStream/RadiodStream.
15
+
16
+ Usage:
17
+ from ka9q import MultiStream, RadiodControl
18
+
19
+ control = RadiodControl("radiod.local")
20
+ multi = MultiStream(control=control)
21
+
22
+ multi.add_channel(
23
+ frequency_hz=14.074e6,
24
+ preset="usb",
25
+ sample_rate=12000,
26
+ encoding=2,
27
+ on_samples=my_callback,
28
+ )
29
+ multi.add_channel(
30
+ frequency_hz=7.074e6,
31
+ preset="usb",
32
+ sample_rate=12000,
33
+ encoding=2,
34
+ on_samples=another_callback,
35
+ )
36
+
37
+ multi.start()
38
+ # ... all channels receive via one socket ...
39
+ multi.stop()
40
+ """
41
+
42
+ import logging
43
+ import socket
44
+ import struct
45
+ import threading
46
+ import time
47
+ from dataclasses import dataclass, field
48
+ from datetime import datetime, timezone
49
+ from typing import Callable, Dict, List, Optional, Set
50
+
51
+ import numpy as np
52
+
53
+ from .discovery import ChannelInfo
54
+ from .resequencer import PacketResequencer, RTPPacket
55
+ from .rtp_recorder import RTPHeader, parse_rtp_header, rtp_to_wallclock
56
+ from .stream import SampleCallback, parse_rtp_samples
57
+ from .stream_quality import GapEvent, GapSource, StreamQuality
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ @dataclass
63
+ class _ChannelSlot:
64
+ """Per-SSRC state within a MultiStream."""
65
+
66
+ channel_info: ChannelInfo
67
+ frequency_hz: float
68
+ preset: str
69
+ sample_rate: int
70
+ encoding: int
71
+ is_iq: bool
72
+ resequencer: PacketResequencer
73
+ quality: StreamQuality
74
+ on_samples: Optional[SampleCallback]
75
+ on_stream_dropped: Optional[Callable]
76
+ on_stream_restored: Optional[Callable]
77
+ sample_buffer: List[np.ndarray] = field(default_factory=list)
78
+ gap_buffer: List[GapEvent] = field(default_factory=list)
79
+ packets_since_delivery: int = 0
80
+ deliver_interval: int = 10
81
+ last_packet_time: float = 0.0
82
+ dropped: bool = False
83
+ first_rtp_timestamp: Optional[int] = None
84
+
85
+
86
+ class MultiStream:
87
+ """Shared-socket multi-SSRC receiver with per-channel callbacks.
88
+
89
+ All channels MUST resolve to the same multicast group (enforced
90
+ on add_channel). One receive thread drains the socket and
91
+ dispatches by SSRC; one health-monitor thread detects drops and
92
+ restores channels via ensure_channel().
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ control,
98
+ drop_timeout_sec: float = 15.0,
99
+ restore_interval_sec: float = 5.0,
100
+ deliver_interval_packets: int = 10,
101
+ samples_per_packet: int = 320,
102
+ resequence_buffer_size: int = 64,
103
+ ):
104
+ self._control = control
105
+ self._drop_timeout_sec = drop_timeout_sec
106
+ self._restore_interval_sec = restore_interval_sec
107
+ self._deliver_interval = deliver_interval_packets
108
+ self._samples_per_packet = samples_per_packet
109
+ self._resequence_buffer_size = resequence_buffer_size
110
+
111
+ self._slots: Dict[int, _ChannelSlot] = {}
112
+ self._multicast_address: Optional[str] = None
113
+ self._port: Optional[int] = None
114
+
115
+ self._socket: Optional[socket.socket] = None
116
+ self._running = False
117
+ self._receive_thread: Optional[threading.Thread] = None
118
+ self._monitor_thread: Optional[threading.Thread] = None
119
+ self._unknown_ssrcs: Set[int] = set()
120
+
121
+ def add_channel(
122
+ self,
123
+ frequency_hz: float,
124
+ preset: str = "usb",
125
+ sample_rate: int = 12000,
126
+ encoding: int = 0,
127
+ agc_enable: int = 0,
128
+ gain: float = 0.0,
129
+ on_samples: Optional[SampleCallback] = None,
130
+ on_stream_dropped: Optional[Callable] = None,
131
+ on_stream_restored: Optional[Callable] = None,
132
+ ) -> ChannelInfo:
133
+ """Provision a channel and register it for reception.
134
+
135
+ Must be called before start(). All channels must resolve to the
136
+ same multicast address (enforced).
137
+
138
+ Returns the ChannelInfo from ensure_channel().
139
+ """
140
+ channel_info = self._control.ensure_channel(
141
+ frequency_hz=frequency_hz,
142
+ preset=preset,
143
+ sample_rate=sample_rate,
144
+ agc_enable=agc_enable,
145
+ gain=gain,
146
+ encoding=encoding,
147
+ )
148
+
149
+ addr = channel_info.multicast_address
150
+ port = channel_info.port
151
+ if self._multicast_address is None:
152
+ self._multicast_address = addr
153
+ self._port = port
154
+ elif addr != self._multicast_address or port != self._port:
155
+ raise ValueError(
156
+ f"Channel {frequency_hz/1e6:.3f} MHz resolved to "
157
+ f"{addr}:{port}, but MultiStream is bound to "
158
+ f"{self._multicast_address}:{self._port}. "
159
+ f"All channels must share one multicast group."
160
+ )
161
+
162
+ ssrc = channel_info.ssrc
163
+ is_iq = preset.lower() in ("iq", "spectrum")
164
+
165
+ slot = _ChannelSlot(
166
+ channel_info=channel_info,
167
+ frequency_hz=frequency_hz,
168
+ preset=preset,
169
+ sample_rate=sample_rate,
170
+ encoding=encoding,
171
+ is_iq=is_iq,
172
+ resequencer=PacketResequencer(
173
+ buffer_size=self._resequence_buffer_size,
174
+ samples_per_packet=self._samples_per_packet,
175
+ sample_rate=sample_rate,
176
+ ),
177
+ quality=StreamQuality(),
178
+ on_samples=on_samples,
179
+ on_stream_dropped=on_stream_dropped,
180
+ on_stream_restored=on_stream_restored,
181
+ deliver_interval=self._deliver_interval,
182
+ )
183
+ self._slots[ssrc] = slot
184
+
185
+ logger.info(
186
+ f"MultiStream: added {frequency_hz/1e6:.3f} MHz "
187
+ f"SSRC={ssrc} on {addr}:{port}"
188
+ )
189
+ return channel_info
190
+
191
+ def start(self) -> None:
192
+ """Open the shared socket and start receive + health threads."""
193
+ if self._running:
194
+ return
195
+ if not self._slots:
196
+ raise RuntimeError("No channels added — call add_channel() first")
197
+
198
+ self._running = True
199
+ self._socket = self._create_socket()
200
+
201
+ self._receive_thread = threading.Thread(
202
+ target=self._receive_loop, daemon=True, name="MultiStream-Recv",
203
+ )
204
+ self._receive_thread.start()
205
+
206
+ self._monitor_thread = threading.Thread(
207
+ target=self._health_monitor_loop, daemon=True,
208
+ name="MultiStream-Health",
209
+ )
210
+ self._monitor_thread.start()
211
+
212
+ logger.info(
213
+ f"MultiStream started: {len(self._slots)} channels on "
214
+ f"{self._multicast_address}:{self._port}"
215
+ )
216
+
217
+ def stop(self) -> None:
218
+ """Stop threads and close socket."""
219
+ if not self._running:
220
+ return
221
+ self._running = False
222
+
223
+ if self._receive_thread:
224
+ self._receive_thread.join(timeout=5.0)
225
+ self._receive_thread = None
226
+
227
+ if self._monitor_thread:
228
+ self._monitor_thread.join(timeout=5.0)
229
+ self._monitor_thread = None
230
+
231
+ # Flush all resequencers
232
+ for ssrc, slot in self._slots.items():
233
+ try:
234
+ final_samples, final_gaps = slot.resequencer.flush()
235
+ if final_samples is not None and len(final_samples) > 0:
236
+ slot.sample_buffer.append(final_samples)
237
+ slot.gap_buffer.extend(final_gaps)
238
+ self._deliver(slot)
239
+ except Exception:
240
+ pass
241
+
242
+ if self._socket:
243
+ try:
244
+ self._socket.close()
245
+ except Exception:
246
+ pass
247
+ self._socket = None
248
+
249
+ logger.info("MultiStream stopped")
250
+
251
+ # ── socket ──
252
+
253
+ def _create_socket(self) -> socket.socket:
254
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
255
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
256
+ if hasattr(socket, "SO_REUSEPORT"):
257
+ try:
258
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
259
+ except OSError:
260
+ pass
261
+
262
+ # Large receive buffer for multi-channel throughput
263
+ try:
264
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024)
265
+ except OSError:
266
+ pass
267
+
268
+ sock.bind(("0.0.0.0", self._port))
269
+
270
+ mreq = struct.pack(
271
+ "=4s4s",
272
+ socket.inet_aton(self._multicast_address),
273
+ socket.inet_aton("0.0.0.0"),
274
+ )
275
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
276
+ sock.settimeout(1.0)
277
+ return sock
278
+
279
+ # ── receive loop (hot path) ──
280
+
281
+ def _receive_loop(self) -> None:
282
+ while self._running:
283
+ try:
284
+ data, addr = self._socket.recvfrom(8192)
285
+ except socket.timeout:
286
+ continue
287
+ except OSError:
288
+ if self._running:
289
+ logger.error("MultiStream socket error", exc_info=True)
290
+ break
291
+
292
+ if len(data) < 12:
293
+ continue
294
+
295
+ # Fast SSRC peek before full header parse
296
+ ssrc = struct.unpack_from("!I", data, 8)[0]
297
+ slot = self._slots.get(ssrc)
298
+ if slot is None:
299
+ if ssrc not in self._unknown_ssrcs:
300
+ self._unknown_ssrcs.add(ssrc)
301
+ logger.debug(f"MultiStream: unknown SSRC {ssrc}")
302
+ continue
303
+
304
+ # Full header parse
305
+ header = parse_rtp_header(data)
306
+ if header is None:
307
+ continue
308
+
309
+ slot.last_packet_time = time.time()
310
+ slot.quality.rtp_packets_received += 1
311
+
312
+ if slot.first_rtp_timestamp is None:
313
+ slot.first_rtp_timestamp = header.timestamp
314
+ slot.quality.first_rtp_timestamp = header.timestamp
315
+ slot.quality.last_rtp_timestamp = header.timestamp
316
+
317
+ # Extract payload
318
+ header_len = 12 + (4 * header.csrc_count)
319
+ payload = data[header_len:]
320
+ if not payload:
321
+ continue
322
+
323
+ # Parse samples
324
+ samples = parse_rtp_samples(payload, slot.encoding, slot.is_iq)
325
+ if samples is None:
326
+ continue
327
+
328
+ # Wallclock
329
+ wallclock = rtp_to_wallclock(header.timestamp, slot.channel_info)
330
+
331
+ # Resequencer
332
+ packet = RTPPacket(
333
+ sequence=header.sequence,
334
+ timestamp=header.timestamp,
335
+ ssrc=header.ssrc,
336
+ samples=samples,
337
+ wallclock=wallclock,
338
+ )
339
+ output, gaps = slot.resequencer.process_packet(packet)
340
+
341
+ if output is not None and len(output) > 0:
342
+ slot.sample_buffer.append(output)
343
+ slot.gap_buffer.extend(gaps)
344
+ slot.packets_since_delivery += 1
345
+
346
+ if slot.packets_since_delivery >= slot.deliver_interval:
347
+ self._deliver(slot)
348
+
349
+ # ── delivery ──
350
+
351
+ def _deliver(self, slot: _ChannelSlot) -> None:
352
+ if not slot.sample_buffer or slot.on_samples is None:
353
+ slot.sample_buffer.clear()
354
+ slot.gap_buffer.clear()
355
+ slot.packets_since_delivery = 0
356
+ return
357
+
358
+ combined = np.concatenate(slot.sample_buffer)
359
+ n = len(combined)
360
+
361
+ slot.quality.total_samples_delivered += n
362
+ slot.quality.batch_samples_delivered = n
363
+ slot.quality.batch_gaps = list(slot.gap_buffer)
364
+ slot.quality.sample_rate = slot.sample_rate
365
+
366
+ try:
367
+ slot.on_samples(combined, slot.quality)
368
+ except Exception:
369
+ logger.exception(
370
+ f"MultiStream: callback error for SSRC {slot.channel_info.ssrc}"
371
+ )
372
+
373
+ slot.sample_buffer.clear()
374
+ slot.gap_buffer.clear()
375
+ slot.packets_since_delivery = 0
376
+
377
+ # ── health monitor ──
378
+
379
+ def _health_monitor_loop(self) -> None:
380
+ time.sleep(10.0)
381
+ check_interval = min(1.0, self._drop_timeout_sec / 4)
382
+
383
+ while self._running:
384
+ time.sleep(check_interval)
385
+ if not self._running:
386
+ break
387
+
388
+ now = time.time()
389
+ for ssrc, slot in list(self._slots.items()):
390
+ if slot.dropped:
391
+ self._attempt_restore(ssrc, slot)
392
+ elif slot.last_packet_time > 0:
393
+ silence = now - slot.last_packet_time
394
+ if silence > self._drop_timeout_sec:
395
+ self._handle_drop(
396
+ ssrc, slot,
397
+ f"No packets for {silence:.1f}s "
398
+ f"(timeout: {self._drop_timeout_sec}s)",
399
+ )
400
+
401
+ def _handle_drop(self, ssrc: int, slot: _ChannelSlot, reason: str) -> None:
402
+ logger.warning(
403
+ f"MultiStream: {slot.frequency_hz/1e6:.3f} MHz dropped — {reason}"
404
+ )
405
+ slot.dropped = True
406
+ if slot.on_stream_dropped:
407
+ try:
408
+ slot.on_stream_dropped(reason)
409
+ except Exception:
410
+ logger.exception("Error in on_stream_dropped callback")
411
+
412
+ def _attempt_restore(self, ssrc: int, slot: _ChannelSlot) -> None:
413
+ try:
414
+ channel_info = self._control.ensure_channel(
415
+ frequency_hz=slot.frequency_hz,
416
+ preset=slot.preset,
417
+ sample_rate=slot.sample_rate,
418
+ encoding=slot.encoding,
419
+ )
420
+ new_ssrc = channel_info.ssrc
421
+ if new_ssrc != ssrc:
422
+ del self._slots[ssrc]
423
+ self._slots[new_ssrc] = slot
424
+
425
+ slot.channel_info = channel_info
426
+ slot.dropped = False
427
+ slot.first_rtp_timestamp = None
428
+ slot.resequencer = PacketResequencer(
429
+ buffer_size=self._resequence_buffer_size,
430
+ samples_per_packet=self._samples_per_packet,
431
+ sample_rate=slot.sample_rate,
432
+ )
433
+ slot.quality = StreamQuality()
434
+ slot.sample_buffer.clear()
435
+ slot.gap_buffer.clear()
436
+ slot.packets_since_delivery = 0
437
+
438
+ logger.info(
439
+ f"MultiStream: {slot.frequency_hz/1e6:.3f} MHz restored "
440
+ f"(SSRC={new_ssrc})"
441
+ )
442
+ if slot.on_stream_restored:
443
+ try:
444
+ slot.on_stream_restored(channel_info)
445
+ except Exception:
446
+ logger.exception("Error in on_stream_restored callback")
447
+
448
+ except Exception as e:
449
+ logger.warning(
450
+ f"MultiStream: restore failed for "
451
+ f"{slot.frequency_hz/1e6:.3f} MHz: {e}"
452
+ )