ka9q-python 3.17.0__tar.gz → 3.18.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 (113) hide show
  1. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/CHANGELOG.md +27 -0
  2. {ka9q_python-3.17.0/ka9q_python.egg-info → ka9q_python-3.18.0}/PKG-INFO +1 -1
  3. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/__init__.py +6 -0
  4. ka9q_python-3.18.0/ka9q/slot_clock.py +258 -0
  5. {ka9q_python-3.17.0 → ka9q_python-3.18.0/ka9q_python.egg-info}/PKG-INFO +1 -1
  6. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/SOURCES.txt +2 -0
  7. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/pyproject.toml +1 -1
  8. ka9q_python-3.18.0/tests/test_slot_clock.py +145 -0
  9. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/LICENSE +0 -0
  10. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/MANIFEST.in +0 -0
  11. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/README.md +0 -0
  12. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/API_REFERENCE.md +0 -0
  13. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/ARCHITECTURE.md +0 -0
  14. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/CLI_GUIDE.md +0 -0
  15. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/GETTING_STARTED.md +0 -0
  16. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/INSTALLATION.md +0 -0
  17. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/MULTI_STREAM.md +0 -0
  18. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/RECIPES.md +0 -0
  19. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/REQUIREMENTS.md +0 -0
  20. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
  21. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/SECURITY.md +0 -0
  22. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/TESTING_GUIDE.md +0 -0
  23. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/TUI_GUIDE.md +0 -0
  24. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/advanced_features_demo.py +0 -0
  25. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/channel_cleanup_example.py +0 -0
  26. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/codar_oceanography.py +0 -0
  27. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/diagnostics/diagnose_packets.py +0 -0
  28. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/diagnostics/repro_utc_bug.py +0 -0
  29. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/discover_example.py +0 -0
  30. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/grape_integration_example.py +0 -0
  31. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/hf_band_scanner.py +0 -0
  32. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/multi_stream_smoke.py +0 -0
  33. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/rtp_recorder_example.py +0 -0
  34. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/simple_am_radio.py +0 -0
  35. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/spectrum_example.py +0 -0
  36. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/stream_example.py +0 -0
  37. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/superdarn_recorder.py +0 -0
  38. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/test_channel_operations.py +0 -0
  39. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/test_improvements.py +0 -0
  40. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/test_timing_fields.py +0 -0
  41. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/tune.py +0 -0
  42. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/tune_example.py +0 -0
  43. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/_multicast.py +0 -0
  44. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/addressing.py +0 -0
  45. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/cli.py +0 -0
  46. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/compat.py +0 -0
  47. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/control.py +0 -0
  48. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/discovery.py +0 -0
  49. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/exceptions.py +0 -0
  50. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/managed_stream.py +0 -0
  51. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/monitor.py +0 -0
  52. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/multi_stream.py +0 -0
  53. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/pps_calibrator.py +0 -0
  54. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/resequencer.py +0 -0
  55. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/rtp_recorder.py +0 -0
  56. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/spectrum_stream.py +0 -0
  57. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/status.py +0 -0
  58. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/status_listener.py +0 -0
  59. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/stream.py +0 -0
  60. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/stream_quality.py +0 -0
  61. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/tui.py +0 -0
  62. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/types.py +0 -0
  63. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/utils.py +0 -0
  64. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
  65. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/entry_points.txt +0 -0
  66. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/requires.txt +0 -0
  67. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/top_level.txt +0 -0
  68. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_radio_compat +0 -0
  69. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/scripts/check_upstream_drift.py +0 -0
  70. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/scripts/sync_types.py +0 -0
  71. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/setup.cfg +0 -0
  72. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/__init__.py +0 -0
  73. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/conftest.py +0 -0
  74. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_addressing.py +0 -0
  75. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_channel_verification.py +0 -0
  76. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_client_id_destination.py +0 -0
  77. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_create_split_encoding.py +0 -0
  78. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_decode_description.py +0 -0
  79. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_decode_functions.py +0 -0
  80. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_encode_functions.py +0 -0
  81. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_encode_socket.py +0 -0
  82. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ensure_channel_encoding.py +0 -0
  83. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_filter_edges.py +0 -0
  84. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_integration.py +0 -0
  85. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_iq_20khz_f32.py +0 -0
  86. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_lifetime.py +0 -0
  87. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_listen_multicast.py +0 -0
  88. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_managed_stream_recovery.py +0 -0
  89. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_monitor.py +0 -0
  90. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multicast_helpers.py +0 -0
  91. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multihomed.py +0 -0
  92. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multistream_gap_storm.py +0 -0
  93. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multistream_prune.py +0 -0
  94. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_native_discovery.py +0 -0
  95. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_parse_rtp_samples_iq.py +0 -0
  96. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_performance_fixes.py +0 -0
  97. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_protocol_compat.py +0 -0
  98. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_remove_channel.py +0 -0
  99. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_rtp_recorder.py +0 -0
  100. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_security_features.py +0 -0
  101. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_spectrum.py +0 -0
  102. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ssrc_dest_unit.py +0 -0
  103. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ssrc_encoding_unit.py +0 -0
  104. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
  105. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_status_decoder.py +0 -0
  106. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_status_listener.py +0 -0
  107. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ttl_warning.py +0 -0
  108. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune.py +0 -0
  109. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_cli.py +0 -0
  110. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_debug.py +0 -0
  111. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_live.py +0 -0
  112. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_method.py +0 -0
  113. {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_upstream_drift.py +0 -0
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.18.0] - 2026-06-28
4
+
5
+ ### Added
6
+
7
+ - **`SlotClock` — epoch-aligned slot boundaries in RTP-timestamp space**
8
+ (`ka9q.slot_clock`). The canonical, drift-immune timing primitive for every
9
+ sigmond slot/period recorder (psk FT8/FT4, wspr WSPR/FST4W, meteor-scatter,
10
+ …). Slot boundaries are driven by radiod's GPSDO-disciplined RTP timestamp
11
+ (which advances exactly once per output sample of real time) rather than a
12
+ delivered-sample-count projection that silently drifts when the receive path
13
+ over/under-counts samples — the failure that labels a WAV with a UTC the audio
14
+ doesn't match and zeroes out decodes on good RF. Boundary stepping is exact
15
+ integer arithmetic (`cadence_sec * sample_rate` must be integer). Absolute
16
+ positions are tracked as an unwrapped 64-bit count relative to a monotonic
17
+ high-water, so harvesting keeps working past the 2³¹-sample signed-32 window
18
+ (~49.7 h @ 12 kHz / ~9.3 h @ 64 kHz IQ) — a long WSPR run no longer stalls.
19
+ Exposes `SlotClock`, `Slot`, and `rtp_diff` (Karn signed-32 difference).
20
+ Pure timing logic — owns no socket, ring, or thread.
21
+
22
+ ### Notes
23
+
24
+ - This is the release that promotes `SlotClock` (previously parked) to public,
25
+ consumed API: the sigmond recorders are migrating their slot/period timing
26
+ onto it so an upstream timing fix lands once instead of being re-patched per
27
+ client. Pairs with `hamsci_dsp.timing.acquire_anchor_utc` (the shared RTP→UTC
28
+ anchor) on the sigmond side.
29
+
3
30
  ## [3.17.0] - 2026-06-28
