rdsclock 0.2.2__tar.gz → 0.4.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.
- {rdsclock-0.2.2 → rdsclock-0.4.0}/PKG-INFO +18 -9
- {rdsclock-0.2.2 → rdsclock-0.4.0}/README.md +17 -8
- {rdsclock-0.2.2 → rdsclock-0.4.0}/pyproject.toml +1 -1
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/__init__.py +1 -1
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/cli.py +39 -8
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/decoder.py +121 -20
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/dsp.py +55 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rds_blocks.py +192 -20
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rds_clock.py +13 -2
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rds_groups.py +16 -5
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/recon.py +22 -3
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/time_consensus.py +130 -1
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/PKG-INFO +18 -9
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/SOURCES.txt +1 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_coverage_edges.py +5 -3
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_coverage_extra.py +80 -7
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_decoder_synthetic.py +30 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_dsp.py +32 -0
- rdsclock-0.4.0/tests/test_pipeline_delay.py +26 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rds_blocks.py +104 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rds_clock.py +9 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rds_groups.py +8 -0
- rdsclock-0.4.0/tests/test_real_iq_regression.py +45 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_time_consensus.py +143 -0
- rdsclock-0.2.2/tests/test_real_iq_regression.py +0 -18
- {rdsclock-0.2.2 → rdsclock-0.4.0}/LICENSE +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/setup.cfg +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/__main__.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/audio.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/channelizer.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/plot.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/py.typed +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rtl_tcp.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/synth.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/dependency_links.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/entry_points.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/requires.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/top_level.txt +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_audio.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_channelizer.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_cli.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_cli_with_fake_sdr.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_plot.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_real_recordings.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_recon.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_recon_offline.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rtl_tcp.py +0 -0
- {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_synth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rdsclock
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments
|
|
5
5
|
Author-email: Mateusz Klatt <mateusz@klatt.ie>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -221,11 +221,17 @@ for environments where:
|
|
|
221
221
|
4. **RF fingerprinting** — per-station features (CFO, RSSI, PI) are
|
|
222
222
|
recorded for later analysis. Automated shift detection is on the
|
|
223
223
|
roadmap.
|
|
224
|
-
5. **
|
|
224
|
+
5. **Receive timestamping** — live captures anchor decoded Group 4A
|
|
225
|
+
receipt to the host monotonic clock and correct the bit-rate estimate
|
|
226
|
+
from the 19 kHz pilot tone. With healthy NTP this is a roughly
|
|
227
|
+
30-80 ms UTC claim; without internet, a 3+ station field demo should
|
|
228
|
+
be treated as roughly 100-250 ms. Hardware-grade timing requires a
|
|
229
|
+
hardware time source.
|
|
230
|
+
6. **Holdover** — between Clock-Time messages the receiver
|
|
225
231
|
extrapolates UTC from a local monotonic clock disciplined by an
|
|
226
232
|
estimated ppm drift. Uncertainty grows linearly with the age of
|
|
227
233
|
the most recent CT.
|
|
228
|
-
|
|
234
|
+
7. **Operator display** — `UTC 2026-05-17 04:23:18 ±2s N=3 trust=HIGH`.
|
|
229
235
|
|
|
230
236
|
### Quick Run
|
|
231
237
|
|
|
@@ -267,12 +273,15 @@ the local antenna picks up.
|
|
|
267
273
|
|
|
268
274
|
## Status
|
|
269
275
|
|
|
270
|
-
- **0.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
- **0.4.0** — current release. Decoded Clock-Time values now include
|
|
277
|
+
optional receive timestamp metadata on the host monotonic clock, using
|
|
278
|
+
group bit positions plus pilot-derived bit-rate drift correction. A
|
|
279
|
+
parallel sub-second consensus path can learn per-station Group 4A
|
|
280
|
+
transmit latency across multiple stations. Expected UTC precision is
|
|
281
|
+
about 30-80 ms with a healthy NTP-disciplined host and about
|
|
282
|
+
100-250 ms in a 3+ station field demo without internet; this is not
|
|
283
|
+
a hardware-time-source claim. Pre-1.0; the CLI and on-disk formats may
|
|
284
|
+
still change.
|
|
276
285
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
277
286
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
278
287
|
(tracked by SonarCloud).
|
|
@@ -183,11 +183,17 @@ for environments where:
|
|
|
183
183
|
4. **RF fingerprinting** — per-station features (CFO, RSSI, PI) are
|
|
184
184
|
recorded for later analysis. Automated shift detection is on the
|
|
185
185
|
roadmap.
|
|
186
|
-
5. **
|
|
186
|
+
5. **Receive timestamping** — live captures anchor decoded Group 4A
|
|
187
|
+
receipt to the host monotonic clock and correct the bit-rate estimate
|
|
188
|
+
from the 19 kHz pilot tone. With healthy NTP this is a roughly
|
|
189
|
+
30-80 ms UTC claim; without internet, a 3+ station field demo should
|
|
190
|
+
be treated as roughly 100-250 ms. Hardware-grade timing requires a
|
|
191
|
+
hardware time source.
|
|
192
|
+
6. **Holdover** — between Clock-Time messages the receiver
|
|
187
193
|
extrapolates UTC from a local monotonic clock disciplined by an
|
|
188
194
|
estimated ppm drift. Uncertainty grows linearly with the age of
|
|
189
195
|
the most recent CT.
|
|
190
|
-
|
|
196
|
+
7. **Operator display** — `UTC 2026-05-17 04:23:18 ±2s N=3 trust=HIGH`.
|
|
191
197
|
|
|
192
198
|
### Quick Run
|
|
193
199
|
|
|
@@ -229,12 +235,15 @@ the local antenna picks up.
|
|
|
229
235
|
|
|
230
236
|
## Status
|
|
231
237
|
|
|
232
|
-
- **0.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
- **0.4.0** — current release. Decoded Clock-Time values now include
|
|
239
|
+
optional receive timestamp metadata on the host monotonic clock, using
|
|
240
|
+
group bit positions plus pilot-derived bit-rate drift correction. A
|
|
241
|
+
parallel sub-second consensus path can learn per-station Group 4A
|
|
242
|
+
transmit latency across multiple stations. Expected UTC precision is
|
|
243
|
+
about 30-80 ms with a healthy NTP-disciplined host and about
|
|
244
|
+
100-250 ms in a 3+ station field demo without internet; this is not
|
|
245
|
+
a hardware-time-source claim. Pre-1.0; the CLI and on-disk formats may
|
|
246
|
+
still change.
|
|
238
247
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
239
248
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
240
249
|
(tracked by SonarCloud).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rdsclock"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -72,6 +72,8 @@ def _print_station(info: StationInfo) -> None:
|
|
|
72
72
|
ct = info.latest_clock
|
|
73
73
|
if ct is not None:
|
|
74
74
|
print(f" ClockTime: {ct} → local: {ct.local.strftime('%Y-%m-%d %H:%M %Z')}")
|
|
75
|
+
if ct.rx_monotonic_ns is not None:
|
|
76
|
+
print(f" rx_monotonic: {ct.rx_monotonic_ns:_d} ns (host time at receipt)")
|
|
75
77
|
print(f" CT count: {len(info.clock_times)}")
|
|
76
78
|
else:
|
|
77
79
|
print(" ClockTime: NONE (station not transmitting Group 4A or sending dummy data)")
|
|
@@ -80,6 +82,16 @@ def _print_station(info: StationInfo) -> None:
|
|
|
80
82
|
print(f" Groups: {dict(items)}")
|
|
81
83
|
|
|
82
84
|
|
|
85
|
+
def _capture_start_iso_to_ns(value: str | None) -> int | None:
|
|
86
|
+
if value is None:
|
|
87
|
+
return None
|
|
88
|
+
normalized = value.replace("Z", "+00:00")
|
|
89
|
+
dt = datetime.fromisoformat(normalized)
|
|
90
|
+
if dt.tzinfo is None:
|
|
91
|
+
dt = dt.replace(tzinfo=UTC)
|
|
92
|
+
return int(dt.timestamp() * 1_000_000_000)
|
|
93
|
+
|
|
94
|
+
|
|
83
95
|
# ---------- generate ----------
|
|
84
96
|
|
|
85
97
|
|
|
@@ -132,6 +144,7 @@ def cmd_decode(args: argparse.Namespace) -> int:
|
|
|
132
144
|
print(f" ▸ {msg}")
|
|
133
145
|
|
|
134
146
|
start = time.time()
|
|
147
|
+
capture_start_ns = _capture_start_iso_to_ns(args.capture_start_iso)
|
|
135
148
|
if args.carrier_hz is not None:
|
|
136
149
|
try:
|
|
137
150
|
iq = dsp.read_iq_complex64(args.file)
|
|
@@ -139,15 +152,25 @@ def cmd_decode(args: argparse.Namespace) -> int:
|
|
|
139
152
|
iq = dsp.read_iq_u8(args.file)
|
|
140
153
|
except Exception:
|
|
141
154
|
iq = dsp.read_iq_u8(args.file)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
decode_kwargs = {
|
|
156
|
+
"fs": args.fs,
|
|
157
|
+
"carrier_hz": args.carrier_hz,
|
|
158
|
+
"auto_carrier": False,
|
|
159
|
+
"progress": progress,
|
|
160
|
+
}
|
|
161
|
+
if capture_start_ns is not None:
|
|
162
|
+
decode_kwargs["capture_start_monotonic_ns"] = capture_start_ns
|
|
163
|
+
result = decode_iq(iq, **decode_kwargs)
|
|
149
164
|
else:
|
|
150
|
-
|
|
165
|
+
if capture_start_ns is None:
|
|
166
|
+
result = decode_file(args.file, fs=args.fs, progress=progress)
|
|
167
|
+
else:
|
|
168
|
+
result = decode_file(
|
|
169
|
+
args.file,
|
|
170
|
+
fs=args.fs,
|
|
171
|
+
progress=progress,
|
|
172
|
+
capture_start_monotonic_ns=capture_start_ns,
|
|
173
|
+
)
|
|
151
174
|
elapsed = time.time() - start
|
|
152
175
|
|
|
153
176
|
print(f"\n=== {args.file} ===")
|
|
@@ -176,6 +199,7 @@ def cmd_live(args: argparse.Namespace) -> int:
|
|
|
176
199
|
_configure_gain(client, args.gain)
|
|
177
200
|
|
|
178
201
|
print(f"Recording {args.duration}s @ {args.freq} MHz …")
|
|
202
|
+
capture_start_ns = time.monotonic_ns()
|
|
179
203
|
iq = client.record(args.duration, args.fs)
|
|
180
204
|
print(
|
|
181
205
|
f" samples: {len(iq)} power: "
|
|
@@ -192,6 +216,7 @@ def cmd_live(args: argparse.Namespace) -> int:
|
|
|
192
216
|
carrier_hz=args.carrier_hz,
|
|
193
217
|
auto_carrier=args.carrier_hz is None,
|
|
194
218
|
progress=(lambda m: print(f" ▸ {m}")) if args.verbose else None,
|
|
219
|
+
capture_start_monotonic_ns=capture_start_ns,
|
|
195
220
|
)
|
|
196
221
|
print(
|
|
197
222
|
f"\n Groups: {result.n_groups} Bits: {result.n_bits} Δf={result.freq_offset_hz:+.1f} Hz"
|
|
@@ -652,6 +677,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
652
677
|
help="Override the RDS subcarrier frequency",
|
|
653
678
|
)
|
|
654
679
|
pd.add_argument("-v", "--verbose", action="store_true", help="Print pipeline steps")
|
|
680
|
+
pd.add_argument(
|
|
681
|
+
"--capture-start-iso",
|
|
682
|
+
default=None,
|
|
683
|
+
dest="capture_start_iso",
|
|
684
|
+
help="Optional ISO timestamp for the start of an offline capture",
|
|
685
|
+
)
|
|
655
686
|
pd.set_defaults(func=cmd_decode)
|
|
656
687
|
|
|
657
688
|
# live
|
|
@@ -6,12 +6,15 @@ function accepts an optional ``progress`` callback.
|
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
8
|
from collections.abc import Callable
|
|
9
|
-
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
10
|
|
|
11
11
|
import numpy as np
|
|
12
12
|
|
|
13
13
|
from . import dsp
|
|
14
|
-
from .rds_blocks import
|
|
14
|
+
from .rds_blocks import (
|
|
15
|
+
GROUP_BITS,
|
|
16
|
+
_find_groups_in_bitstream_with_counts_and_positions,
|
|
17
|
+
)
|
|
15
18
|
from .rds_clock import ClockTime
|
|
16
19
|
from .rds_groups import StationInfo, parse_groups
|
|
17
20
|
|
|
@@ -25,6 +28,13 @@ class DecodeResult:
|
|
|
25
28
|
n_bits: int
|
|
26
29
|
freq_offset_hz: float
|
|
27
30
|
symbol_offset: int
|
|
31
|
+
n_groups_clean: int = -1
|
|
32
|
+
n_groups_corrected: int = 0
|
|
33
|
+
group_bit_positions: list[int] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if self.n_groups_clean < 0:
|
|
37
|
+
self.n_groups_clean = self.n_groups
|
|
28
38
|
|
|
29
39
|
@property
|
|
30
40
|
def clock_times(self) -> list[ClockTime]:
|
|
@@ -40,6 +50,7 @@ def decode_iq(
|
|
|
40
50
|
costas_beta: float = 0.005,
|
|
41
51
|
auto_carrier: bool = True,
|
|
42
52
|
progress: Callable[[str], None] | None = None,
|
|
53
|
+
capture_start_monotonic_ns: int | None = None,
|
|
43
54
|
) -> DecodeResult:
|
|
44
55
|
"""Run the full pipeline. Returns a ``DecodeResult`` aggregating groups.
|
|
45
56
|
|
|
@@ -57,6 +68,9 @@ def decode_iq(
|
|
|
57
68
|
|
|
58
69
|
emit("fm_demod")
|
|
59
70
|
fm = dsp.fm_demod(iq)
|
|
71
|
+
bit_rate_drift = (
|
|
72
|
+
dsp.measure_bit_rate_drift(fm, fs) if capture_start_monotonic_ns is not None else None
|
|
73
|
+
)
|
|
60
74
|
|
|
61
75
|
if carrier_hz is None:
|
|
62
76
|
if auto_carrier:
|
|
@@ -93,6 +107,9 @@ def decode_iq(
|
|
|
93
107
|
emit(f"biphase matched filter + offset search (sps {sps})")
|
|
94
108
|
matched = dsp.biphase_matched_filter(rds_sync, sps_bit=sps)
|
|
95
109
|
best_groups: list[bytearray] | None = None
|
|
110
|
+
best_positions: list[int] | None = None
|
|
111
|
+
best_groups_clean = 0
|
|
112
|
+
best_groups_corrected = 0
|
|
96
113
|
best_bits: np.ndarray | None = None
|
|
97
114
|
variant_used = "biphase/none"
|
|
98
115
|
sym_offset = 0
|
|
@@ -101,41 +118,78 @@ def decode_iq(
|
|
|
101
118
|
if len(sampled) < 100:
|
|
102
119
|
continue
|
|
103
120
|
bits_candidate = dsp.bits_from_symbols_diff(sampled)
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
(
|
|
122
|
+
groups_candidate,
|
|
123
|
+
positions_candidate,
|
|
124
|
+
variant_candidate,
|
|
125
|
+
groups_clean_candidate,
|
|
126
|
+
groups_corrected_candidate,
|
|
127
|
+
) = _best_variant_groups(bits_candidate)
|
|
128
|
+
if best_groups is None or _group_score(
|
|
129
|
+
groups_candidate, groups_clean_candidate, groups_corrected_candidate
|
|
130
|
+
) > _group_score(best_groups, best_groups_clean, best_groups_corrected):
|
|
106
131
|
best_groups = groups_candidate
|
|
132
|
+
best_positions = positions_candidate
|
|
133
|
+
best_groups_clean = groups_clean_candidate
|
|
134
|
+
best_groups_corrected = groups_corrected_candidate
|
|
107
135
|
best_bits = bits_candidate
|
|
108
136
|
variant_used = f"biphase/off{offset}/{variant_candidate}"
|
|
109
137
|
sym_offset = offset
|
|
110
138
|
|
|
111
|
-
if best_groups is None or best_bits is None:
|
|
139
|
+
if best_groups is None or best_positions is None or best_bits is None:
|
|
112
140
|
# Very short streams may not have enough biphase samples for the
|
|
113
141
|
# offset sweep. Preserve the legacy paths for those callers.
|
|
114
142
|
emit(f"clock recovery fallback: best_offset + mueller_muller (sps {sps})")
|
|
115
143
|
bo_symbols, bo_sym_off = dsp.best_symbol_offset(rds_sync, sps=sps)
|
|
116
144
|
bo_bits = dsp.bits_from_symbols_diff(bo_symbols)
|
|
117
|
-
bo_groups, bo_variant =
|
|
145
|
+
bo_groups, bo_positions, bo_variant, bo_groups_clean, bo_groups_corrected = (
|
|
146
|
+
_best_variant_groups(bo_bits)
|
|
147
|
+
)
|
|
118
148
|
|
|
119
149
|
mm_symbols = dsp.clock_recovery_mm(rds_sync, sps=sps)
|
|
120
150
|
mm_bits = dsp.bits_from_symbols_diff(mm_symbols)
|
|
121
|
-
mm_groups, mm_variant =
|
|
151
|
+
mm_groups, mm_positions, mm_variant, mm_groups_clean, mm_groups_corrected = (
|
|
152
|
+
_best_variant_groups(mm_bits)
|
|
153
|
+
)
|
|
122
154
|
|
|
123
|
-
if
|
|
155
|
+
if _group_score(mm_groups, mm_groups_clean, mm_groups_corrected) > _group_score(
|
|
156
|
+
bo_groups, bo_groups_clean, bo_groups_corrected
|
|
157
|
+
):
|
|
124
158
|
groups = mm_groups
|
|
159
|
+
group_positions = mm_positions
|
|
160
|
+
groups_clean = mm_groups_clean
|
|
161
|
+
groups_corrected = mm_groups_corrected
|
|
125
162
|
variant_used = f"mm/{mm_variant}"
|
|
126
163
|
bits = mm_bits
|
|
127
164
|
sym_offset = -1 # MM has no fixed offset, only its mu state
|
|
128
165
|
else:
|
|
129
166
|
groups = bo_groups
|
|
167
|
+
group_positions = bo_positions
|
|
168
|
+
groups_clean = bo_groups_clean
|
|
169
|
+
groups_corrected = bo_groups_corrected
|
|
130
170
|
variant_used = f"bo/{bo_variant}"
|
|
131
171
|
bits = bo_bits
|
|
132
172
|
sym_offset = bo_sym_off
|
|
133
173
|
else:
|
|
134
174
|
groups = best_groups
|
|
175
|
+
group_positions = best_positions
|
|
176
|
+
groups_clean = best_groups_clean
|
|
177
|
+
groups_corrected = best_groups_corrected
|
|
135
178
|
bits = best_bits
|
|
136
179
|
|
|
137
|
-
|
|
138
|
-
|
|
180
|
+
rx_monotonic_ns_by_group = _rx_monotonic_ns_by_group(
|
|
181
|
+
capture_start_monotonic_ns,
|
|
182
|
+
group_positions,
|
|
183
|
+
bit_rate_drift,
|
|
184
|
+
)
|
|
185
|
+
if rx_monotonic_ns_by_group is None:
|
|
186
|
+
info = parse_groups(groups)
|
|
187
|
+
else:
|
|
188
|
+
info = parse_groups(groups, rx_monotonic_ns_by_group=rx_monotonic_ns_by_group)
|
|
189
|
+
emit(
|
|
190
|
+
f"groups={len(groups)} clean={groups_clean} corrected={groups_corrected} "
|
|
191
|
+
f"variant={variant_used} freq_off={freq_off:+.1f} Hz"
|
|
192
|
+
)
|
|
139
193
|
|
|
140
194
|
return DecodeResult(
|
|
141
195
|
info=info,
|
|
@@ -143,10 +197,19 @@ def decode_iq(
|
|
|
143
197
|
n_bits=len(bits),
|
|
144
198
|
freq_offset_hz=float(freq_off),
|
|
145
199
|
symbol_offset=sym_offset,
|
|
200
|
+
n_groups_clean=groups_clean,
|
|
201
|
+
n_groups_corrected=groups_corrected,
|
|
202
|
+
group_bit_positions=group_positions,
|
|
146
203
|
)
|
|
147
204
|
|
|
148
205
|
|
|
149
|
-
def
|
|
206
|
+
def _group_score(
|
|
207
|
+
groups: list[bytearray], n_groups_clean: int, n_groups_corrected: int
|
|
208
|
+
) -> tuple[int, int, int]:
|
|
209
|
+
return len(groups), n_groups_clean, -n_groups_corrected
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _best_variant_groups(bits: np.ndarray) -> tuple[list[bytearray], list[int], str, int, int]:
|
|
150
213
|
"""Try the four bitstream polarity/order variants and return the one
|
|
151
214
|
that yields the most groups.
|
|
152
215
|
|
|
@@ -156,19 +219,49 @@ def _best_variant_groups(bits: np.ndarray) -> tuple[list[bytearray], str]:
|
|
|
156
219
|
flips symbol order (mainly relevant for short streams).
|
|
157
220
|
"""
|
|
158
221
|
candidates = [
|
|
159
|
-
("normal", bits),
|
|
160
|
-
("inverted", 1 - bits),
|
|
161
|
-
("reversed", bits[::-1]),
|
|
162
|
-
("inv+rev", (1 - bits)[::-1]),
|
|
222
|
+
("normal", bits, False),
|
|
223
|
+
("inverted", 1 - bits, False),
|
|
224
|
+
("reversed", bits[::-1], True),
|
|
225
|
+
("inv+rev", (1 - bits)[::-1], True),
|
|
163
226
|
]
|
|
164
227
|
best: list[bytearray] = []
|
|
228
|
+
best_positions: list[int] = []
|
|
165
229
|
best_name = "normal"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
best_clean = 0
|
|
231
|
+
best_corrected = 0
|
|
232
|
+
for name, bstream, reversed_axis in candidates:
|
|
233
|
+
g, positions, n_clean, n_corrected = _find_groups_in_bitstream_with_counts_and_positions(
|
|
234
|
+
np.ascontiguousarray(bstream), tolerate_single_bit=True
|
|
235
|
+
)
|
|
236
|
+
if _group_score(g, n_clean, n_corrected) > _group_score(best, best_clean, best_corrected):
|
|
169
237
|
best = g
|
|
238
|
+
best_positions = _positions_on_capture_axis(len(bits), positions, reversed_axis)
|
|
170
239
|
best_name = name
|
|
171
|
-
|
|
240
|
+
best_clean = n_clean
|
|
241
|
+
best_corrected = n_corrected
|
|
242
|
+
return best, best_positions, best_name, best_clean, best_corrected
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _positions_on_capture_axis(n_bits: int, positions: list[int], reversed_axis: bool) -> list[int]:
|
|
246
|
+
if not reversed_axis:
|
|
247
|
+
return positions
|
|
248
|
+
return [n_bits - pos - GROUP_BITS for pos in positions]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _rx_monotonic_ns_by_group(
|
|
252
|
+
capture_start_monotonic_ns: int | None,
|
|
253
|
+
group_positions: list[int],
|
|
254
|
+
bit_rate_drift: dsp.BitRateDrift | None,
|
|
255
|
+
) -> list[int | None] | None:
|
|
256
|
+
if capture_start_monotonic_ns is None or bit_rate_drift is None:
|
|
257
|
+
return None
|
|
258
|
+
bit_rate_hz = bit_rate_drift.bit_rate_hz
|
|
259
|
+
return [
|
|
260
|
+
capture_start_monotonic_ns
|
|
261
|
+
+ int((bit_position + GROUP_BITS) / bit_rate_hz * 1e9)
|
|
262
|
+
+ dsp.PIPELINE_GROUP_DELAY_NS
|
|
263
|
+
for bit_position in group_positions
|
|
264
|
+
]
|
|
172
265
|
|
|
173
266
|
|
|
174
267
|
def decode_file(
|
|
@@ -176,6 +269,7 @@ def decode_file(
|
|
|
176
269
|
fs: float = dsp.DEFAULT_INPUT_FS,
|
|
177
270
|
progress: Callable[[str], None] | None = None,
|
|
178
271
|
fmt: str | None = None,
|
|
272
|
+
capture_start_monotonic_ns: int | None = None,
|
|
179
273
|
) -> DecodeResult:
|
|
180
274
|
"""Load an IQ file and decode it.
|
|
181
275
|
|
|
@@ -209,4 +303,11 @@ def decode_file(
|
|
|
209
303
|
|
|
210
304
|
if len(iq) == 0:
|
|
211
305
|
raise ValueError(f"empty IQ file: {path}")
|
|
212
|
-
|
|
306
|
+
if capture_start_monotonic_ns is None:
|
|
307
|
+
return decode_iq(iq, fs=fs, progress=progress)
|
|
308
|
+
return decode_iq(
|
|
309
|
+
iq,
|
|
310
|
+
fs=fs,
|
|
311
|
+
progress=progress,
|
|
312
|
+
capture_start_monotonic_ns=capture_start_monotonic_ns,
|
|
313
|
+
)
|
|
@@ -5,6 +5,8 @@ NumPy ndarrays (typically ``complex64`` for IQ samples and ``float32``
|
|
|
5
5
|
for FM-demodulated baseband).
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
8
10
|
import numpy as np
|
|
9
11
|
from scipy.signal import filtfilt, firwin, lfilter, resample_poly
|
|
10
12
|
|
|
@@ -23,6 +25,17 @@ SYMBOL_LPF_HZ = 4_000 # LPF in the symbol-rate domain (after decimation)
|
|
|
23
25
|
|
|
24
26
|
DEFAULT_INPUT_FS = 250_000 # rtl_sdr -s 250000
|
|
25
27
|
DEFAULT_RDS_FS = 19_000 # after decimation; ~16 samples/symbol
|
|
28
|
+
PIPELINE_GROUP_DELAY_NS = -2_528_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class BitRateDrift:
|
|
33
|
+
"""Pilot-derived RDS bit-rate estimate and stability flag."""
|
|
34
|
+
|
|
35
|
+
bit_rate_hz: float
|
|
36
|
+
pilot_hz: float
|
|
37
|
+
pilot_mad_hz: float
|
|
38
|
+
confident: bool
|
|
26
39
|
|
|
27
40
|
|
|
28
41
|
def read_iq_u8(path: str) -> np.ndarray:
|
|
@@ -109,6 +122,48 @@ def estimate_pilot_19khz(baseband: np.ndarray, fs: float, search_span_hz: float
|
|
|
109
122
|
return float(sub_freqs[int(np.argmax(sub_spec))])
|
|
110
123
|
|
|
111
124
|
|
|
125
|
+
def measure_bit_rate_drift(
|
|
126
|
+
baseband: np.ndarray,
|
|
127
|
+
fs: float,
|
|
128
|
+
n_windows: int = 4,
|
|
129
|
+
pilot_mad_threshold_hz: float = 50.0,
|
|
130
|
+
) -> BitRateDrift:
|
|
131
|
+
"""Estimate RDS bit-rate drift from the 19 kHz stereo pilot.
|
|
132
|
+
|
|
133
|
+
The RDS subcarrier is locked to the third pilot harmonic, so the
|
|
134
|
+
bit rate follows the same ppm drift. If the pilot estimate is
|
|
135
|
+
unstable across windows, return the nominal 1187.5 bit/s rate and
|
|
136
|
+
mark the estimate as not confident.
|
|
137
|
+
"""
|
|
138
|
+
if n_windows <= 0:
|
|
139
|
+
raise ValueError("n_windows must be positive")
|
|
140
|
+
|
|
141
|
+
samples = np.asarray(baseband)
|
|
142
|
+
if len(samples) < n_windows * 1024:
|
|
143
|
+
return BitRateDrift(
|
|
144
|
+
bit_rate_hz=RDS_SYMBOL_RATE,
|
|
145
|
+
pilot_hz=19_000.0,
|
|
146
|
+
pilot_mad_hz=float("inf"),
|
|
147
|
+
confident=False,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
window_len = len(samples) // n_windows
|
|
151
|
+
estimates = [
|
|
152
|
+
estimate_pilot_19khz(samples[i * window_len : (i + 1) * window_len], fs)
|
|
153
|
+
for i in range(n_windows)
|
|
154
|
+
]
|
|
155
|
+
pilot_hz = float(np.median(estimates))
|
|
156
|
+
pilot_mad_hz = float(np.median(np.abs(np.asarray(estimates) - pilot_hz)))
|
|
157
|
+
confident = pilot_mad_hz <= pilot_mad_threshold_hz
|
|
158
|
+
bit_rate_hz = RDS_SYMBOL_RATE * pilot_hz / 19_000.0 if confident else RDS_SYMBOL_RATE
|
|
159
|
+
return BitRateDrift(
|
|
160
|
+
bit_rate_hz=float(bit_rate_hz),
|
|
161
|
+
pilot_hz=pilot_hz,
|
|
162
|
+
pilot_mad_hz=pilot_mad_hz,
|
|
163
|
+
confident=confident,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
112
167
|
def estimate_rds_carrier(
|
|
113
168
|
baseband: np.ndarray,
|
|
114
169
|
fs: float,
|