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.
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/CHANGELOG.md +34 -0
- {ka9q_python-3.7.0/ka9q_python.egg-info → ka9q_python-3.8.0}/PKG-INFO +1 -1
- ka9q_python-3.8.0/examples/multi_stream_smoke.py +106 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/__init__.py +7 -3
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/managed_stream.py +6 -1
- ka9q_python-3.8.0/ka9q/multi_stream.py +452 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/stream.py +43 -18
- {ka9q_python-3.7.0 → ka9q_python-3.8.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/SOURCES.txt +2 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/pyproject.toml +1 -1
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/LICENSE +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/MANIFEST.in +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/README.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/RELEASE_NOTES_v3.5.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/CHANGELOG.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/CROSS_PLATFORM_SUPPORT.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/DISTRIBUTION_RECOMMENDATION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/MULTI_HOMED_QUICK_REF.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/NATIVE_DISCOVERY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/PYPI_PUBLICATION_GUIDE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/QUICK_REFERENCE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/RTP_RECORDER_PASS_ALL_PACKETS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/RTP_TIMING_IMPLEMENTATION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/SSRC_COLLISION_PREVENTION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/TESTING_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/TEST_RESULTS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_ENHANCEMENT_GUIDE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_ENHANCEMENT_IMPLEMENTED.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_ESCAPE_SEQUENCE_FIX.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_FUNCTIONALITY_REVIEW.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_IMPLEMENTATION_STATUS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/WEB_UI_INTERACTIVE_COMPLETE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANGES_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANNEL_CLEANUP_ADDITION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANNEL_CLEANUP_COMPLETE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CHANNEL_TUNING_DIAGNOSTICS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CODE_REVIEW_RECOMMENDATIONS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CODE_REVIEW_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/COMMIT_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/CRITICAL_FIXES_CHECKLIST.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/FINAL_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/FIXES_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/GIT_STATUS_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_COMPLETE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_STATUS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPLEMENTATION_SUMMARY_v2.2.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/IMPROVEMENTS_IMPLEMENTED.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_ACTION_PLAN.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_IMPLEMENTATION_COMPLETE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/MULTI_HOMED_SUPPORT_REVIEW.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PACKAGE_STATUS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PACKAGE_VERIFICATION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_FIXES_APPLIED.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_REVIEW.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_REVIEW_V2.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/PERFORMANCE_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/QUICK_ACTION_ITEMS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/QUICK_START_DIAGNOSIS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/RELEASE_CHECKLIST_v3.0.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/SUMMARY_WEB_UI_FIXES.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/TUNE_IMPLEMENTATION.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/WEBUI_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/development/WEB_UI_ENHANCEMENTS_COMPLETE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/CONTROL_COMPARISON.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/DESTINATION_AWARE_CHANNELS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/NEW_FEATURES.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/RADIOD_FEATURES_SUMMARY.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/features/RTP_DESTINATION_FEATURE.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_INSTRUCTIONS.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.2.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.4.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v2.5.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.0.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.1.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/GITHUB_RELEASE_v3.2.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.1.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.2.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.3.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RELEASE_NOTES_v2.4.0.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/docs/releases/RTP_TIMING_RELEASE_NOTES.md +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/tune.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/control.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/types.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/setup.cfg +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/setup.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/__init__.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/conftest.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.7.0 → ka9q_python-3.8.0}/tests/test_tune_live.py +0 -0
- {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
|
|
@@ -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.
|
|
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
|
+
)
|