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.
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/CHANGELOG.md +27 -0
- {ka9q_python-3.17.0/ka9q_python.egg-info → ka9q_python-3.18.0}/PKG-INFO +1 -1
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/__init__.py +6 -0
- ka9q_python-3.18.0/ka9q/slot_clock.py +258 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/SOURCES.txt +2 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/pyproject.toml +1 -1
- ka9q_python-3.18.0/tests/test_slot_clock.py +145 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/LICENSE +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/MANIFEST.in +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/README.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/REQUIREMENTS.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/tune.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/_multicast.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/compat.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/control.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/multi_stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/rtp_recorder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/status.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/status_listener.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/types.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/ka9q_radio_compat +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/setup.cfg +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/__init__.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/conftest.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_client_id_destination.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multicast_helpers.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multistream_gap_storm.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_multistream_prune.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_parse_rtp_samples_iq.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_rtp_recorder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_status_listener.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.17.0 → ka9q_python-3.18.0}/tests/test_tune_method.py +0 -0
- {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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|