4
31
 
5
32
  First release marked **Production/Stable** (trove classifier 4 → 5). Folds in
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.17.0
3
+ Version: 3.18.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Author-email: Michael Hauan AC0G <ac0g@hauan.org>
6
6
  License: MIT
@@ -115,6 +115,7 @@ from .managed_stream import (
115
115
  StreamState,
116
116
  )
117
117
  from .multi_stream import MultiStream
118
+ from .slot_clock import SlotClock, Slot, rtp_diff
118
119
  from .spectrum_stream import SpectrumStream
119
120
  from .status_listener import StatusListener, StatusListenerStats
120
121
 
@@ -161,6 +162,11 @@ __all__ = [
161
162
  'rtp_to_utc',
162
163
  'rtp_to_wallclock',
163
164
 
165
+ # Slot timing (epoch-aligned, RTP-referenced)
166
+ 'SlotClock',
167
+ 'Slot',
168
+ 'rtp_diff',
169
+
164
170
  # Stream API (sample-oriented)
165
171
  'RadiodStream',
166
172
  'StreamQuality',
@@ -0,0 +1,258 @@
1
+ """SlotClock — epoch-aligned slot boundaries in GPS-true RTP-timestamp space.
2
+
3
+ The canonical, drift-immune timing primitive shared by every sigmond slot/
4
+ period recorder (psk-recorder FT8/FT4, wspr-recorder WSPR/FST4W, hfdl, …).
5
+ It exists because clients kept re-implementing slot timing on top of a
6
+ *delivered-sample-count* projection, which silently drifts: if the receive
7
+ path ever over- or under-counts samples relative to real time (gap-fill
8
+ accounting, a late anchor, a dropped burst the resequencer mis-sizes), the
9
+ projected UTC runs ahead of or behind the actual RF, the WAV gets a label
10
+ that doesn't match its content, and decode_ft8/wsprd align to the wrong grid
11
+ point → zero decodes on perfectly good audio. A self-check built from the
12
+ same delivered-count can't catch this — both sides move together.
13
+
14
+ The fix, and this class's whole premise: **drive boundaries from the RTP
15
+ timestamp radiod stamps on every packet.** radiod's RTP counter is
16
+ GPS/PPS-disciplined and advances by exactly one per output sample of real
17
+ time, regardless of what the client's delivery bookkeeping does. So:
18
+
19
+ * Anchor ONCE: map a single RTP timestamp to UTC via ``rtp_to_utc``
20
+ (the §18/METROLOGY RTP-reference rule — the only wall-clock-ish read).
21
+ * Every slot boundary is an epoch-aligned UTC instant (a multiple of the
22
+ cadence: FT8 :00/:15/:30/:45, FT4 every 7.5 s, WSPR every 120 s) whose
23
+ RTP timestamp is computed by integer arithmetic from the anchor.
24
+ * A slot is *complete* once the stream's latest RTP timestamp has passed
25
+ the slot's end (plus a small settle). The sample window to extract is
26
+ expressed in RTP units, so it's immune to delivered-count drift.
27
+ * ``cadence_sec * sample_rate`` must be an integer number of samples
28
+ (true for every real mode: 15·12000, 7.5·12000, 120·12000, …), so
29
+ boundary-to-boundary stepping is exact integer arithmetic — no float
30
+ accumulation.
31
+
32
+ RTP timestamps are unsigned 32-bit and wrap (~99 h at 12 kHz, ~16 h at
33
+ 64 kHz IQ). All differences use Phil Karn's signed-32 technique so a wrap
34
+ is just normal arithmetic. Absolute RTP positions are tracked as an
35
+ unwrapped 64-bit count off the anchor: a monotonic high-water offset is
36
+ carried, and every wrapped timestamp is unwrapped *relative to that
37
+ high-water* (not relative to the anchor). This is what makes the offset
38
+ correct for streams that run arbitrarily long past their anchor — a raw
39
+ ``anchor``-relative signed-32 diff would alias once the stream is more than
40
+ 2**31 samples (~49.7 h @ 12 kHz, ~9.3 h @ 64 kHz IQ) from the anchor, at
41
+ which point ``advance`` would silently stop harvesting slots. All real
42
+ queries (the latest timestamp, a just-passed boundary) are within one slot
43
+ of the high-water, so the unwrap is unambiguous.
44
+
45
+ This class is pure timing logic — it owns no socket, ring, or thread. The
46
+ caller feeds it RTP timestamps and asks which slots are now complete.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ import math
53
+ from dataclasses import dataclass
54
+ from typing import List, Optional
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+
59
+ def rtp_diff(a: int, b: int) -> int:
60
+ """Signed 32-bit RTP timestamp difference ``a - b`` (Karn's technique).
61
+
62
+ Returns a value in [-2**31, 2**31) so timestamp wraps are handled as
63
+ ordinary arithmetic: positive => a is ahead of b.
64
+ """
65
+ d = (a - b) & 0xFFFFFFFF
66
+ if d >= 0x80000000:
67
+ d -= 0x100000000
68
+ return d
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Slot:
73
+ """One completed, epoch-aligned slot.
74
+
75
+ index monotonic slot counter from the anchor (k)
76
+ start_rtp RTP timestamp of the first sample of the slot
77
+ start_utc UTC of that first sample (epoch-aligned: multiple
78
+ of cadence_sec to within <1 sample)
79
+ n_samples number of samples in the slot (== cadence_samples)
80
+ """
81
+
82
+ index: int
83
+ start_rtp: int
84
+ start_utc: float
85
+ n_samples: int
86
+
87
+
88
+ class SlotClock:
89
+ """Epoch-aligned slot boundaries tracked in RTP-timestamp space.
90
+
91
+ Usage::
92
+
93
+ clk = SlotClock(cadence_sec=15.0, sample_rate=12000)
94
+ clk.anchor(rtp_timestamp=first_rtp, utc=rtp_to_utc(first_rtp, ci))
95
+ ...
96
+ # as packets arrive, advance the high-water mark and harvest slots:
97
+ for slot in clk.advance(latest_rtp_timestamp):
98
+ samples = ring.extract_rtp(slot.start_rtp, slot.n_samples)
99
+ ...
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ cadence_sec: float,
105
+ sample_rate: int,
106
+ settle_sec: float = 1.5,
107
+ ) -> None:
108
+ cadence_samples = cadence_sec * sample_rate
109
+ if abs(cadence_samples - round(cadence_samples)) > 1e-6:
110
+ raise ValueError(
111
+ f"cadence_sec * sample_rate must be an integer sample count; "
112
+ f"got {cadence_sec} * {sample_rate} = {cadence_samples}"
113
+ )
114
+ self.cadence_sec = float(cadence_sec)
115
+ self.sample_rate = int(sample_rate)
116
+ self.cadence_samples = int(round(cadence_samples))
117
+ self.settle_samples = int(round(settle_sec * sample_rate))
118
+
119
+ self._anchor_rtp: Optional[int] = None
120
+ self._anchor_utc: Optional[float] = None
121
+ # Absolute (unwrapped) RTP position of the most recent boundary we
122
+ # have already emitted, as an offset in samples from the anchor.
123
+ self._next_boundary_off: Optional[int] = None
124
+ self._next_index: int = 0
125
+ # Monotonic high-water unwrapped offset from the anchor. Every
126
+ # ``offset_of_rtp`` unwraps relative to this (never the bare anchor),
127
+ # so positions stay correct past the 2**31-sample signed-32 window.
128
+ self._latest_off: int = 0
129
+
130
+ # ── anchoring ────────────────────────────────────────────────────
131
+
132
+ @property
133
+ def anchored(self) -> bool:
134
+ return self._anchor_rtp is not None
135
+
136
+ def reset(self) -> None:
137
+ """Drop the anchor so the next ``anchor()`` re-establishes the grid.
138
+
139
+ The monotonic slot index is preserved. Callers that key a buffer by
140
+ ``offset_of_rtp`` MUST also discard that buffer, since offsets are
141
+ relative to the (now-cleared) anchor.
142
+ """
143
+ self._anchor_rtp = None
144
+ self._anchor_utc = None
145
+ self._next_boundary_off = None
146
+ self._latest_off = 0
147
+
148
+ def anchor(self, rtp_timestamp: int, utc: float) -> None:
149
+ """Pin (rtp_timestamp -> utc). Call once; call again to re-anchor.
150
+
151
+ ``utc`` should come from ``ka9q.rtp_to_utc(rtp_timestamp, ci)``
152
+ so the whole grid is RTP/GPS-referenced. Re-anchoring preserves the
153
+ monotonic slot index but recomputes the first upcoming boundary, so a
154
+ corrected anchor takes effect on the next clean boundary.
155
+ """
156
+ self._anchor_rtp = int(rtp_timestamp) & 0xFFFFFFFF
157
+ self._anchor_utc = float(utc)
158
+ # The anchor IS offset 0; the unwrap high-water restarts here.
159
+ self._latest_off = 0
160
+ # First epoch-aligned boundary at or after the anchor instant.
161
+ t0 = math.ceil(self._anchor_utc / self.cadence_sec) * self.cadence_sec
162
+ self._next_boundary_off = int(round((t0 - self._anchor_utc) * self.sample_rate))
163
+ logger.info(
164
+ "SlotClock(cadence=%.3fs, sr=%d): anchored rtp=%d utc=%.3f; "
165
+ "first boundary at utc=%.3f (+%d samples)",
166
+ self.cadence_sec, self.sample_rate, self._anchor_rtp,
167
+ self._anchor_utc, t0, self._next_boundary_off,
168
+ )
169
+
170
+ # ── projection helpers ───────────────────────────────────────────
171
+
172
+ def utc_of_offset(self, sample_off: int) -> float:
173
+ """UTC of the sample ``sample_off`` samples after the anchor."""
174
+ assert self._anchor_utc is not None
175
+ return self._anchor_utc + sample_off / self.sample_rate
176
+
177
+ def rtp_of_offset(self, sample_off: int) -> int:
178
+ """Wrapped 32-bit RTP timestamp ``sample_off`` samples after anchor."""
179
+ assert self._anchor_rtp is not None
180
+ return (self._anchor_rtp + sample_off) & 0xFFFFFFFF
181
+
182
+ def offset_of_rtp(self, rtp_timestamp: int) -> int:
183
+ """Unwrapped 64-bit sample offset from the anchor for a wrapped RTP ts.
184
+
185
+ Unwraps relative to the monotonic high-water (``_latest_off``), not the
186
+ bare anchor, so the result stays correct once the stream is more than
187
+ 2**31 samples past the anchor. Callers query this only for timestamps
188
+ near the stream's leading edge (the latest timestamp, or a boundary
189
+ within the last slot), which are always within one signed-32 window of
190
+ the high-water — so the unwrap is unambiguous. Advances the high-water
191
+ when a query is ahead of it; never rewinds it on a stale/past query.
192
+ """
193
+ assert self._anchor_rtp is not None
194
+ ref_rtp = (self._anchor_rtp + self._latest_off) & 0xFFFFFFFF
195
+ off = self._latest_off + rtp_diff(rtp_timestamp, ref_rtp)
196
+ if off > self._latest_off:
197
+ self._latest_off = off
198
+ return off
199
+
200
+ # ── slot harvesting ──────────────────────────────────────────────
201
+
202
+ def advance(self, latest_rtp_timestamp: int) -> List[Slot]:
203
+ """Return every slot that has fully arrived as of ``latest_rtp``.
204
+
205
+ ``latest_rtp_timestamp`` is the RTP timestamp just past the newest
206
+ sample the caller holds (i.e. first_rtp + samples_buffered). A slot
207
+ is complete once latest_rtp has passed slot_end + settle. Boundaries
208
+ step by exact integer ``cadence_samples`` so no drift accumulates.
209
+ """
210
+ if self._anchor_rtp is None or self._next_boundary_off is None:
211
+ return []
212
+ latest_off = self.offset_of_rtp(latest_rtp_timestamp)
213
+ out: List[Slot] = []
214
+ while latest_off >= (
215
+ self._next_boundary_off + self.cadence_samples + self.settle_samples
216
+ ):
217
+ start_off = self._next_boundary_off
218
+ out.append(
219
+ Slot(
220
+ index=self._next_index,
221
+ start_rtp=self.rtp_of_offset(start_off),
222
+ start_utc=self.utc_of_offset(start_off),
223
+ n_samples=self.cadence_samples,
224
+ )
225
+ )
226
+ self._next_boundary_off = start_off + self.cadence_samples
227
+ self._next_index += 1
228
+ return out
229
+
230
+ # ── RTP-reference re-validation ──────────────────────────────────
231
+
232
+ def divergence_sec(self, channel_info, rtp_to_utc) -> Optional[float]:
233
+ """Grid-vs-GPS divergence at the next boundary, in seconds.
234
+
235
+ Recomputes the next boundary's true UTC straight from radiod's
236
+ (StatusListener-refreshed) ``channel_info`` via ``rtp_to_utc``
237
+ and compares it to the grid projection. A sustained nonzero result
238
+ means the anchor is stale/wrong — the caller should ``anchor()``
239
+ again off the fresh reference. Returns None if it can't be computed.
240
+
241
+ ``rtp_to_utc`` is passed in (rather than imported) so the caller binds
242
+ the exact projector it anchored with; ``ka9q.rtp_to_wallclock`` works
243
+ too as a deprecated alias of the same function.
244
+ """
245
+ if self._anchor_rtp is None or self._next_boundary_off is None:
246
+ return None
247
+ boundary_rtp = self.rtp_of_offset(self._next_boundary_off)
248
+ projected = self.utc_of_offset(self._next_boundary_off)
249
+ try:
250
+ ref = rtp_to_utc(
251
+ boundary_rtp, channel_info, wallclock_hint_sec=projected,
252
+ )
253
+ except Exception as exc: # noqa: BLE001 — detection must not crash audio
254
+ logger.debug("SlotClock divergence check raised: %s", exc)
255
+ return None
256
+ if ref is None:
257
+ return None
258
+ return projected - ref
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ka9q-python
3
- Version: 3.17.0
3
+ Version: 3.18.0
4
4
  Summary: Python interface for ka9q-radio control and monitoring
5
5
  Author-email: Michael Hauan AC0G <ac0g@hauan.org>
6
6
  License: MIT
@@ -49,6 +49,7 @@ ka9q/multi_stream.py
49
49
  ka9q/pps_calibrator.py
50
50
  ka9q/resequencer.py
51
51
  ka9q/rtp_recorder.py
52
+ ka9q/slot_clock.py
52
53
  ka9q/spectrum_stream.py
53
54
  ka9q/status.py
54
55
  ka9q/status_listener.py
@@ -94,6 +95,7 @@ tests/test_protocol_compat.py
94
95
  tests/test_remove_channel.py
95
96
  tests/test_rtp_recorder.py
96
97
  tests/test_security_features.py
98
+ tests/test_slot_clock.py
97
99
  tests/test_spectrum.py
98
100
  tests/test_ssrc_dest_unit.py
99
101
  tests/test_ssrc_encoding_unit.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ka9q-python"
7
- version = "3.17.0"
7
+ version = "3.18.0"
8
8
  description = "Python interface for ka9q-radio control and monitoring"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -0,0 +1,145 @@
1
+ """Unit tests for ka9q.slot_clock.SlotClock."""
2
+ import math
3
+
4
+ import pytest
5
+
6
+ from ka9q.slot_clock import SlotClock, Slot, rtp_diff
7
+
8
+
9
+ SR = 12000
10
+
11
+
12
+ def test_rtp_diff_wrap():
13
+ assert rtp_diff(10, 5) == 5
14
+ assert rtp_diff(5, 10) == -5
15
+ # wrap: 2 just past 0xFFFFFFFE
16
+ assert rtp_diff(2, 0xFFFFFFFE) == 4
17
+ assert rtp_diff(0xFFFFFFFE, 2) == -4
18
+
19
+
20
+ def test_rejects_non_integer_cadence():
21
+ # 7.4 s * 12000 = 88800 ok; 7.5*12000=90000 ok; but 0.0001 off must fail
22
+ SlotClock(7.5, SR) # ok (90000 samples)
23
+ SlotClock(15.0, SR) # ok
24
+ SlotClock(120.0, SR) # ok
25
+ with pytest.raises(ValueError):
26
+ SlotClock(15.000001, SR)
27
+
28
+
29
+ def test_cadence_samples():
30
+ assert SlotClock(15.0, SR).cadence_samples == 180000
31
+ assert SlotClock(7.5, SR).cadence_samples == 90000
32
+ assert SlotClock(120.0, SR).cadence_samples == 1440000
33
+
34
+
35
+ def test_boundaries_epoch_aligned():
36
+ """First boundary lands on a cadence multiple of UTC, regardless of where
37
+ the anchor falls within a slot."""
38
+ clk = SlotClock(15.0, SR, settle_sec=0.0)
39
+ # anchor at an awkward 7.3 s into a slot: utc = 1000.000 ... pick utc not
40
+ # on a 15 s grid point.
41
+ anchor_utc = 1_000_000_007.3
42
+ clk.anchor(rtp_timestamp=0, utc=anchor_utc)
43
+ # next boundary utc must be a multiple of 15
44
+ boundary_utc = clk.utc_of_offset(clk._next_boundary_off)
45
+ assert abs((boundary_utc % 15.0)) < 1e-3 or abs((boundary_utc % 15.0) - 15.0) < 1e-3
46
+
47
+
48
+ def test_advance_yields_aligned_slots_no_drift():
49
+ clk = SlotClock(15.0, SR, settle_sec=1.5)
50
+ # anchor exactly on a grid point for easy reasoning
51
+ clk.anchor(rtp_timestamp=1000, utc=1_000_000_005.0) # 005 -> next boundary 015
52
+ # feed latest rtp far enough to complete several slots
53
+ # boundary0 offset = (15-5)*12000 = 120000 ; +cadence+settle to complete
54
+ slots = []
55
+ # advance in steps to simulate streaming
56
+ for secs in range(11, 80, 1):
57
+ latest_rtp = (1000 + secs * SR) & 0xFFFFFFFF
58
+ slots.extend(clk.advance(latest_rtp))
59
+ assert len(slots) >= 3
60
+ # every slot start_utc is a multiple of 15, and consecutive starts differ
61
+ # by EXACTLY cadence_samples (no float drift)
62
+ for s in slots:
63
+ assert abs(s.start_utc % 15.0) < 1e-3 or abs(s.start_utc % 15.0 - 15.0) < 1e-3
64
+ assert s.n_samples == 180000
65
+ for a, b in zip(slots, slots[1:]):
66
+ assert b.index == a.index + 1
67
+ # start_utc advances by exactly 15 s
68
+ assert abs((b.start_utc - a.start_utc) - 15.0) < 1e-9
69
+
70
+
71
+ def test_advance_handles_rtp_wrap():
72
+ clk = SlotClock(15.0, SR, settle_sec=0.5)
73
+ # anchor near the 32-bit wrap
74
+ base = 0xFFFFFFFF - 5 * SR # ~5 s before wrap
75
+ clk.anchor(rtp_timestamp=base & 0xFFFFFFFF, utc=1_000_000_000.0)
76
+ got = []
77
+ for secs in range(1, 60):
78
+ latest = (base + secs * SR) & 0xFFFFFFFF
79
+ got.extend(clk.advance(latest))
80
+ assert len(got) >= 2
81
+ for a, b in zip(got, got[1:]):
82
+ assert b.index == a.index + 1
83
+ assert abs((b.start_utc - a.start_utc) - 15.0) < 1e-9
84
+
85
+
86
+ def test_offset_of_rtp_roundtrip():
87
+ clk = SlotClock(7.5, SR)
88
+ clk.anchor(rtp_timestamp=12345, utc=1_000.0)
89
+ off = 90000 * 3 + 17
90
+ rtp = clk.rtp_of_offset(off)
91
+ assert clk.offset_of_rtp(rtp) == off
92
+
93
+
94
+ def test_long_run_past_signed32_window_keeps_harvesting():
95
+ """Regression: a stream running past 2**31 samples from the anchor must
96
+ keep harvesting slots. A bare anchor-relative signed-32 diff aliases at
97
+ ~49.7 h @ 12 kHz and silently stops `advance` (real RF, no slots -> 0
98
+ decodes); the high-water unwrap must carry through it.
99
+
100
+ Steps the leading edge in 1-hour jumps (43.2M samples << 2**31, so each
101
+ individual unwrap is unambiguous) across the ~49.7 h boundary out to 55 h.
102
+ """
103
+ clk = SlotClock(15.0, SR, settle_sec=1.5)
104
+ clk.anchor(rtp_timestamp=0, utc=900_000_000.0) # multiple of 15 -> boundary0 at offset 0
105
+ samples_per_hour = 3600 * SR # 43,200,000 (< 2**31 = 2,147,483,648)
106
+ boundary_hour = (2 ** 31) / samples_per_hour # ~49.7 h
107
+ last_index = -1
108
+ crossed = False
109
+ for hour in range(1, 56):
110
+ latest_off = hour * samples_per_hour
111
+ latest_rtp = latest_off & 0xFFFFFFFF
112
+ for s in clk.advance(latest_rtp):
113
+ # contiguous, monotonic indices and exact 15 s grid throughout
114
+ assert s.index == last_index + 1
115
+ assert abs(s.start_utc - (900_000_000.0 + s.index * 15.0)) < 1e-3
116
+ last_index = s.index
117
+ if hour > boundary_hour:
118
+ crossed = True
119
+ # still producing slots well past the old-bug cutoff
120
+ assert last_index > int(boundary_hour * 3600 / 15)
121
+ assert crossed
122
+ # 55 h / 15 s ≈ 13200 slots; we should be near the end, not stalled early
123
+ assert last_index > 13000
124
+
125
+
126
+ def test_offset_of_rtp_unwraps_past_window():
127
+ """offset_of_rtp must return the true 64-bit offset past 2**31, not alias."""
128
+ clk = SlotClock(15.0, SR)
129
+ clk.anchor(rtp_timestamp=7, utc=900_000_000.0)
130
+ # walk the high-water forward in sub-2**31 steps to 3 billion samples
131
+ step = 2 ** 30 # 1,073,741,824
132
+ off = 0
133
+ for _ in range(3):
134
+ off += step
135
+ assert clk.offset_of_rtp((7 + off) & 0xFFFFFFFF) == off
136
+ assert off > 2 ** 31 # we genuinely crossed the signed-32 boundary
137
+
138
+
139
+ def test_settle_delays_completion():
140
+ clk = SlotClock(15.0, SR, settle_sec=2.0)
141
+ clk.anchor(rtp_timestamp=0, utc=900_000_000.0) # 900000000 % 15 == 0 -> boundary0 at offset 0
142
+ # slot0 spans [0,180000); completes only after 180000 + settle(24000)
143
+ assert clk.advance((180000 + 24000 - 1) & 0xFFFFFFFF) == []
144
+ slots = clk.advance((180000 + 24000 + 1) & 0xFFFFFFFF)
145
+ assert len(slots) == 1 and slots[0].index == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes