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.
Files changed (48) hide show
  1. {rdsclock-0.2.2 → rdsclock-0.4.0}/PKG-INFO +18 -9
  2. {rdsclock-0.2.2 → rdsclock-0.4.0}/README.md +17 -8
  3. {rdsclock-0.2.2 → rdsclock-0.4.0}/pyproject.toml +1 -1
  4. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/__init__.py +1 -1
  5. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/cli.py +39 -8
  6. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/decoder.py +121 -20
  7. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/dsp.py +55 -0
  8. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rds_blocks.py +192 -20
  9. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rds_clock.py +13 -2
  10. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rds_groups.py +16 -5
  11. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/recon.py +22 -3
  12. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/time_consensus.py +130 -1
  13. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/PKG-INFO +18 -9
  14. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/SOURCES.txt +1 -0
  15. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_coverage_edges.py +5 -3
  16. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_coverage_extra.py +80 -7
  17. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_decoder_synthetic.py +30 -0
  18. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_dsp.py +32 -0
  19. rdsclock-0.4.0/tests/test_pipeline_delay.py +26 -0
  20. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rds_blocks.py +104 -0
  21. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rds_clock.py +9 -0
  22. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rds_groups.py +8 -0
  23. rdsclock-0.4.0/tests/test_real_iq_regression.py +45 -0
  24. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_time_consensus.py +143 -0
  25. rdsclock-0.2.2/tests/test_real_iq_regression.py +0 -18
  26. {rdsclock-0.2.2 → rdsclock-0.4.0}/LICENSE +0 -0
  27. {rdsclock-0.2.2 → rdsclock-0.4.0}/setup.cfg +0 -0
  28. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/__main__.py +0 -0
  29. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/audio.py +0 -0
  30. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/channelizer.py +0 -0
  31. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/plot.py +0 -0
  32. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/py.typed +0 -0
  33. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/rtl_tcp.py +0 -0
  34. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock/synth.py +0 -0
  35. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/dependency_links.txt +0 -0
  36. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/entry_points.txt +0 -0
  37. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/requires.txt +0 -0
  38. {rdsclock-0.2.2 → rdsclock-0.4.0}/src/rdsclock.egg-info/top_level.txt +0 -0
  39. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_audio.py +0 -0
  40. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_channelizer.py +0 -0
  41. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_cli.py +0 -0
  42. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_cli_with_fake_sdr.py +0 -0
  43. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_plot.py +0 -0
  44. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_real_recordings.py +0 -0
  45. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_recon.py +0 -0
  46. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_recon_offline.py +0 -0
  47. {rdsclock-0.2.2 → rdsclock-0.4.0}/tests/test_rtl_tcp.py +0 -0
  48. {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.2.2
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. **Holdover** — between Clock-Time messages the receiver
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
- 6. **Operator display** — `UTC 2026-05-17 04:23:18 ±2s N=3 trust=HIGH`.
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.2.2** — current release. Operator-facing polish: 250 kS/s SDR
271
- captures can now play at 48 kHz audio via rational resampling, and
272
- Programme Service names are displayed only after validation so
273
- dynamic-PS stations do not show mixed scrolling fragments. Decoder
274
- group counts and PI codes remain locked to the 0.2.1 baseline.
275
- Pre-1.0; the CLI and on-disk formats may still change.
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. **Holdover** — between Clock-Time messages the receiver
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
- 6. **Operator display** — `UTC 2026-05-17 04:23:18 ±2s N=3 trust=HIGH`.
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.2.2** — current release. Operator-facing polish: 250 kS/s SDR
233
- captures can now play at 48 kHz audio via rational resampling, and
234
- Programme Service names are displayed only after validation so
235
- dynamic-PS stations do not show mixed scrolling fragments. Decoder
236
- group counts and PI codes remain locked to the 0.2.1 baseline.
237
- Pre-1.0; the CLI and on-disk formats may still change.
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.2.2"
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"
@@ -19,4 +19,4 @@ Modules:
19
19
  scan / recon / demo.
20
20
  """
21
21
 
22
- __version__ = "0.2.2"
22
+ __version__ = "0.4.0"
@@ -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
- result = decode_iq(
143
- iq,
144
- fs=args.fs,
145
- carrier_hz=args.carrier_hz,
146
- auto_carrier=False,
147
- progress=progress,
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
- result = decode_file(args.file, fs=args.fs, progress=progress)
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 find_groups_in_bitstream
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
- groups_candidate, variant_candidate = _best_variant_groups(bits_candidate)
105
- if best_groups is None or len(groups_candidate) > len(best_groups):
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 = _best_variant_groups(bo_bits)
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 = _best_variant_groups(mm_bits)
151
+ mm_groups, mm_positions, mm_variant, mm_groups_clean, mm_groups_corrected = (
152
+ _best_variant_groups(mm_bits)
153
+ )
122
154
 
123
- if len(mm_groups) > len(bo_groups):
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
- info = parse_groups(groups)
138
- emit(f"groups={len(groups)} variant={variant_used} freq_off={freq_off:+.1f} Hz")
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 _best_variant_groups(bits: np.ndarray) -> tuple[list[bytearray], str]:
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
- for name, bstream in candidates:
167
- g = find_groups_in_bitstream(np.ascontiguousarray(bstream))
168
- if len(g) > len(best):
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
- return best, best_name
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
- return decode_iq(iq, fs=fs, progress=progress)
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,