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.
Files changed (104) hide show
  1. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/CHANGELOG.md +79 -0
  2. {ka9q_python-3.10.0/ka9q_python.egg-info → ka9q_python-3.13.0}/PKG-INFO +1 -1
  3. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/API_REFERENCE.md +89 -1
  4. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/ARCHITECTURE.md +14 -1
  5. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/RECIPES.md +184 -0
  6. ka9q_python-3.13.0/examples/spectrum_example.py +109 -0
  7. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/__init__.py +5 -1
  8. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/control.py +71 -4
  9. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/multi_stream.py +42 -0
  10. ka9q_python-3.13.0/ka9q/spectrum_stream.py +317 -0
  11. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/status.py +45 -2
  12. {ka9q_python-3.10.0 → ka9q_python-3.13.0/ka9q_python.egg-info}/PKG-INFO +1 -1
  13. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/SOURCES.txt +7 -1
  14. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/pyproject.toml +1 -1
  15. ka9q_python-3.13.0/scripts/check_upstream_drift.py +490 -0
  16. ka9q_python-3.13.0/tests/conftest.py +29 -0
  17. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_channel_verification.py +1 -5
  18. ka9q_python-3.13.0/tests/test_filter_edges.py +256 -0
  19. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_lifetime.py +88 -0
  20. ka9q_python-3.13.0/tests/test_spectrum.py +271 -0
  21. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_debug.py +3 -2
  22. ka9q_python-3.13.0/tests/test_upstream_drift.py +176 -0
  23. ka9q_python-3.10.0/tests/conftest.py +0 -14
  24. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/LICENSE +0 -0
  25. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/MANIFEST.in +0 -0
  26. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/README.md +0 -0
  27. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/CLI_GUIDE.md +0 -0
  28. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/GETTING_STARTED.md +0 -0
  29. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/INSTALLATION.md +0 -0
  30. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/MULTI_STREAM.md +0 -0
  31. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  32. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/SECURITY.md +0 -0
  33. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/TESTING_GUIDE.md +0 -0
  34. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/docs/TUI_GUIDE.md +0 -0
  35. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/advanced_features_demo.py +0 -0
  36. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/channel_cleanup_example.py +0 -0
  37. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/codar_oceanography.py +0 -0
  38. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/diagnostics/diagnose_packets.py +0 -0
  39. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  40. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/discover_example.py +0 -0
  41. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/grape_integration_example.py +0 -0
  42. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/hf_band_scanner.py +0 -0
  43. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/multi_stream_smoke.py +0 -0
  44. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/rtp_recorder_example.py +0 -0
  45. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/simple_am_radio.py +0 -0
  46. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/stream_example.py +0 -0
  47. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/superdarn_recorder.py +0 -0
  48. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/test_channel_operations.py +0 -0
  49. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/test_improvements.py +0 -0
  50. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/test_timing_fields.py +0 -0
  51. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/tune.py +0 -0
  52. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/examples/tune_example.py +0 -0
  53. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/addressing.py +0 -0
  54. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/cli.py +0 -0
  55. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/compat.py +0 -0
  56. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/discovery.py +0 -0
  57. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/exceptions.py +0 -0
  58. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/managed_stream.py +0 -0
  59. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/monitor.py +0 -0
  60. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/pps_calibrator.py +0 -0
  61. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/resequencer.py +0 -0
  62. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/rtp_recorder.py +0 -0
  63. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/stream.py +0 -0
  64. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/stream_quality.py +0 -0
  65. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/tui.py +0 -0
  66. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/types.py +0 -0
  67. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q/utils.py +0 -0
  68. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  69. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/entry_points.txt +0 -0
  70. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/requires.txt +0 -0
  71. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_python.egg-info/top_level.txt +0 -0
  72. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/ka9q_radio_compat +0 -0
  73. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/scripts/sync_types.py +0 -0
  74. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/setup.cfg +0 -0
  75. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/setup.py +0 -0
  76. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/__init__.py +0 -0
  77. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_addressing.py +0 -0
  78. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_create_split_encoding.py +0 -0
  79. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_decode_description.py +0 -0
  80. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_decode_functions.py +0 -0
  81. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_encode_functions.py +0 -0
  82. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_encode_socket.py +0 -0
  83. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ensure_channel_encoding.py +0 -0
  84. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_integration.py +0 -0
  85. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_iq_20khz_f32.py +0 -0
  86. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_listen_multicast.py +0 -0
  87. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_managed_stream_recovery.py +0 -0
  88. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_monitor.py +0 -0
  89. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_multihomed.py +0 -0
  90. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_native_discovery.py +0 -0
  91. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_performance_fixes.py +0 -0
  92. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_protocol_compat.py +0 -0
  93. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_remove_channel.py +0 -0
  94. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_rtp_recorder.py +0 -0
  95. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_security_features.py +0 -0
  96. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ssrc_dest_unit.py +0 -0
  97. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ssrc_encoding_unit.py +0 -0
  98. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  99. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_status_decoder.py +0 -0
  100. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_ttl_warning.py +0 -0
  101. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune.py +0 -0
  102. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_cli.py +0 -0
  103. {ka9q_python-3.10.0 → ka9q_python-3.13.0}/tests/test_tune_live.py +0 -0
  104. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.10.0
3
+ Version: 3.13.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Home-page: https://github.com/mijahauan/ka9q-python
6
6
  Author: Michael Hauan AC0G
@@ -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.9.0'
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',