ka9q-python 3.13.0__tar.gz → 3.14.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.13.0 → ka9q_python-3.14.0}/CHANGELOG.md +48 -0
- {ka9q_python-3.13.0/ka9q_python.egg-info → ka9q_python-3.14.0}/PKG-INFO +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/__init__.py +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/compat.py +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/control.py +48 -6
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/multi_stream.py +14 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/rtp_recorder.py +49 -11
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/types.py +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.0/ka9q_python.egg-info}/PKG-INFO +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/SOURCES.txt +1 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q_radio_compat +1 -1
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/pyproject.toml +1 -1
- ka9q_python-3.14.0/tests/test_client_id_destination.py +150 -0
- ka9q_python-3.14.0/tests/test_rtp_recorder.py +120 -0
- ka9q_python-3.13.0/tests/test_rtp_recorder.py +0 -41
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/LICENSE +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/MANIFEST.in +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/README.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/API_REFERENCE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/ARCHITECTURE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/CLI_GUIDE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/GETTING_STARTED.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/INSTALLATION.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/MULTI_STREAM.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/RECIPES.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/RTP_TIMING_SUPPORT.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/SECURITY.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/TESTING_GUIDE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/docs/TUI_GUIDE.md +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/advanced_features_demo.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/channel_cleanup_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/codar_oceanography.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/diagnostics/diagnose_packets.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/diagnostics/repro_utc_bug.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/discover_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/grape_integration_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/hf_band_scanner.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/multi_stream_smoke.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/rtp_recorder_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/simple_am_radio.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/spectrum_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/stream_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/superdarn_recorder.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/test_channel_operations.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/test_improvements.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/test_timing_fields.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/tune.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/examples/tune_example.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/addressing.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/cli.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/discovery.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/exceptions.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/managed_stream.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/monitor.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/pps_calibrator.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/resequencer.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/spectrum_stream.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/status.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/stream.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/stream_quality.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/tui.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q/utils.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/dependency_links.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/entry_points.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/requires.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/ka9q_python.egg-info/top_level.txt +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/scripts/check_upstream_drift.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/scripts/sync_types.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/setup.cfg +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/setup.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/__init__.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/conftest.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_addressing.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_channel_verification.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_create_split_encoding.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_decode_description.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_decode_functions.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_encode_functions.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_encode_socket.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_ensure_channel_encoding.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_filter_edges.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_integration.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_iq_20khz_f32.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_lifetime.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_listen_multicast.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_managed_stream_recovery.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_monitor.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_multihomed.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_native_discovery.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_performance_fixes.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_protocol_compat.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_remove_channel.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_security_features.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_spectrum.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_ssrc_dest_unit.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_ssrc_encoding_unit.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_ssrc_radiod_host_unit.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_status_decoder.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_ttl_warning.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_tune.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_tune_cli.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_tune_debug.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_tune_live.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_tune_method.py +0 -0
- {ka9q_python-3.13.0 → ka9q_python-3.14.0}/tests/test_upstream_drift.py +0 -0
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.14.0] - 2026-05-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`RadiodControl(client_id=...)` for per-client deterministic multicast
|
|
8
|
+
destinations.** Closes the CONTRACT v0.3 §7 gap where the spec said
|
|
9
|
+
"ka9q-python derives the multicast destination" but the implementation
|
|
10
|
+
never did, so every client on a given radiod landed on radiod's
|
|
11
|
+
config-file default group. Two clients with no operator action are now
|
|
12
|
+
guaranteed to use distinct multicast addresses without per-client
|
|
13
|
+
derivation code.
|
|
14
|
+
- New `client_id: Optional[str] = None` kwarg on
|
|
15
|
+
`RadiodControl.__init__`. When set, `ensure_channel(destination=None)`
|
|
16
|
+
auto-derives a `239.x.y.z` address via
|
|
17
|
+
`generate_multicast_ip(client_id, radiod_host=self.status_address)`.
|
|
18
|
+
- Destination precedence inside `ensure_channel`:
|
|
19
|
+
`(1) explicit destination=` >
|
|
20
|
+
`(2) derived from (client_id, status_address)` >
|
|
21
|
+
`(3) None → radiod's config default`.
|
|
22
|
+
- Multi-radiod handling falls out of the hash: a `psk-recorder` instance
|
|
23
|
+
pointing at `bee1-hf-status.local` derives a different address from
|
|
24
|
+
one pointing at `bee3-hf-status.local`.
|
|
25
|
+
- Multi-client handling falls out of the hash: `psk-recorder` and
|
|
26
|
+
`wspr-recorder` on the same radiod derive different addresses.
|
|
27
|
+
- `MultiStream._attempt_restore` inherits the behavior because it reuses
|
|
28
|
+
the same `RadiodControl` instance (and thus the same `client_id`).
|
|
29
|
+
Restoration after a radiod restart re-creates each channel on the
|
|
30
|
+
same per-client multicast group it was on before.
|
|
31
|
+
- Because `allocate_ssrc` already hashes `destination` into the SSRC,
|
|
32
|
+
per-client destinations produce per-client SSRCs — radiod's channel
|
|
33
|
+
table cleanly separates concurrent clients.
|
|
34
|
+
|
|
35
|
+
### Backward Compatibility
|
|
36
|
+
|
|
37
|
+
- Default behavior unchanged: `RadiodControl(status)` without `client_id`
|
|
38
|
+
preserves pre-3.14 semantics — `destination=None` flows through, radiod
|
|
39
|
+
uses its config-file default, every channel from that radiod shares one
|
|
40
|
+
multicast group. Clients opt in by passing `client_id="<name>"`.
|
|
41
|
+
|
|
42
|
+
### Tests
|
|
43
|
+
|
|
44
|
+
- 9 new unit tests in `tests/test_client_id_destination.py` cover:
|
|
45
|
+
client_id default-None / set / stored; precedence (explicit wins over
|
|
46
|
+
derived, no client_id → None); uniqueness invariants (same client on
|
|
47
|
+
two radiods → distinct; two clients on one radiod → distinct;
|
|
48
|
+
same client + same radiod → repeatable); SSRC divergence per client.
|
|
49
|
+
134 offline unit tests pass.
|
|
50
|
+
|
|
3
51
|
## [3.13.0] - 2026-05-08
|
|
4
52
|
|
|
5
53
|
### Added
|
|
@@ -691,18 +691,35 @@ class RadiodControl:
|
|
|
691
691
|
"""
|
|
692
692
|
|
|
693
693
|
def __init__(self, status_address: str, max_commands_per_sec: int = 100,
|
|
694
|
-
interface: Optional[str] = None
|
|
694
|
+
interface: Optional[str] = None,
|
|
695
|
+
client_id: Optional[str] = None):
|
|
695
696
|
"""
|
|
696
697
|
Initialize radiod control
|
|
697
|
-
|
|
698
|
+
|
|
698
699
|
Args:
|
|
699
700
|
status_address: mDNS name or IP:port of radiod status stream
|
|
700
701
|
max_commands_per_sec: Maximum commands per second (rate limiting)
|
|
701
702
|
interface: IP address of network interface for multicast (e.g., '192.168.1.100').
|
|
702
703
|
Required on multi-homed systems. If None, uses INADDR_ANY (0.0.0.0).
|
|
704
|
+
client_id: Identity string for this client application
|
|
705
|
+
(e.g. ``"psk-recorder"``, ``"hf-timestd"``). When set,
|
|
706
|
+
:py:meth:`ensure_channel` derives a per-(client, radiod)
|
|
707
|
+
multicast destination via
|
|
708
|
+
:py:func:`ka9q.generate_multicast_ip` whenever the
|
|
709
|
+
caller does not supply an explicit ``destination=``.
|
|
710
|
+
Same client_id + same radiod -> same destination
|
|
711
|
+
(all of that client's channels share one multicast
|
|
712
|
+
group); different client_id OR different radiod ->
|
|
713
|
+
distinct destinations (peer clients on one station
|
|
714
|
+
never collide; one client targeting two radiods
|
|
715
|
+
sees one destination per radiod). ``None`` (default)
|
|
716
|
+
preserves pre-3.14 behavior: when no ``destination=``
|
|
717
|
+
is passed, radiod uses its config-file default and
|
|
718
|
+
every client lands on the same group.
|
|
703
719
|
"""
|
|
704
720
|
self.status_address = status_address
|
|
705
721
|
self.interface = interface
|
|
722
|
+
self.client_id = client_id
|
|
706
723
|
self.socket = None
|
|
707
724
|
self._status_sock = None # Cached status listener socket for tune()
|
|
708
725
|
self._status_sock_lock = None # Will be initialized when needed
|
|
@@ -1363,8 +1380,16 @@ class RadiodControl:
|
|
|
1363
1380
|
agc_enable: Enable automatic gain control (0=off, 1=on, default: 0)
|
|
1364
1381
|
gain: Manual gain in dB (default: 0.0). Only used when agc_enable=0
|
|
1365
1382
|
destination: RTP destination multicast address (optional).
|
|
1366
|
-
|
|
1367
|
-
|
|
1383
|
+
Precedence: (1) explicit ``destination=`` wins;
|
|
1384
|
+
(2) otherwise if the RadiodControl was constructed
|
|
1385
|
+
with ``client_id=``, this method derives a
|
|
1386
|
+
deterministic ``239.x.y.z`` address from the
|
|
1387
|
+
(client_id, status_address) pair so each peer
|
|
1388
|
+
client on a station gets its own multicast group;
|
|
1389
|
+
(3) otherwise (``destination=None`` and no
|
|
1390
|
+
``client_id``), radiod's config-file default is
|
|
1391
|
+
used. The resolved address becomes part of the
|
|
1392
|
+
channel identity (SSRC).
|
|
1368
1393
|
encoding: Output encoding (0=none, 4=F32, etc.) - see Encoding class
|
|
1369
1394
|
timeout: Maximum time to wait for channel verification (default: 5.0s)
|
|
1370
1395
|
frequency_tolerance: Acceptable frequency deviation in Hz (default: 1.0)
|
|
@@ -1418,15 +1443,32 @@ class RadiodControl:
|
|
|
1418
1443
|
applications requesting the same parameters will share the same
|
|
1419
1444
|
channel, reducing radiod resource usage.
|
|
1420
1445
|
"""
|
|
1446
|
+
from .addressing import generate_multicast_ip
|
|
1421
1447
|
from .discovery import ChannelInfo, discover_channels
|
|
1422
|
-
|
|
1448
|
+
|
|
1423
1449
|
# Validate inputs
|
|
1424
1450
|
_validate_frequency(frequency_hz)
|
|
1425
1451
|
_validate_sample_rate(sample_rate)
|
|
1426
1452
|
_validate_gain(gain)
|
|
1427
1453
|
_validate_preset(preset)
|
|
1428
1454
|
_validate_timeout(timeout)
|
|
1429
|
-
|
|
1455
|
+
|
|
1456
|
+
# CONTRACT v0.3 §7: when the RadiodControl was constructed with a
|
|
1457
|
+
# client_id and the caller didn't pass an explicit destination,
|
|
1458
|
+
# derive a deterministic per-(client, radiod) multicast address.
|
|
1459
|
+
# This makes peer clients on the same host land on distinct
|
|
1460
|
+
# multicast groups without per-client derivation code. An
|
|
1461
|
+
# explicit destination= still wins (e.g. operator override).
|
|
1462
|
+
if destination is None and self.client_id:
|
|
1463
|
+
destination = generate_multicast_ip(
|
|
1464
|
+
unique_id=self.client_id,
|
|
1465
|
+
radiod_host=self.status_address,
|
|
1466
|
+
)
|
|
1467
|
+
logger.debug(
|
|
1468
|
+
"ensure_channel: derived destination=%s for client_id=%r "
|
|
1469
|
+
"radiod=%r", destination, self.client_id, self.status_address,
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1430
1472
|
# Compute deterministic SSRC from parameters (including radiod identity)
|
|
1431
1473
|
ssrc = allocate_ssrc(
|
|
1432
1474
|
frequency_hz=frequency_hz,
|
|
@@ -134,6 +134,7 @@ class MultiStream:
|
|
|
134
134
|
high_edge: Optional[float] = None,
|
|
135
135
|
kaiser_beta: Optional[float] = None,
|
|
136
136
|
lifetime: Optional[int] = None,
|
|
137
|
+
timeout: float = 5.0,
|
|
137
138
|
) -> ChannelInfo:
|
|
138
139
|
"""Provision a channel and register it for reception.
|
|
139
140
|
|
|
@@ -153,6 +154,18 @@ class MultiStream:
|
|
|
153
154
|
``set_channel_lifetime()`` (or by calling
|
|
154
155
|
RadiodControl.set_channel_lifetime directly on the SSRC).
|
|
155
156
|
|
|
157
|
+
``timeout`` is forwarded to ``ensure_channel`` and bounds the
|
|
158
|
+
wait for radiod's status-message ACK confirming the channel
|
|
159
|
+
was created. The default 5 s is fine for an idle radiod, but
|
|
160
|
+
when many producers register channels in quick succession
|
|
161
|
+
(e.g. a multi-client deployment restarting) radiod can stall
|
|
162
|
+
long enough that 5 s isn't enough — observed on bee1 2026-05-08
|
|
163
|
+
as the T6 BPSK PPS channel timing out and crashing
|
|
164
|
+
timestd-core-recorder, which in turn left TSL3 SHM stale.
|
|
165
|
+
Callers in latency-sensitive setups should pass a longer
|
|
166
|
+
value (10-30 s) so a brief radiod-busy window doesn't fail
|
|
167
|
+
the whole channel registration.
|
|
168
|
+
|
|
156
169
|
Returns the ChannelInfo from ensure_channel().
|
|
157
170
|
"""
|
|
158
171
|
channel_info = self._control.ensure_channel(
|
|
@@ -166,6 +179,7 @@ class MultiStream:
|
|
|
166
179
|
high_edge=high_edge,
|
|
167
180
|
kaiser_beta=kaiser_beta,
|
|
168
181
|
lifetime=lifetime,
|
|
182
|
+
timeout=timeout,
|
|
169
183
|
)
|
|
170
184
|
|
|
171
185
|
addr = channel_info.multicast_address
|
|
@@ -127,34 +127,72 @@ def parse_rtp_header(data: bytes) -> Optional[RTPHeader]:
|
|
|
127
127
|
def rtp_to_wallclock(rtp_timestamp: int, channel: ChannelInfo) -> Optional[float]:
|
|
128
128
|
"""
|
|
129
129
|
Convert RTP timestamp to Unix wall-clock time
|
|
130
|
-
|
|
130
|
+
|
|
131
131
|
Uses the GPS_TIME/RTP_TIMESNAP timing information from radiod.
|
|
132
|
-
|
|
132
|
+
|
|
133
133
|
Args:
|
|
134
134
|
rtp_timestamp: RTP timestamp from packet header
|
|
135
135
|
channel: ChannelInfo with gps_time, rtp_timesnap, sample_rate
|
|
136
|
-
|
|
136
|
+
|
|
137
137
|
Returns:
|
|
138
138
|
Unix timestamp (seconds) or None if timing info unavailable
|
|
139
|
+
|
|
140
|
+
Wraparound handling:
|
|
141
|
+
RTP timestamps are 32-bit and wrap every 2**32 samples. At
|
|
142
|
+
96 kHz that's 12.43 hours; at 16 kHz it's 74.6 hours. The
|
|
143
|
+
``rtp_timesnap``/``gps_time`` pair is captured once at SSRC
|
|
144
|
+
discovery, so an SSRC alive longer than one wrap period is
|
|
145
|
+
in a *later* RTP epoch than the snapshot — the naive signed
|
|
146
|
+
32-bit subtraction is correct only within ±2**31 samples
|
|
147
|
+
(~6 hours at 96 kHz) of the snapshot, beyond which it
|
|
148
|
+
silently aliases.
|
|
149
|
+
|
|
150
|
+
We disambiguate by picking the wrap-epoch count ``k`` (full
|
|
151
|
+
2**32-sample periods elapsed since the snapshot) that places
|
|
152
|
+
the resulting wall-clock time closest to the local system
|
|
153
|
+
clock. System clock stays within seconds of true UTC even
|
|
154
|
+
under hostile conditions, and the wrap ambiguity period is
|
|
155
|
+
hours, so this is robust without tight NTP discipline.
|
|
156
|
+
|
|
157
|
+
Observed on bee1 2026-05-08: long-running SSRCs caused TSL3
|
|
158
|
+
SHM samples stuck at the snapshot's wall-clock time, ~12.4 h
|
|
159
|
+
behind. Chrony filtered every sample and reach fell to 0.
|
|
139
160
|
"""
|
|
140
161
|
if channel.gps_time is None or channel.rtp_timesnap is None:
|
|
141
162
|
return None
|
|
142
|
-
|
|
163
|
+
|
|
143
164
|
# Convert GPS nanoseconds to Unix time
|
|
144
165
|
# GPS epoch is Jan 6, 1980; Unix epoch is Jan 1, 1970
|
|
145
166
|
# gps_time is nanoseconds since GPS epoch, so add GPS_UTC_OFFSET (in ns)
|
|
146
167
|
# AND subtract current GPS_LEAP_SECONDS (18s) to align with UTC
|
|
147
168
|
sender_time = channel.gps_time + BILLION * (GPS_UTC_OFFSET - GPS_LEAP_SECONDS)
|
|
148
|
-
|
|
149
|
-
#
|
|
150
|
-
#
|
|
169
|
+
|
|
170
|
+
# Signed 32-bit RTP delta — correct within ±2**31 samples of
|
|
171
|
+
# the snapshot.
|
|
151
172
|
rtp_delta = int((rtp_timestamp - channel.rtp_timesnap) & 0xFFFFFFFF)
|
|
152
173
|
if rtp_delta > 0x7FFFFFFF:
|
|
153
174
|
rtp_delta -= 0x100000000
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
|
|
176
|
+
base_wall_ns = sender_time + (BILLION * rtp_delta // channel.sample_rate)
|
|
177
|
+
|
|
178
|
+
# Adjust for wrap epochs. One period = 2**32 samples =
|
|
179
|
+
# BILLION * 2**32 // sample_rate ns. Pick the integer k that
|
|
180
|
+
# minimises |base_wall_ns + k*period - sys_now_ns| — i.e. the
|
|
181
|
+
# value closest to the system clock. Exact when sys clock is
|
|
182
|
+
# within ±period/2 of true UTC.
|
|
183
|
+
period_ns = BILLION * 0x100000000 // channel.sample_rate
|
|
184
|
+
sys_now_ns = int(time.time() * BILLION)
|
|
185
|
+
diff_ns = sys_now_ns - base_wall_ns
|
|
186
|
+
if period_ns > 0:
|
|
187
|
+
# Round-to-nearest of diff_ns / period_ns (Python `//` is
|
|
188
|
+
# floor; bias by half-period before flooring to round).
|
|
189
|
+
if diff_ns >= 0:
|
|
190
|
+
k = (diff_ns + period_ns // 2) // period_ns
|
|
191
|
+
else:
|
|
192
|
+
k = -(((-diff_ns) + period_ns // 2) // period_ns)
|
|
193
|
+
else:
|
|
194
|
+
k = 0
|
|
195
|
+
wall_time_ns = base_wall_ns + k * period_ns
|
|
158
196
|
|
|
159
197
|
# Apply L6 BPSK PPS chain-delay calibration if available.
|
|
160
198
|
# This corrects for the end-to-end RF→ADC→DSP→RTP latency
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
ka9q-radio protocol types and constants
|
|
3
3
|
|
|
4
4
|
Auto-generated by scripts/sync_types.py from ka9q-radio C headers.
|
|
5
|
-
Validated against ka9q-radio commit:
|
|
5
|
+
Validated against ka9q-radio commit: f78cff9cc8c7
|
|
6
6
|
|
|
7
7
|
DO NOT EDIT MANUALLY — run: python scripts/sync_types.py --apply
|
|
8
8
|
"""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""RadiodControl(client_id=...) destination-derivation tests.
|
|
2
|
+
|
|
3
|
+
When ``client_id`` is set, ``ensure_channel(destination=None)`` should
|
|
4
|
+
auto-derive a per-(client, radiod) multicast address so peer clients on
|
|
5
|
+
the same host land on distinct multicast groups without per-client
|
|
6
|
+
derivation code. Tests mock ``_connect`` and short-circuit
|
|
7
|
+
``create_channel`` to capture the resolved destination without
|
|
8
|
+
requiring a live radiod or running the verify loop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import unittest
|
|
14
|
+
from unittest.mock import patch
|
|
15
|
+
|
|
16
|
+
from ka9q.addressing import generate_multicast_ip
|
|
17
|
+
from ka9q.control import RadiodControl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ensure_dest(ctrl: RadiodControl, *, destination=None) -> object:
|
|
21
|
+
"""Drive ensure_channel to ``create_channel`` and return the
|
|
22
|
+
destination it would have received. ``create_channel`` raises to
|
|
23
|
+
short-circuit the post-create verify loop (which would otherwise
|
|
24
|
+
spin discover_channels for ``timeout`` seconds)."""
|
|
25
|
+
captured: dict = {}
|
|
26
|
+
|
|
27
|
+
def _capture(*_args, **kwargs):
|
|
28
|
+
captured['destination'] = kwargs.get('destination')
|
|
29
|
+
raise _ShortCircuit()
|
|
30
|
+
|
|
31
|
+
# ensure_channel re-imports discover_channels from ka9q.discovery
|
|
32
|
+
# *inside* the function body, so the patch target is the
|
|
33
|
+
# discovery module, not the control module's alias.
|
|
34
|
+
with patch('ka9q.discovery.discover_channels', return_value={}), \
|
|
35
|
+
patch.object(ctrl, 'create_channel', side_effect=_capture):
|
|
36
|
+
try:
|
|
37
|
+
ctrl.ensure_channel(
|
|
38
|
+
frequency_hz=14_074_000.0,
|
|
39
|
+
preset="iq",
|
|
40
|
+
sample_rate=16000,
|
|
41
|
+
destination=destination,
|
|
42
|
+
)
|
|
43
|
+
except _ShortCircuit:
|
|
44
|
+
pass
|
|
45
|
+
return captured.get('destination')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ShortCircuit(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestClientIdStored(unittest.TestCase):
|
|
53
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
54
|
+
def test_client_id_default_none(self, _c):
|
|
55
|
+
ctrl = RadiodControl("radiod.local")
|
|
56
|
+
self.assertIsNone(ctrl.client_id)
|
|
57
|
+
|
|
58
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
59
|
+
def test_client_id_stored(self, _c):
|
|
60
|
+
ctrl = RadiodControl("radiod.local", client_id="psk-recorder")
|
|
61
|
+
self.assertEqual(ctrl.client_id, "psk-recorder")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestDestinationPrecedence(unittest.TestCase):
|
|
65
|
+
"""ensure_channel's destination= resolution order:
|
|
66
|
+
explicit > derived (from client_id+status) > None.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
70
|
+
def test_explicit_destination_wins_over_client_id(self, _c):
|
|
71
|
+
ctrl = RadiodControl("radiod.local", client_id="psk-recorder")
|
|
72
|
+
dest = _ensure_dest(ctrl, destination="239.1.2.3")
|
|
73
|
+
self.assertEqual(dest, "239.1.2.3")
|
|
74
|
+
|
|
75
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
76
|
+
def test_no_client_id_keeps_destination_none(self, _c):
|
|
77
|
+
"""Pre-3.14 behavior preserved: no client_id, no explicit
|
|
78
|
+
destination -> destination=None flows through so radiod uses
|
|
79
|
+
its config-file default."""
|
|
80
|
+
ctrl = RadiodControl("radiod.local")
|
|
81
|
+
dest = _ensure_dest(ctrl, destination=None)
|
|
82
|
+
self.assertIsNone(dest)
|
|
83
|
+
|
|
84
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
85
|
+
def test_client_id_derives_destination(self, _c):
|
|
86
|
+
ctrl = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
87
|
+
expected = generate_multicast_ip(
|
|
88
|
+
"psk-recorder", radiod_host="bee1-hf-status.local",
|
|
89
|
+
)
|
|
90
|
+
dest = _ensure_dest(ctrl, destination=None)
|
|
91
|
+
self.assertEqual(dest, expected)
|
|
92
|
+
self.assertTrue(dest.startswith("239."))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestDestinationUniqueness(unittest.TestCase):
|
|
96
|
+
"""The two invariants the operator-facing design promises:
|
|
97
|
+
(a) same client on two radiods -> two distinct destinations
|
|
98
|
+
(b) two clients on one radiod -> two distinct destinations
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
102
|
+
def test_same_client_different_radiods(self, _c):
|
|
103
|
+
a = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
104
|
+
b = RadiodControl("bee3-hf-status.local", client_id="psk-recorder")
|
|
105
|
+
self.assertNotEqual(_ensure_dest(a), _ensure_dest(b),
|
|
106
|
+
"psk-recorder on radiod-0 vs radiod-1 must use "
|
|
107
|
+
"distinct multicast groups")
|
|
108
|
+
|
|
109
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
110
|
+
def test_different_clients_same_radiod(self, _c):
|
|
111
|
+
a = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
112
|
+
b = RadiodControl("bee1-hf-status.local", client_id="wspr-recorder")
|
|
113
|
+
self.assertNotEqual(_ensure_dest(a), _ensure_dest(b),
|
|
114
|
+
"psk-recorder and wspr-recorder on one radiod "
|
|
115
|
+
"must use distinct multicast groups")
|
|
116
|
+
|
|
117
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
118
|
+
def test_same_client_same_radiod_repeatable(self, _c):
|
|
119
|
+
"""Restart must bind to the same multicast group."""
|
|
120
|
+
a = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
121
|
+
b = RadiodControl("bee1-hf-status.local", client_id="psk-recorder")
|
|
122
|
+
self.assertEqual(_ensure_dest(a), _ensure_dest(b))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestDestinationParticipatesInSsrc(unittest.TestCase):
|
|
126
|
+
"""allocate_ssrc already hashes destination into the SSRC, so two
|
|
127
|
+
clients with derived destinations must produce different SSRCs
|
|
128
|
+
even for identical channel parameters."""
|
|
129
|
+
|
|
130
|
+
@patch('ka9q.control.RadiodControl._connect')
|
|
131
|
+
def test_ssrcs_diverge_per_client(self, _c):
|
|
132
|
+
from ka9q.control import allocate_ssrc
|
|
133
|
+
|
|
134
|
+
params = dict(frequency_hz=14_074_000.0, preset="iq",
|
|
135
|
+
sample_rate=16000, agc=False, gain=0.0)
|
|
136
|
+
psk_dest = generate_multicast_ip("psk-recorder",
|
|
137
|
+
radiod_host="bee1-hf-status.local")
|
|
138
|
+
wspr_dest = generate_multicast_ip("wspr-recorder",
|
|
139
|
+
radiod_host="bee1-hf-status.local")
|
|
140
|
+
psk_ssrc = allocate_ssrc(**params, destination=psk_dest,
|
|
141
|
+
radiod_host="bee1-hf-status.local")
|
|
142
|
+
wspr_ssrc = allocate_ssrc(**params, destination=wspr_dest,
|
|
143
|
+
radiod_host="bee1-hf-status.local")
|
|
144
|
+
self.assertNotEqual(psk_ssrc, wspr_ssrc,
|
|
145
|
+
"Per-client destination must produce per-client "
|
|
146
|
+
"SSRC, so radiod's channel table separates them")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
unittest.main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for RTP recorder functionality
|
|
3
|
+
"""
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from ka9q.rtp_recorder import rtp_to_wallclock
|
|
9
|
+
from ka9q.discovery import ChannelInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
GPS_UTC_OFFSET = 315964800
|
|
13
|
+
BILLION = 1_000_000_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _channel(sample_rate, gps_time_ns, rtp_timesnap):
|
|
17
|
+
return ChannelInfo(
|
|
18
|
+
ssrc=1234,
|
|
19
|
+
preset="test",
|
|
20
|
+
sample_rate=sample_rate,
|
|
21
|
+
frequency=100.0,
|
|
22
|
+
snr=0.0,
|
|
23
|
+
multicast_address="239.1.2.3",
|
|
24
|
+
port=5004,
|
|
25
|
+
gps_time=gps_time_ns,
|
|
26
|
+
rtp_timesnap=rtp_timesnap,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_rtp_to_wallclock_basic():
|
|
31
|
+
"""Same RTP as snapshot → exactly GPS+offset; +N samples → +N/sample_rate s."""
|
|
32
|
+
gps_time_ns = 1234567890000000000 # ~2019
|
|
33
|
+
channel = _channel(48000, gps_time_ns, 1000)
|
|
34
|
+
expected_sec = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
35
|
+
|
|
36
|
+
# Pin system clock close to the channel's wall-clock so the
|
|
37
|
+
# wrap-disambiguation picks epoch k=0 deterministically.
|
|
38
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected_sec):
|
|
39
|
+
assert rtp_to_wallclock(1000, channel) == pytest.approx(expected_sec)
|
|
40
|
+
assert rtp_to_wallclock(1000 + 48000, channel) == pytest.approx(expected_sec + 1.0)
|
|
41
|
+
# One sample later → +1/48000 s
|
|
42
|
+
assert rtp_to_wallclock(1001, channel) == pytest.approx(expected_sec + 1 / 48000.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_rtp_to_wallclock_signed_32bit_window():
|
|
46
|
+
"""RTP within ±2**31 samples of snapshot (the natively-correct window)
|
|
47
|
+
→ no wrap adjustment needed regardless of system clock."""
|
|
48
|
+
gps_time_ns = 1234567890000000000
|
|
49
|
+
channel = _channel(96000, gps_time_ns, 1000)
|
|
50
|
+
base_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
51
|
+
|
|
52
|
+
# 30 minutes ahead at 96 kHz = +30*60*96000 = 172_800_000 samples
|
|
53
|
+
target_rtp = (1000 + 172_800_000) & 0xFFFFFFFF
|
|
54
|
+
expected = base_wall + 30 * 60.0
|
|
55
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected):
|
|
56
|
+
assert rtp_to_wallclock(target_rtp, channel) == pytest.approx(expected)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_rtp_to_wallclock_wraps_correctly_after_one_period():
|
|
60
|
+
"""After one full RTP wrap (2**32 samples), naive subtraction aliases
|
|
61
|
+
to ~snapshot. System-clock disambiguation must return the right
|
|
62
|
+
wall-clock value one wrap later."""
|
|
63
|
+
sample_rate = 96000
|
|
64
|
+
gps_time_ns = 1234567890000000000
|
|
65
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
66
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
67
|
+
|
|
68
|
+
period_sec = 0x100000000 / sample_rate # 12.43 h
|
|
69
|
+
target_rtp = (1000 + 0x100000000) & 0xFFFFFFFF # exactly 1 wrap later
|
|
70
|
+
expected = snapshot_wall + period_sec
|
|
71
|
+
|
|
72
|
+
# System clock at the *expected* time — wrap counter k must be 1.
|
|
73
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected):
|
|
74
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
75
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_rtp_to_wallclock_wraps_correctly_after_two_periods():
|
|
79
|
+
"""Same idea, two full wraps (~24.86 h after snapshot at 96 kHz)."""
|
|
80
|
+
sample_rate = 96000
|
|
81
|
+
gps_time_ns = 1234567890000000000
|
|
82
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
83
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
84
|
+
|
|
85
|
+
period_sec = 0x100000000 / sample_rate
|
|
86
|
+
target_rtp = (1000 + 2 * 0x100000000 + 100) & 0xFFFFFFFF # 2 wraps + 100 samples
|
|
87
|
+
expected = snapshot_wall + 2 * period_sec + 100 / sample_rate
|
|
88
|
+
|
|
89
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=expected):
|
|
90
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
91
|
+
assert result == pytest.approx(expected, abs=1e-3)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_rtp_to_wallclock_disambiguator_tolerates_clock_skew():
|
|
95
|
+
"""System clock can be off by hours (NTP not yet locked, holdover,
|
|
96
|
+
operator skew) and the wrap disambiguator should still pick the
|
|
97
|
+
right epoch as long as skew is < period/2 (~6 h at 96 kHz)."""
|
|
98
|
+
sample_rate = 96000
|
|
99
|
+
gps_time_ns = 1234567890000000000
|
|
100
|
+
channel = _channel(sample_rate, gps_time_ns, 1000)
|
|
101
|
+
snapshot_wall = (gps_time_ns + BILLION * (GPS_UTC_OFFSET - 18)) / BILLION
|
|
102
|
+
|
|
103
|
+
period_sec = 0x100000000 / sample_rate
|
|
104
|
+
target_rtp = (1000 + 0x100000000 + 50_000_000) & 0xFFFFFFFF
|
|
105
|
+
true_wall = snapshot_wall + period_sec + 50_000_000 / sample_rate # 12.43 h + ~520 s
|
|
106
|
+
|
|
107
|
+
# System clock 4 hours off (within ±6 h tolerance window).
|
|
108
|
+
skewed_now = true_wall - 4 * 3600.0
|
|
109
|
+
with patch("ka9q.rtp_recorder.time.time", return_value=skewed_now):
|
|
110
|
+
result = rtp_to_wallclock(target_rtp, channel)
|
|
111
|
+
assert result == pytest.approx(true_wall, abs=1e-3)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_rtp_to_wallclock_returns_none_when_timing_missing():
|
|
115
|
+
channel = _channel(48000, None, None)
|
|
116
|
+
assert rtp_to_wallclock(1000, channel) is None
|
|
117
|
+
channel = _channel(48000, 1234567890000000000, None)
|
|
118
|
+
assert rtp_to_wallclock(1000, channel) is None
|
|
119
|
+
channel = _channel(48000, None, 1000)
|
|
120
|
+
assert rtp_to_wallclock(1000, channel) is None
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Tests for RTP recorder functionality
|
|
3
|
-
"""
|
|
4
|
-
import pytest
|
|
5
|
-
from ka9q.rtp_recorder import rtp_to_wallclock
|
|
6
|
-
from ka9q.discovery import ChannelInfo
|
|
7
|
-
|
|
8
|
-
def test_rtp_to_wallclock():
|
|
9
|
-
"""Test GPS time to Unix time conversion"""
|
|
10
|
-
# Channel info with mocked timing
|
|
11
|
-
# GPS Time: 1234567890000000000 ns (random recent-ish GPS time)
|
|
12
|
-
# This corresponds to some time around 2019
|
|
13
|
-
gps_time_ns = 1234567890000000000
|
|
14
|
-
|
|
15
|
-
# Constants from code
|
|
16
|
-
GPS_UTC_OFFSET = 315964800
|
|
17
|
-
BILLION = 1_000_000_000
|
|
18
|
-
|
|
19
|
-
channel = ChannelInfo(
|
|
20
|
-
ssrc=1234,
|
|
21
|
-
preset="test",
|
|
22
|
-
sample_rate=48000,
|
|
23
|
-
frequency=100.0,
|
|
24
|
-
snr=0.0,
|
|
25
|
-
multicast_address="239.1.2.3",
|
|
26
|
-
port=5004,
|
|
27
|
-
gps_time=gps_time_ns,
|
|
28
|
-
rtp_timesnap=1000
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
# Case 1: Same RTP timestamp as snapshot
|
|
32
|
-
# Result should be exactly GPS time + offset
|
|
33
|
-
# Expected Unix time = GPS time + Offset - Leap Seconds
|
|
34
|
-
expected_unix_ns = gps_time_ns + (BILLION * (GPS_UTC_OFFSET - 18))
|
|
35
|
-
expected_unix_sec = expected_unix_ns / BILLION
|
|
36
|
-
|
|
37
|
-
assert rtp_to_wallclock(1000, channel) == pytest.approx(expected_unix_sec)
|
|
38
|
-
|
|
39
|
-
# Case 2: One second later
|
|
40
|
-
# 48000 samples later
|
|
41
|
-
assert rtp_to_wallclock(1000 + 48000, channel) == pytest.approx(expected_unix_sec + 1.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
|