ka9q-python 3.7.1__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.1 → ka9q_python-3.8.0}/CHANGELOG.md +19 -0
  2. {ka9q_python-3.7.1/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.1 → ka9q_python-3.8.0}/ka9q/__init__.py +7 -3
  5. ka9q_python-3.8.0/ka9q/multi_stream.py +452 -0
  6. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/stream.py +42 -31
  7. {ka9q_python-3.7.1 → ka9q_python-3.8.0/ka9q_python.egg-info}/PKG-INFO +1 -1
  8. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q_python.egg-info/SOURCES.txt +2 -0
  9. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/pyproject.toml +1 -1
  10. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/LICENSE +0 -0
  11. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/MANIFEST.in +0 -0
  12. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/README.md +0 -0
  13. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/RELEASE_NOTES_v3.5.0.md +0 -0
  14. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/API_REFERENCE.md +0 -0
  15. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/ARCHITECTURE.md +0 -0
  16. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/CHANGELOG.md +0 -0
  17. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/CROSS_PLATFORM_SUPPORT.md +0 -0
  18. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/DISTRIBUTION_RECOMMENDATION.md +0 -0
  19. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/GETTING_STARTED.md +0 -0
  20. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/INSTALLATION.md +0 -0
  21. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/MULTI_HOMED_QUICK_REF.md +0 -0
  22. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/NATIVE_DISCOVERY.md +0 -0
  23. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/PYPI_PUBLICATION_GUIDE.md +0 -0
  24. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/QUICK_REFERENCE.md +0 -0
  25. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/RTP_RECORDER_PASS_ALL_PACKETS.md +0 -0
  26. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/RTP_TIMING_IMPLEMENTATION.md +0 -0
  27. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  28. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/SECURITY.md +0 -0
  29. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/SSRC_COLLISION_PREVENTION.md +0 -0
  30. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/TESTING_GUIDE.md +0 -0
  31. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/TESTING_SUMMARY.md +0 -0
  32. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/TEST_RESULTS.md +0 -0
  33. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/WEB_UI_ENHANCEMENT_GUIDE.md +0 -0
  34. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/WEB_UI_ENHANCEMENT_IMPLEMENTED.md +0 -0
  35. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/WEB_UI_ESCAPE_SEQUENCE_FIX.md +0 -0
  36. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/WEB_UI_FUNCTIONALITY_REVIEW.md +0 -0
  37. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/WEB_UI_IMPLEMENTATION_STATUS.md +0 -0
  38. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/WEB_UI_INTERACTIVE_COMPLETE.md +0 -0
  39. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CHANGES_SUMMARY.md +0 -0
  40. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CHANNEL_CLEANUP_ADDITION.md +0 -0
  41. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CHANNEL_CLEANUP_COMPLETE.md +0 -0
  42. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CHANNEL_TUNING_DIAGNOSTICS.md +0 -0
  43. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CODE_REVIEW_RECOMMENDATIONS.md +0 -0
  44. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CODE_REVIEW_SUMMARY.md +0 -0
  45. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/COMMIT_SUMMARY.md +0 -0
  46. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/CRITICAL_FIXES_CHECKLIST.md +0 -0
  47. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/FINAL_SUMMARY.md +0 -0
  48. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/FIXES_SUMMARY.md +0 -0
  49. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/GIT_STATUS_SUMMARY.md +0 -0
  50. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_COMPLETE.md +0 -0
  51. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_STATUS.md +0 -0
  52. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_SUMMARY.md +0 -0
  53. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_SUMMARY_v2.2.0.md +0 -0
  54. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/IMPROVEMENTS_IMPLEMENTED.md +0 -0
  55. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_ACTION_PLAN.md +0 -0
  56. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_IMPLEMENTATION_COMPLETE.md +0 -0
  57. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_SUPPORT_REVIEW.md +0 -0
  58. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/PACKAGE_STATUS.md +0 -0
  59. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/PACKAGE_VERIFICATION.md +0 -0
  60. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_FIXES_APPLIED.md +0 -0
  61. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_REVIEW.md +0 -0
  62. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_REVIEW_V2.md +0 -0
  63. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_SUMMARY.md +0 -0
  64. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/QUICK_ACTION_ITEMS.md +0 -0
  65. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/QUICK_START_DIAGNOSIS.md +0 -0
  66. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/RELEASE_CHECKLIST_v3.0.0.md +0 -0
  67. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/SUMMARY.md +0 -0
  68. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/SUMMARY_WEB_UI_FIXES.md +0 -0
  69. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/TUNE_IMPLEMENTATION.md +0 -0
  70. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/WEBUI_SUMMARY.md +0 -0
  71. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/development/WEB_UI_ENHANCEMENTS_COMPLETE.md +0 -0
  72. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/features/CONTROL_COMPARISON.md +0 -0
  73. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/features/DESTINATION_AWARE_CHANNELS.md +0 -0
  74. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/features/NEW_FEATURES.md +0 -0
  75. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/features/RADIOD_FEATURES_SUMMARY.md +0 -0
  76. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/features/RTP_DESTINATION_FEATURE.md +0 -0
  77. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_INSTRUCTIONS.md +0 -0
  78. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.2.0.md +0 -0
  79. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.4.0.md +0 -0
  80. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.5.0.md +0 -0
  81. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.0.0.md +0 -0
  82. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.1.0.md +0 -0
  83. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.2.0.md +0 -0
  84. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES.md +0 -0
  85. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.1.0.md +0 -0
  86. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.2.0.md +0 -0
  87. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.3.0.md +0 -0
  88. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.4.0.md +0 -0
  89. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/docs/releases/RTP_TIMING_RELEASE_NOTES.md +0 -0
  90. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/advanced_features_demo.py +0 -0
  91. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/channel_cleanup_example.py +0 -0
  92. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/codar_oceanography.py +0 -0
  93. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/diagnostics/diagnose_packets.py +0 -0
  94. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  95. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/discover_example.py +0 -0
  96. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/grape_integration_example.py +0 -0
  97. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/hf_band_scanner.py +0 -0
  98. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/rtp_recorder_example.py +0 -0
  99. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/simple_am_radio.py +0 -0
  100. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/stream_example.py +0 -0
  101. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/superdarn_recorder.py +0 -0
  102. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/test_channel_operations.py +0 -0
  103. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/test_improvements.py +0 -0
  104. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/test_timing_fields.py +0 -0
  105. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/tune.py +0 -0
  106. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/examples/tune_example.py +0 -0
  107. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/addressing.py +0 -0
  108. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/compat.py +0 -0
  109. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/control.py +0 -0
  110. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/discovery.py +0 -0
  111. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/exceptions.py +0 -0
  112. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/managed_stream.py +0 -0
  113. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/monitor.py +0 -0
  114. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/pps_calibrator.py +0 -0
  115. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/resequencer.py +0 -0
  116. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/rtp_recorder.py +0 -0
  117. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/stream_quality.py +0 -0
  118. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/types.py +0 -0
  119. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q/utils.py +0 -0
  120. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  121. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q_python.egg-info/requires.txt +0 -0
  122. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q_python.egg-info/top_level.txt +0 -0
  123. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/ka9q_radio_compat +0 -0
  124. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/scripts/sync_types.py +0 -0
  125. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/setup.cfg +0 -0
  126. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/setup.py +0 -0
  127. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/__init__.py +0 -0
  128. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/conftest.py +0 -0
  129. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_addressing.py +0 -0
  130. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_channel_verification.py +0 -0
  131. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_create_split_encoding.py +0 -0
  132. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_decode_functions.py +0 -0
  133. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_encode_functions.py +0 -0
  134. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_encode_socket.py +0 -0
  135. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_ensure_channel_encoding.py +0 -0
  136. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_integration.py +0 -0
  137. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_iq_20khz_f32.py +0 -0
  138. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_listen_multicast.py +0 -0
  139. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_managed_stream_recovery.py +0 -0
  140. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_monitor.py +0 -0
  141. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_multihomed.py +0 -0
  142. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_native_discovery.py +0 -0
  143. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_performance_fixes.py +0 -0
  144. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_protocol_compat.py +0 -0
  145. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_remove_channel.py +0 -0
  146. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_rtp_recorder.py +0 -0
  147. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_security_features.py +0 -0
  148. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_ssrc_dest_unit.py +0 -0
  149. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_ssrc_encoding_unit.py +0 -0
  150. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  151. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_ttl_warning.py +0 -0
  152. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_tune.py +0 -0
  153. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_tune_cli.py +0 -0
  154. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_tune_debug.py +0 -0
  155. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_tune_live.py +0 -0
  156. {ka9q_python-3.7.1 → ka9q_python-3.8.0}/tests/test_tune_method.py +0 -0
@@ -1,5 +1,24 @@
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
+
3
22
  ## [3.7.1] - 2026-04-12
4
23
 
5
24
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.7.1
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',
@@ -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
+ )