ka9q-python 3.10.0__tar.gz → 3.13.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.10.0 → ka9q_python-3.13.0}/CHANGELOG.md +79 -0
- {ka9q_python-3.10.0/ka9q_python.egg-info → ka9q_python-3.13.0}/PKG-INFO +1 -1
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/API_REFERENCE.md +89 -1
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/ARCHITECTURE.md +14 -1
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/RECIPES.md +184 -0
- ka9q_python-3.13.0/examples/spectrum_example.py +109 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/__init__.py +5 -1
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/control.py +71 -4
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/multi_stream.py +42 -0
- ka9q_python-3.13.0/ka9q/spectrum_stream.py +317 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/status.py +45 -2
- {ka9q_python-3.10.0 → ka9q_python-3.13.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/SOURCES.txt +7 -1
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/pyproject.toml +1 -1
- ka9q_python-3.13.0/scripts/check_upstream_drift.py +490 -0
- ka9q_python-3.13.0/tests/conftest.py +29 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_channel_verification.py +1 -5
- ka9q_python-3.13.0/tests/test_filter_edges.py +256 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_lifetime.py +88 -0
- ka9q_python-3.13.0/tests/test_spectrum.py +271 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_debug.py +3 -2
- ka9q_python-3.13.0/tests/test_upstream_drift.py +176 -0
- ka9q_python-3.10.0/tests/conftest.py +0 -14
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/LICENSE +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/MANIFEST.in +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/README.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/tune.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/types.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/setup.cfg +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/setup.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/__init__.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_method.py +0 -0
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.13.0] - 2026-05-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`MultiStream` channel-lifetime support**: closes the gap from 3.10.0 where
|
|
8
|
+
`RadiodControl.{create_channel,ensure_channel,tune}` accepted a `lifetime=`
|
|
9
|
+
kwarg but `MultiStream.add_channel()` did not, leaving MultiStream-based
|
|
10
|
+
clients (psk-recorder, hfdl-recorder, hf-timestd) unable to opt into
|
|
11
|
+
radiod's channel self-destruct timer for crash-resilient cleanup.
|
|
12
|
+
- `MultiStream.add_channel(..., lifetime=None)` — optional kwarg, forwarded
|
|
13
|
+
to the internal `ensure_channel` call. Stored per-slot.
|
|
14
|
+
- **Drop/restore path now re-applies lifetime**: `_attempt_restore` reads
|
|
15
|
+
the stored slot lifetime and passes it to `ensure_channel`. Previously,
|
|
16
|
+
a channel that radiod self-destructed and MultiStream restored would
|
|
17
|
+
silently lose its LIFETIME until the next external keep-alive — the
|
|
18
|
+
most dangerous failure mode now closed.
|
|
19
|
+
- `MultiStream.set_channel_lifetime(ssrc, lifetime)` — keep-alive method
|
|
20
|
+
that updates both the wire (via `RadiodControl.set_channel_lifetime`)
|
|
21
|
+
and the slot's stored lifetime, so the value survives subsequent
|
|
22
|
+
drop/restore cycles.
|
|
23
|
+
|
|
24
|
+
### Backward Compatibility
|
|
25
|
+
|
|
26
|
+
- Default behavior unchanged: omitting `lifetime` produces a packet with no
|
|
27
|
+
LIFETIME tag (ChannelSlot.lifetime defaults to None). Existing MultiStream
|
|
28
|
+
callers see no change in wire behavior.
|
|
29
|
+
|
|
30
|
+
### Tests
|
|
31
|
+
|
|
32
|
+
- 5 new unit tests in `tests/test_lifetime.py::TestMultiStreamLifetime` cover:
|
|
33
|
+
forward-on-add, lifetime=None default, restore-reapplies-lifetime,
|
|
34
|
+
set_channel_lifetime updates slot+wire, set_channel_lifetime is a no-op for
|
|
35
|
+
unknown SSRC. 258 unit tests still green.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## [3.12.0] - 2026-05-07
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **Spectrum bin vector decoding**: `decode_status_packet()` now decodes `BIN_DATA` (float32, `SPECT_DEMOD`) and `BIN_BYTE_DATA` (uint8, `SPECT2_DEMOD`) TLV vectors from radiod status packets. New fields on `SpectrumStatus`:
|
|
45
|
+
- `bin_data: Optional[np.ndarray]` — float32 power values per FFT bin.
|
|
46
|
+
- `bin_byte_data: Optional[np.ndarray]` — uint8 quantised log-power values.
|
|
47
|
+
- `bin_power_db` property — returns dB values regardless of source format (10*log10 for float data, `base + byte * step` for byte data).
|
|
48
|
+
|
|
49
|
+
- **`SpectrumStream`**: new class for receiving real-time FFT spectrum data from radiod. Spectrum data flows over the status multicast channel (port 5006) as TLV vectors, not over RTP. `SpectrumStream` handles channel creation, periodic polling, SSRC filtering, and delivers decoded `ChannelStatus` objects (with populated `spectrum.bin_power_db`) to an `on_spectrum` callback. Supports retuning via `set_frequency()` and context manager usage.
|
|
50
|
+
|
|
51
|
+
### Documentation
|
|
52
|
+
|
|
53
|
+
- API_REFERENCE.md: SpectrumStream section, SpectrumStatus bin vector fields, quickstart table updated.
|
|
54
|
+
- ARCHITECTURE.md: module listing, abstraction layer description, threading model updated.
|
|
55
|
+
- RECIPES.md: Recipe 5 covering spectrum display, spectrogram accumulation, frequency axis reconstruction, FFT parameter tuning, and combined audio+spectrum patterns.
|
|
56
|
+
- New `examples/spectrum_example.py`: runnable CLI example for real-time spectrum reception.
|
|
57
|
+
|
|
58
|
+
### Tests
|
|
59
|
+
|
|
60
|
+
- 13 new unit tests in `tests/test_spectrum.py` covering float32 and uint8 bin decoding, multi-byte TLV lengths, `bin_power_db` property, combined metadata+bins, edge cases, and import verification.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## [3.11.0] - 2026-05-06
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **Channel filter overrides on create / ensure / add_channel**: optional `low_edge`, `high_edge`, and `kaiser_beta` kwargs on `RadiodControl.create_channel()`, `RadiodControl.ensure_channel()`, and `MultiStream.add_channel()` — when specified, the requested filter passband is applied inline with the channel-create command (no transient at preset BW), and on the reuse path of `ensure_channel`, `set_filter` is called so the requested edges are authoritative regardless of the channel's prior state. Previously, callers were stuck with the preset's `low`/`high` values and had to either author a custom radiod preset or call `set_filter` manually after channel creation. Filter edges are not part of the SSRC hash — multiple callers requesting the same channel with different filters reconfigure last-writer-wins, matching the existing model for `gain` / `agc_enable`.
|
|
70
|
+
- Motivating clients: hf-timestd's BPSK PPS calibrator needs ~±500 Hz to match the TS1 injector's effectively-CW spectrum (the iq preset's ±5 kHz is wrong by an order of magnitude); planned SuperDARN and CODAR-sounder receivers need wider passbands than any single preset provides.
|
|
71
|
+
|
|
72
|
+
### Backward Compatibility
|
|
73
|
+
|
|
74
|
+
- Default behavior unchanged: omitting all three kwargs produces a wire packet with no LOW_EDGE / HIGH_EDGE / KAISER_BETA tags, so radiod uses the preset's defaults exactly as before. Reuse path also skips `set_filter` when no filter args are supplied.
|
|
75
|
+
|
|
76
|
+
### Tests
|
|
77
|
+
|
|
78
|
+
- 8 new unit tests in `tests/test_filter_edges.py` cover encode-presence on each entry point, encode-absence when omitted, ensure_channel forwarding on the create path, and ensure_channel reuse-path calling set_filter (only when filter args are supplied).
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
3
82
|
## [3.10.0] - 2026-04-30
|
|
4
83
|
|
|
5
84
|
### Added
|
|
@@ -42,6 +42,7 @@ Pick the highest layer that fits:
|
|
|
42
42
|
| Continuous samples | [`RadiodStream`](../ka9q/stream.py) | You want numpy arrays with gap-filling |
|
|
43
43
|
| Self-healing samples | [`ManagedStream`](../ka9q/managed_stream.py) | Long-running client that must survive radiod restarts |
|
|
44
44
|
| Many channels, one socket | [`MultiStream`](../ka9q/multi_stream.py) | 10+ channels on the same multicast group |
|
|
45
|
+
| Spectrum / FFT data | [`SpectrumStream`](../ka9q/spectrum_stream.py) | Spectrogram display, band monitoring, signal search |
|
|
45
46
|
|
|
46
47
|
All layers sit on top of [`RadiodControl`](../ka9q/control.py), which
|
|
47
48
|
speaks the TLV protocol to radiod over multicast UDP.
|
|
@@ -396,7 +397,18 @@ Derived properties:
|
|
|
396
397
|
`deemph_tc`, `deemph_gain`, `threshold_extend`.
|
|
397
398
|
- `SpectrumStatus` — `avg`, `base`, `step`, `shape`, `fft_n`,
|
|
398
399
|
`overlap`, `resolution_bw`, `noise_bw`, `bin_count`, `crossover`,
|
|
399
|
-
`window_type`.
|
|
400
|
+
`window_type`, `bin_data`, `bin_byte_data`.
|
|
401
|
+
|
|
402
|
+
**Bin vectors** (populated when the status packet contains spectrum
|
|
403
|
+
data from a `SPECT_DEMOD` or `SPECT2_DEMOD` channel):
|
|
404
|
+
|
|
405
|
+
- `bin_data: Optional[np.ndarray]` — float32 I²+Q² power per bin
|
|
406
|
+
(SPECT_DEMOD). Bin 0 = DC, 1..N/2 = positive, N/2+1..N-1 = negative.
|
|
407
|
+
- `bin_byte_data: Optional[np.ndarray]` — uint8 quantised log-power
|
|
408
|
+
(SPECT2_DEMOD). Reconstruct dB with `base + byte * step`.
|
|
409
|
+
- `bin_power_db -> Optional[np.ndarray]` — **property** that returns
|
|
410
|
+
dB values regardless of source format (10*log10 for float,
|
|
411
|
+
base + byte*step for byte). Returns `None` if no bin data is present.
|
|
400
412
|
- `Filter2Status` — `blocking`, `blocksize`, `fir_length`,
|
|
401
413
|
`kaiser_beta`.
|
|
402
414
|
- `OpusStatus` — `bit_rate`, `dtx`, `application`, `bandwidth`, `fec`.
|
|
@@ -518,6 +530,82 @@ for freq in (14.074e6, 7.074e6, 3.573e6):
|
|
|
518
530
|
multi.start()
|
|
519
531
|
```
|
|
520
532
|
|
|
533
|
+
### `SpectrumStream`
|
|
534
|
+
|
|
535
|
+
Source: [spectrum_stream.py](../ka9q/spectrum_stream.py). Receives
|
|
536
|
+
real-time FFT spectrum data from radiod via the status multicast
|
|
537
|
+
channel (port 5006). Unlike audio streams which use RTP, spectrum
|
|
538
|
+
data arrives as `BIN_DATA` or `BIN_BYTE_DATA` TLV vectors inside
|
|
539
|
+
status packets.
|
|
540
|
+
|
|
541
|
+
```python
|
|
542
|
+
SpectrumStream(
|
|
543
|
+
control, # RadiodControl
|
|
544
|
+
frequency_hz, # center frequency (Hz)
|
|
545
|
+
bin_count=1024, # number of FFT bins
|
|
546
|
+
resolution_bw=100.0, # bin bandwidth (Hz)
|
|
547
|
+
*,
|
|
548
|
+
demod_type=DemodType.SPECT2_DEMOD, # SPECT_DEMOD or SPECT2_DEMOD
|
|
549
|
+
window_type=None, # see WindowType (default: Kaiser)
|
|
550
|
+
kaiser_beta=None, # Kaiser window shape parameter
|
|
551
|
+
averaging=None, # FFTs averaged per response
|
|
552
|
+
overlap=None, # window overlap ratio (0.0–1.0)
|
|
553
|
+
poll_interval_sec=0.1, # seconds between poll commands
|
|
554
|
+
on_spectrum=None, # (ChannelStatus) -> None
|
|
555
|
+
)
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
- `start() -> int` — create the spectrum channel and begin receiving.
|
|
559
|
+
Returns the SSRC.
|
|
560
|
+
- `stop()` — stop threads, close socket, remove the channel from radiod.
|
|
561
|
+
- `set_frequency(frequency_hz)` — retune the spectrum center frequency.
|
|
562
|
+
- `ssrc -> Optional[int]` — the allocated SSRC.
|
|
563
|
+
- `frames_received -> int` — count of spectrum frames delivered.
|
|
564
|
+
- Supports `with` (context manager).
|
|
565
|
+
|
|
566
|
+
The `on_spectrum` callback receives a fully-decoded `ChannelStatus`
|
|
567
|
+
whose `.spectrum.bin_data` or `.spectrum.bin_byte_data` is populated.
|
|
568
|
+
Use `.spectrum.bin_power_db` for a format-independent numpy array of
|
|
569
|
+
dB values.
|
|
570
|
+
|
|
571
|
+
```python
|
|
572
|
+
from ka9q import RadiodControl, SpectrumStream
|
|
573
|
+
|
|
574
|
+
def on_spectrum(status):
|
|
575
|
+
db = status.spectrum.bin_power_db
|
|
576
|
+
freq = status.frequency
|
|
577
|
+
rbw = status.spectrum.resolution_bw
|
|
578
|
+
print(f"{len(db)} bins at {freq/1e6:.3f} MHz, "
|
|
579
|
+
f"peak {db.max():.1f} dB, floor {db.min():.1f} dB")
|
|
580
|
+
|
|
581
|
+
with RadiodControl("radiod.local") as ctl:
|
|
582
|
+
with SpectrumStream(
|
|
583
|
+
control=ctl,
|
|
584
|
+
frequency_hz=14.1e6,
|
|
585
|
+
bin_count=2048,
|
|
586
|
+
resolution_bw=50.0,
|
|
587
|
+
on_spectrum=on_spectrum,
|
|
588
|
+
) as stream:
|
|
589
|
+
time.sleep(30) # receive for 30 seconds
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
#### How spectrum data flows
|
|
593
|
+
|
|
594
|
+
Spectrum data uses a completely different path from audio:
|
|
595
|
+
|
|
596
|
+
| | Audio | Spectrum |
|
|
597
|
+
|---|---|---|
|
|
598
|
+
| Transport | RTP on data multicast (port 5004) | TLV inside status multicast (port 5006) |
|
|
599
|
+
| Packet type | RTP with audio payload | Status packet with `BIN_DATA`/`BIN_BYTE_DATA` vectors |
|
|
600
|
+
| Trigger | Continuous (radiod pushes) | Poll-driven (`SpectrumStream` sends periodic COMMAND packets) |
|
|
601
|
+
| Demod type | `LINEAR_DEMOD`, `FM_DEMOD`, `WFM_DEMOD` | `SPECT_DEMOD` (float32) or `SPECT2_DEMOD` (uint8) |
|
|
602
|
+
|
|
603
|
+
`SpectrumStream` handles the polling, socket management, SSRC
|
|
604
|
+
filtering, and TLV decoding internally. The callback receives a
|
|
605
|
+
ready-to-use `ChannelStatus` with numpy arrays.
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
521
609
|
### `StreamQuality`, `GapSource`, `GapEvent`
|
|
522
610
|
|
|
523
611
|
Source: [stream_quality.py](../ka9q/stream_quality.py). Delivered to
|
|
@@ -48,6 +48,7 @@ ka9q/
|
|
|
48
48
|
├── stream.py RadiodStream — continuous sample delivery
|
|
49
49
|
├── managed_stream.py ManagedStream — self-healing single-channel wrapper
|
|
50
50
|
├── multi_stream.py MultiStream — shared-socket multi-SSRC receiver
|
|
51
|
+
├── spectrum_stream.py SpectrumStream — real-time FFT bin data receiver
|
|
51
52
|
├── pps_calibrator.py L6 BPSK PPS chain-delay calibration
|
|
52
53
|
├── cli.py `ka9q` console script (list / query / set / tui)
|
|
53
54
|
└── tui.py Textual TUI panels
|
|
@@ -95,6 +96,17 @@ users: wspr-recorder, psk-recorder, hf-timestd.
|
|
|
95
96
|
|
|
96
97
|
See [MULTI_STREAM.md](MULTI_STREAM.md) for depth.
|
|
97
98
|
|
|
99
|
+
### 5. `SpectrumStream` ([spectrum_stream.py](../ka9q/spectrum_stream.py))
|
|
100
|
+
|
|
101
|
+
Real-time FFT spectrum receiver. Spectrum data takes a different path
|
|
102
|
+
from audio: it arrives as `BIN_DATA` / `BIN_BYTE_DATA` TLV vectors
|
|
103
|
+
inside status packets on port 5006 (not RTP on port 5004).
|
|
104
|
+
`SpectrumStream` creates a `SPECT2_DEMOD` channel, polls radiod
|
|
105
|
+
periodically to trigger fresh FFT output, and delivers decoded
|
|
106
|
+
`ChannelStatus` objects (with `spectrum.bin_power_db` as a numpy
|
|
107
|
+
array) to an `on_spectrum` callback. Use this for spectrogram
|
|
108
|
+
displays, band activity monitors, and signal-search applications.
|
|
109
|
+
|
|
98
110
|
---
|
|
99
111
|
|
|
100
112
|
## Core: `RadiodControl`
|
|
@@ -252,6 +264,7 @@ packet atomically, releases.
|
|
|
252
264
|
- `MultiStream`: one health thread, one RTP receive thread total
|
|
253
265
|
(shared across all slots).
|
|
254
266
|
- `RadiodStream`: one RTP receive thread.
|
|
267
|
+
- `SpectrumStream`: one status-channel receive thread, one poll thread.
|
|
255
268
|
|
|
256
269
|
All are daemon threads; `stop()` joins with a 5 s timeout.
|
|
257
270
|
|
|
@@ -331,7 +344,7 @@ socket attributes to `None` in `finally` blocks.
|
|
|
331
344
|
### Stream lifecycle
|
|
332
345
|
|
|
333
346
|
Every streaming class (`RadiodStream`, `ManagedStream`, `MultiStream`,
|
|
334
|
-
`RTPRecorder`) follows the same shape:
|
|
347
|
+
`SpectrumStream`, `RTPRecorder`) follows the same shape:
|
|
335
348
|
|
|
336
349
|
```
|
|
337
350
|
__init__ → start() → (threads run) → stop() → (threads join, sockets closed)
|
|
@@ -501,6 +501,190 @@ Until then, use the recipe above.
|
|
|
501
501
|
|
|
502
502
|
---
|
|
503
503
|
|
|
504
|
+
## Recipe 5 — Spectrum display and spectrogram (FFT bin data)
|
|
505
|
+
|
|
506
|
+
ka9q-radio’s `radiod` can produce FFT spectrum data in addition to
|
|
507
|
+
demodulated audio. The ka9q-web frontend uses this for its waterfall
|
|
508
|
+
display. `SpectrumStream` gives Python clients the same capability.
|
|
509
|
+
|
|
510
|
+
### 5.1 How spectrum data differs from audio
|
|
511
|
+
|
|
512
|
+
Audio streams use RTP packets on the data multicast group (port 5004).
|
|
513
|
+
Spectrum data is completely different:
|
|
514
|
+
|
|
515
|
+
- It flows over the **status multicast channel** (port 5006), not RTP.
|
|
516
|
+
- It arrives as `BIN_DATA` (float32) or `BIN_BYTE_DATA` (uint8)
|
|
517
|
+
vectors inside TLV-encoded status packets.
|
|
518
|
+
- It is **poll-driven**: the client sends periodic COMMAND packets to
|
|
519
|
+
request fresh FFT output; radiod responds with status packets
|
|
520
|
+
containing the bin vectors.
|
|
521
|
+
- The demodulator type is `SPECT_DEMOD` (float32 output) or
|
|
522
|
+
`SPECT2_DEMOD` (quantised uint8 output, more compact).
|
|
523
|
+
|
|
524
|
+
`SpectrumStream` handles all of this internally.
|
|
525
|
+
|
|
526
|
+
### 5.2 Basic spectrum display
|
|
527
|
+
|
|
528
|
+
```python
|
|
529
|
+
from ka9q import RadiodControl, SpectrumStream
|
|
530
|
+
|
|
531
|
+
def on_spectrum(status):
|
|
532
|
+
db = status.spectrum.bin_power_db # numpy float32 array
|
|
533
|
+
freq = status.frequency # center frequency, Hz
|
|
534
|
+
rbw = status.spectrum.resolution_bw # bin width, Hz
|
|
535
|
+
n = len(db)
|
|
536
|
+
print(f"{n} bins at {freq/1e6:.3f} MHz, "
|
|
537
|
+
f"RBW {rbw:.1f} Hz, "
|
|
538
|
+
f"peak {db.max():.1f} dB, floor {db.min():.1f} dB")
|
|
539
|
+
|
|
540
|
+
with RadiodControl("bee1-hf-status.local") as ctl:
|
|
541
|
+
with SpectrumStream(
|
|
542
|
+
control=ctl,
|
|
543
|
+
frequency_hz=14.1e6,
|
|
544
|
+
bin_count=2048,
|
|
545
|
+
resolution_bw=50.0,
|
|
546
|
+
on_spectrum=on_spectrum,
|
|
547
|
+
) as stream:
|
|
548
|
+
import time
|
|
549
|
+
time.sleep(60) # receive spectrum for 60 seconds
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### 5.3 Building a spectrogram
|
|
553
|
+
|
|
554
|
+
A spectrogram is a time-vs-frequency image where each row is one
|
|
555
|
+
spectrum frame. Accumulate the `bin_power_db` arrays into a 2-D
|
|
556
|
+
matrix:
|
|
557
|
+
|
|
558
|
+
```python
|
|
559
|
+
import numpy as np
|
|
560
|
+
from ka9q import RadiodControl, SpectrumStream
|
|
561
|
+
|
|
562
|
+
rows = []
|
|
563
|
+
|
|
564
|
+
def on_spectrum(status):
|
|
565
|
+
db = status.spectrum.bin_power_db
|
|
566
|
+
if db is not None:
|
|
567
|
+
rows.append(db.copy())
|
|
568
|
+
|
|
569
|
+
with RadiodControl("bee1-hf-status.local") as ctl:
|
|
570
|
+
with SpectrumStream(
|
|
571
|
+
control=ctl,
|
|
572
|
+
frequency_hz=14.1e6,
|
|
573
|
+
bin_count=1024,
|
|
574
|
+
resolution_bw=100.0,
|
|
575
|
+
averaging=5,
|
|
576
|
+
on_spectrum=on_spectrum,
|
|
577
|
+
) as stream:
|
|
578
|
+
import time
|
|
579
|
+
time.sleep(30)
|
|
580
|
+
|
|
581
|
+
# rows is now a list of 1-D numpy arrays; stack into a 2-D image
|
|
582
|
+
spectrogram = np.array(rows) # shape: (time_steps, n_bins)
|
|
583
|
+
print(f"Spectrogram: {spectrogram.shape[0]} frames x "
|
|
584
|
+
f"{spectrogram.shape[1]} bins")
|
|
585
|
+
|
|
586
|
+
# Render with matplotlib:
|
|
587
|
+
# import matplotlib.pyplot as plt
|
|
588
|
+
# plt.imshow(spectrogram.T, aspect="auto", origin="lower",
|
|
589
|
+
# cmap="viridis", vmin=-120, vmax=-40)
|
|
590
|
+
# plt.colorbar(label="dB")
|
|
591
|
+
# plt.xlabel("Time (frame)"); plt.ylabel("Bin")
|
|
592
|
+
# plt.show()
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### 5.4 FFT parameters
|
|
596
|
+
|
|
597
|
+
`SpectrumStream` exposes radiod’s full FFT configuration:
|
|
598
|
+
|
|
599
|
+
| Parameter | Default | Description |
|
|
600
|
+
|---|---|---|
|
|
601
|
+
| `bin_count` | 1024 | Number of frequency bins |
|
|
602
|
+
| `resolution_bw` | 100.0 Hz | Bandwidth per bin |
|
|
603
|
+
| `window_type` | None (radiod default: Kaiser) | FFT window function (see `WindowType`) |
|
|
604
|
+
| `kaiser_beta` | None | Kaiser window shape parameter |
|
|
605
|
+
| `averaging` | None | Number of FFTs averaged per output |
|
|
606
|
+
| `overlap` | None | Window overlap ratio (0.0–1.0) |
|
|
607
|
+
| `demod_type` | `SPECT2_DEMOD` | `SPECT_DEMOD` for float32, `SPECT2_DEMOD` for uint8 |
|
|
608
|
+
| `poll_interval_sec` | 0.1 | Seconds between poll commands |
|
|
609
|
+
|
|
610
|
+
radiod uses two internal algorithms depending on resolution
|
|
611
|
+
bandwidth relative to a crossover frequency (default 200 Hz):
|
|
612
|
+
|
|
613
|
+
- **Wideband** (rbw > 200 Hz): operates on raw A/D samples.
|
|
614
|
+
- **Narrowband** (rbw ≤ 200 Hz): downconverts to complex baseband
|
|
615
|
+
first. Higher resolution but higher CPU cost.
|
|
616
|
+
|
|
617
|
+
### 5.5 Frequency axis reconstruction
|
|
618
|
+
|
|
619
|
+
Bin order in the delivered arrays is: bin 0 = DC (center frequency),
|
|
620
|
+
bins 1..N/2 = positive offsets, bins N/2+1..N-1 = negative offsets.
|
|
621
|
+
To build a frequency axis:
|
|
622
|
+
|
|
623
|
+
```python
|
|
624
|
+
def bin_frequencies(center_hz, bin_count, resolution_bw):
|
|
625
|
+
"""Return frequency axis for spectrum bins (Hz)."""
|
|
626
|
+
import numpy as np
|
|
627
|
+
bins = np.arange(bin_count)
|
|
628
|
+
# Shift so bin 0 = DC is at center
|
|
629
|
+
offsets = np.where(bins <= bin_count // 2,
|
|
630
|
+
bins, bins - bin_count)
|
|
631
|
+
return center_hz + offsets * resolution_bw
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### 5.6 Retuning
|
|
635
|
+
|
|
636
|
+
`SpectrumStream.set_frequency()` retunes the spectrum channel
|
|
637
|
+
without stopping and restarting:
|
|
638
|
+
|
|
639
|
+
```python
|
|
640
|
+
stream.start()
|
|
641
|
+
time.sleep(10)
|
|
642
|
+
stream.set_frequency(7.1e6) # switch to 40m band
|
|
643
|
+
time.sleep(10)
|
|
644
|
+
stream.stop()
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### 5.7 Combining spectrum with audio
|
|
648
|
+
|
|
649
|
+
A common pattern for interactive SDR applications: display a
|
|
650
|
+
spectrogram with `SpectrumStream` and play audio from a selected
|
|
651
|
+
frequency with `ManagedStream`. Both use the same `RadiodControl`:
|
|
652
|
+
|
|
653
|
+
```python
|
|
654
|
+
from ka9q import RadiodControl, SpectrumStream, ManagedStream
|
|
655
|
+
|
|
656
|
+
with RadiodControl("radiod.local") as ctl:
|
|
657
|
+
# Wideband spectrum display
|
|
658
|
+
spectrum = SpectrumStream(
|
|
659
|
+
control=ctl,
|
|
660
|
+
frequency_hz=14.1e6,
|
|
661
|
+
bin_count=2048,
|
|
662
|
+
resolution_bw=50.0,
|
|
663
|
+
on_spectrum=render_waterfall,
|
|
664
|
+
)
|
|
665
|
+
spectrum.start()
|
|
666
|
+
|
|
667
|
+
# Narrowband audio for a selected signal
|
|
668
|
+
audio = ManagedStream(
|
|
669
|
+
control=ctl,
|
|
670
|
+
frequency_hz=14.074e6,
|
|
671
|
+
preset="usb",
|
|
672
|
+
sample_rate=12000,
|
|
673
|
+
on_samples=play_audio,
|
|
674
|
+
)
|
|
675
|
+
audio.start()
|
|
676
|
+
|
|
677
|
+
# ... user clicks on waterfall to retune audio ...
|
|
678
|
+
# ctl.set_frequency(audio.channel.ssrc, new_freq)
|
|
679
|
+
|
|
680
|
+
audio.stop()
|
|
681
|
+
spectrum.stop()
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
This is the pattern David Gonsalves’ spectrogram client will use.
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
504
688
|
## Further reading
|
|
505
689
|
|
|
506
690
|
- [GETTING_STARTED.md](GETTING_STARTED.md) — install, mDNS, multi-homed hosts
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Spectrum display example — receive real-time FFT data from radiod.
|
|
4
|
+
|
|
5
|
+
Prints per-frame statistics: bin count, center frequency, resolution
|
|
6
|
+
bandwidth, peak power, and noise floor. Demonstrates SpectrumStream,
|
|
7
|
+
which receives spectrum data via the status multicast channel (not RTP).
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 spectrum_example.py HOST [--freq HZ] [--bins N] [--rbw HZ]
|
|
11
|
+
[--duration SEC]
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
python3 spectrum_example.py bee1-hf-status.local --freq 14.1e6
|
|
15
|
+
python3 spectrum_example.py bee1-hf-status.local --freq 7.0e6 --bins 2048 --rbw 25
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from ka9q import RadiodControl, SpectrumStream
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bin_frequencies(center_hz: float, bin_count: int, resolution_bw: float):
|
|
28
|
+
"""Build a frequency axis (Hz) from spectrum metadata."""
|
|
29
|
+
bins = np.arange(bin_count)
|
|
30
|
+
offsets = np.where(bins <= bin_count // 2, bins, bins - bin_count)
|
|
31
|
+
return center_hz + offsets * resolution_bw
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
description="Receive real-time spectrum data from radiod"
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("host", help="radiod status address (e.g. bee1-hf-status.local)")
|
|
39
|
+
parser.add_argument("--freq", type=float, default=14.1e6,
|
|
40
|
+
help="Center frequency in Hz (default: 14.1 MHz)")
|
|
41
|
+
parser.add_argument("--bins", type=int, default=1024,
|
|
42
|
+
help="Number of FFT bins (default: 1024)")
|
|
43
|
+
parser.add_argument("--rbw", type=float, default=100.0,
|
|
44
|
+
help="Resolution bandwidth in Hz (default: 100)")
|
|
45
|
+
parser.add_argument("--duration", type=float, default=30.0,
|
|
46
|
+
help="How long to run in seconds (default: 30)")
|
|
47
|
+
parser.add_argument("--averaging", type=int, default=None,
|
|
48
|
+
help="Number of FFTs to average per frame")
|
|
49
|
+
parser.add_argument("--interface", type=str, default=None,
|
|
50
|
+
help="Network interface IP for multicast")
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
|
|
53
|
+
frame_count = 0
|
|
54
|
+
|
|
55
|
+
def on_spectrum(status):
|
|
56
|
+
nonlocal frame_count
|
|
57
|
+
frame_count += 1
|
|
58
|
+
|
|
59
|
+
db = status.spectrum.bin_power_db
|
|
60
|
+
if db is None:
|
|
61
|
+
print(f" frame {frame_count}: no bin data")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
freq_mhz = (status.frequency or 0) / 1e6
|
|
65
|
+
rbw = status.spectrum.resolution_bw or args.rbw
|
|
66
|
+
n = len(db)
|
|
67
|
+
|
|
68
|
+
# Find peak bin and its frequency
|
|
69
|
+
peak_idx = int(np.argmax(db))
|
|
70
|
+
freqs = bin_frequencies(status.frequency or args.freq, n, rbw)
|
|
71
|
+
peak_freq_mhz = freqs[peak_idx] / 1e6
|
|
72
|
+
|
|
73
|
+
print(
|
|
74
|
+
f" frame {frame_count:4d}: "
|
|
75
|
+
f"{n} bins @ {freq_mhz:.3f} MHz, "
|
|
76
|
+
f"RBW {rbw:.1f} Hz, "
|
|
77
|
+
f"peak {db[peak_idx]:.1f} dB at {peak_freq_mhz:.4f} MHz, "
|
|
78
|
+
f"floor {db.min():.1f} dB"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
print(f"Connecting to {args.host}...")
|
|
82
|
+
with RadiodControl(args.host, interface=args.interface) as ctl:
|
|
83
|
+
print(f"Starting spectrum stream: {args.freq/1e6:.3f} MHz, "
|
|
84
|
+
f"{args.bins} bins, {args.rbw} Hz/bin")
|
|
85
|
+
|
|
86
|
+
kwargs = {}
|
|
87
|
+
if args.averaging is not None:
|
|
88
|
+
kwargs["averaging"] = args.averaging
|
|
89
|
+
|
|
90
|
+
with SpectrumStream(
|
|
91
|
+
control=ctl,
|
|
92
|
+
frequency_hz=args.freq,
|
|
93
|
+
bin_count=args.bins,
|
|
94
|
+
resolution_bw=args.rbw,
|
|
95
|
+
on_spectrum=on_spectrum,
|
|
96
|
+
**kwargs,
|
|
97
|
+
) as stream:
|
|
98
|
+
print(f"SSRC: {stream.ssrc}")
|
|
99
|
+
print(f"Receiving for {args.duration} seconds...\n")
|
|
100
|
+
try:
|
|
101
|
+
time.sleep(args.duration)
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
print("\nInterrupted.")
|
|
104
|
+
|
|
105
|
+
print(f"\nDone. {frame_count} frames received.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
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.13.0'
|
|
60
60
|
__author__ = 'Michael Hauan AC0G'
|
|
61
61
|
|
|
62
62
|
from .control import RadiodControl, allocate_ssrc
|
|
@@ -106,6 +106,7 @@ from .managed_stream import (
|
|
|
106
106
|
StreamState,
|
|
107
107
|
)
|
|
108
108
|
from .multi_stream import MultiStream
|
|
109
|
+
from .spectrum_stream import SpectrumStream
|
|
109
110
|
|
|
110
111
|
__all__ = [
|
|
111
112
|
# Control
|
|
@@ -166,6 +167,9 @@ __all__ = [
|
|
|
166
167
|
# Multi Stream (shared socket, multiple channels)
|
|
167
168
|
'MultiStream',
|
|
168
169
|
|
|
170
|
+
# Spectrum Stream (FFT bin data receiver)
|
|
171
|
+
'SpectrumStream',
|
|
172
|
+
|
|
169
173
|
# Utilities
|
|
170
174
|
'generate_multicast_ip',
|
|
171
175
|
'ChannelMonitor',
|