ka9q-python 3.10.0__tar.gz → 3.12.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.12.0}/CHANGELOG.md +42 -0
- {ka9q_python-3.10.0/ka9q_python.egg-info → ka9q_python-3.12.0}/PKG-INFO +1 -1
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/API_REFERENCE.md +89 -1
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/ARCHITECTURE.md +14 -1
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/RECIPES.md +184 -0
- ka9q_python-3.12.0/examples/spectrum_example.py +109 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/__init__.py +5 -1
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/control.py +71 -4
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/multi_stream.py +10 -0
- ka9q_python-3.12.0/ka9q/spectrum_stream.py +317 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/status.py +45 -2
- {ka9q_python-3.10.0 → ka9q_python-3.12.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q_python.egg-info/SOURCES.txt +7 -1
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/pyproject.toml +1 -1
- ka9q_python-3.12.0/scripts/check_upstream_drift.py +490 -0
- ka9q_python-3.12.0/tests/conftest.py +29 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_channel_verification.py +1 -5
- ka9q_python-3.12.0/tests/test_filter_edges.py +256 -0
- ka9q_python-3.12.0/tests/test_spectrum.py +271 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_tune_debug.py +3 -2
- ka9q_python-3.12.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.12.0}/LICENSE +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/MANIFEST.in +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/README.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/tune.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/types.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/setup.cfg +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/setup.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/__init__.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.10.0 → ka9q_python-3.12.0}/tests/test_tune_method.py +0 -0
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.12.0] - 2026-05-07
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **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`:
|
|
8
|
+
- `bin_data: Optional[np.ndarray]` — float32 power values per FFT bin.
|
|
9
|
+
- `bin_byte_data: Optional[np.ndarray]` — uint8 quantised log-power values.
|
|
10
|
+
- `bin_power_db` property — returns dB values regardless of source format (10*log10 for float data, `base + byte * step` for byte data).
|
|
11
|
+
|
|
12
|
+
- **`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.
|
|
13
|
+
|
|
14
|
+
### Documentation
|
|
15
|
+
|
|
16
|
+
- API_REFERENCE.md: SpectrumStream section, SpectrumStatus bin vector fields, quickstart table updated.
|
|
17
|
+
- ARCHITECTURE.md: module listing, abstraction layer description, threading model updated.
|
|
18
|
+
- RECIPES.md: Recipe 5 covering spectrum display, spectrogram accumulation, frequency axis reconstruction, FFT parameter tuning, and combined audio+spectrum patterns.
|
|
19
|
+
- New `examples/spectrum_example.py`: runnable CLI example for real-time spectrum reception.
|
|
20
|
+
|
|
21
|
+
### Tests
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## [3.11.0] - 2026-05-06
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **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`.
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
35
|
+
### Backward Compatibility
|
|
36
|
+
|
|
37
|
+
- 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.
|
|
38
|
+
|
|
39
|
+
### Tests
|
|
40
|
+
|
|
41
|
+
- 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).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
3
45
|
## [3.10.0] - 2026-04-30
|
|
4
46
|
|
|
5
47
|
### 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.12.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',
|
|
@@ -1087,7 +1087,10 @@ class RadiodControl:
|
|
|
1087
1087
|
destination: Optional[str] = None,
|
|
1088
1088
|
encoding: int = 0,
|
|
1089
1089
|
ssrc: Optional[int] = None,
|
|
1090
|
-
lifetime: Optional[int] = None
|
|
1090
|
+
lifetime: Optional[int] = None,
|
|
1091
|
+
low_edge: Optional[float] = None,
|
|
1092
|
+
high_edge: Optional[float] = None,
|
|
1093
|
+
kaiser_beta: Optional[float] = None) -> int:
|
|
1091
1094
|
"""
|
|
1092
1095
|
Create a new channel with specified configuration
|
|
1093
1096
|
|
|
@@ -1126,6 +1129,16 @@ class RadiodControl:
|
|
|
1126
1129
|
the lifetime to at least Channel_idle_timeout (~20 s) — so
|
|
1127
1130
|
callers using a finite lifetime as a crash-safe cleanup
|
|
1128
1131
|
must keep polling (e.g. via tune() or set_channel_lifetime).
|
|
1132
|
+
low_edge: Channel filter low edge in Hz, relative to channel
|
|
1133
|
+
center (typically negative for symmetric IQ filters).
|
|
1134
|
+
None = use the preset's default. Useful for clients that
|
|
1135
|
+
need a narrower or wider passband than the preset provides
|
|
1136
|
+
(e.g. BPSK PPS calibration with a near-CW signal needs a
|
|
1137
|
+
much narrower filter than the iq preset's default).
|
|
1138
|
+
high_edge: Channel filter high edge in Hz, relative to channel
|
|
1139
|
+
center. None = use the preset's default. See low_edge.
|
|
1140
|
+
kaiser_beta: Filter window Kaiser beta. None = use radiod default.
|
|
1141
|
+
Higher beta = sharper transition + more sidelobe rejection.
|
|
1129
1142
|
|
|
1130
1143
|
Returns:
|
|
1131
1144
|
The SSRC of the created channel (useful when auto-allocated)
|
|
@@ -1173,8 +1186,13 @@ class RadiodControl:
|
|
|
1173
1186
|
_validate_sample_rate(sample_rate)
|
|
1174
1187
|
_validate_gain(gain)
|
|
1175
1188
|
|
|
1189
|
+
filter_desc = ""
|
|
1190
|
+
if low_edge is not None or high_edge is not None or kaiser_beta is not None:
|
|
1191
|
+
filter_desc = (f", filter=[{low_edge},{high_edge}]"
|
|
1192
|
+
f"{f' beta={kaiser_beta}' if kaiser_beta is not None else ''}")
|
|
1176
1193
|
logger.info(f"Creating channel: SSRC={ssrc}, freq={frequency_hz/1e6:.3f} MHz, "
|
|
1177
|
-
f"demod={preset}, rate={sample_rate}Hz, agc={agc_enable}, gain={gain}dB,
|
|
1194
|
+
f"demod={preset}, rate={sample_rate}Hz, agc={agc_enable}, gain={gain}dB, "
|
|
1195
|
+
f"enc={encoding}{filter_desc}")
|
|
1178
1196
|
|
|
1179
1197
|
# Build a single command packet with ALL parameters
|
|
1180
1198
|
# This ensures radiod creates the channel with the correct settings
|
|
@@ -1200,7 +1218,21 @@ class RadiodControl:
|
|
|
1200
1218
|
if sample_rate:
|
|
1201
1219
|
encode_int(cmdbuffer, StatusType.OUTPUT_SAMPRATE, sample_rate)
|
|
1202
1220
|
logger.info(f"Setting sample rate for SSRC {ssrc} to {sample_rate} Hz")
|
|
1203
|
-
|
|
1221
|
+
|
|
1222
|
+
# Filter edges + Kaiser beta — override the preset defaults inline
|
|
1223
|
+
# with the create command so the channel goes live with the requested
|
|
1224
|
+
# passband from frame zero (no transient at preset BW). Same TLV path
|
|
1225
|
+
# as set_filter().
|
|
1226
|
+
if low_edge is not None:
|
|
1227
|
+
encode_double(cmdbuffer, StatusType.LOW_EDGE, low_edge)
|
|
1228
|
+
logger.info(f"Setting LOW_EDGE for SSRC {ssrc} to {low_edge} Hz")
|
|
1229
|
+
if high_edge is not None:
|
|
1230
|
+
encode_double(cmdbuffer, StatusType.HIGH_EDGE, high_edge)
|
|
1231
|
+
logger.info(f"Setting HIGH_EDGE for SSRC {ssrc} to {high_edge} Hz")
|
|
1232
|
+
if kaiser_beta is not None:
|
|
1233
|
+
encode_float(cmdbuffer, StatusType.KAISER_BETA, kaiser_beta)
|
|
1234
|
+
logger.info(f"Setting KAISER_BETA for SSRC {ssrc} to {kaiser_beta}")
|
|
1235
|
+
|
|
1204
1236
|
# AGC setting
|
|
1205
1237
|
encode_int(cmdbuffer, StatusType.AGC_ENABLE, agc_enable)
|
|
1206
1238
|
logger.info(f"Setting AGC_ENABLE for SSRC {ssrc} to {agc_enable}")
|
|
@@ -1306,6 +1338,9 @@ class RadiodControl:
|
|
|
1306
1338
|
timeout: float = 5.0,
|
|
1307
1339
|
frequency_tolerance: float = 1.0,
|
|
1308
1340
|
lifetime: Optional[int] = None,
|
|
1341
|
+
low_edge: Optional[float] = None,
|
|
1342
|
+
high_edge: Optional[float] = None,
|
|
1343
|
+
kaiser_beta: Optional[float] = None,
|
|
1309
1344
|
):
|
|
1310
1345
|
"""
|
|
1311
1346
|
Ensure a channel exists with the requested characteristics and return it.
|
|
@@ -1339,7 +1374,21 @@ class RadiodControl:
|
|
|
1339
1374
|
so the channel's lifetime is refreshed regardless of its
|
|
1340
1375
|
prior state. None (default) = don't touch lifetime.
|
|
1341
1376
|
See create_channel() for the full unit semantics.
|
|
1342
|
-
|
|
1377
|
+
low_edge: Optional channel filter low edge in Hz (relative to
|
|
1378
|
+
channel center). When specified, the requested filter is
|
|
1379
|
+
applied both to newly-created channels (inline with the
|
|
1380
|
+
create command) and to reused channels (via set_filter
|
|
1381
|
+
after the reuse decision), so ensure_channel always
|
|
1382
|
+
returns a channel with the requested passband.
|
|
1383
|
+
None (default) = use the preset's filter edges.
|
|
1384
|
+
Note: filter edges are not part of the channel identity
|
|
1385
|
+
(they don't participate in SSRC allocation), so a second
|
|
1386
|
+
caller requesting the same channel with different edges
|
|
1387
|
+
will reconfigure the filter — last-writer-wins, same
|
|
1388
|
+
model as gain/AGC.
|
|
1389
|
+
high_edge: See low_edge.
|
|
1390
|
+
kaiser_beta: Optional filter window Kaiser beta. None = radiod default.
|
|
1391
|
+
|
|
1343
1392
|
Returns:
|
|
1344
1393
|
ChannelInfo object with verified channel details, ready for RadiodStream
|
|
1345
1394
|
|
|
@@ -1428,6 +1477,21 @@ class RadiodControl:
|
|
|
1428
1477
|
# set forces it to the requested value.
|
|
1429
1478
|
if lifetime is not None:
|
|
1430
1479
|
self.set_channel_lifetime(ssrc, int(lifetime))
|
|
1480
|
+
# Apply filter edges on reuse so the requested
|
|
1481
|
+
# passband is authoritative regardless of the prior
|
|
1482
|
+
# channel state. ChannelInfo doesn't expose current
|
|
1483
|
+
# filter edges (radiod's discovery payload doesn't
|
|
1484
|
+
# carry them reliably), so we re-send rather than
|
|
1485
|
+
# compare.
|
|
1486
|
+
if (low_edge is not None
|
|
1487
|
+
or high_edge is not None
|
|
1488
|
+
or kaiser_beta is not None):
|
|
1489
|
+
self.set_filter(
|
|
1490
|
+
ssrc,
|
|
1491
|
+
low_edge=low_edge,
|
|
1492
|
+
high_edge=high_edge,
|
|
1493
|
+
kaiser_beta=kaiser_beta,
|
|
1494
|
+
)
|
|
1431
1495
|
return existing
|
|
1432
1496
|
else:
|
|
1433
1497
|
logger.info(
|
|
@@ -1456,6 +1520,9 @@ class RadiodControl:
|
|
|
1456
1520
|
encoding=encoding,
|
|
1457
1521
|
ssrc=ssrc,
|
|
1458
1522
|
lifetime=lifetime,
|
|
1523
|
+
low_edge=low_edge,
|
|
1524
|
+
high_edge=high_edge,
|
|
1525
|
+
kaiser_beta=kaiser_beta,
|
|
1459
1526
|
)
|
|
1460
1527
|
|
|
1461
1528
|
# Wait for channel to appear and verify it meets specs
|