rdsclock 0.2.0__tar.gz → 0.2.2__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 (46) hide show
  1. {rdsclock-0.2.0 → rdsclock-0.2.2}/PKG-INFO +10 -6
  2. {rdsclock-0.2.0 → rdsclock-0.2.2}/README.md +7 -5
  3. {rdsclock-0.2.0 → rdsclock-0.2.2}/pyproject.toml +4 -1
  4. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/__init__.py +1 -1
  5. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/audio.py +23 -30
  6. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/dsp.py +19 -3
  7. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rds_blocks.py +93 -53
  8. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rds_groups.py +39 -2
  9. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/PKG-INFO +10 -6
  10. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/requires.txt +3 -0
  11. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_audio.py +16 -3
  12. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_coverage_edges.py +26 -1
  13. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_coverage_extra.py +1 -1
  14. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_dsp.py +13 -0
  15. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rds_blocks.py +36 -0
  16. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rds_groups.py +54 -4
  17. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_synth.py +1 -1
  18. {rdsclock-0.2.0 → rdsclock-0.2.2}/LICENSE +0 -0
  19. {rdsclock-0.2.0 → rdsclock-0.2.2}/setup.cfg +0 -0
  20. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/__main__.py +0 -0
  21. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/channelizer.py +0 -0
  22. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/cli.py +0 -0
  23. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/decoder.py +0 -0
  24. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/plot.py +0 -0
  25. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/py.typed +0 -0
  26. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rds_clock.py +0 -0
  27. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/recon.py +0 -0
  28. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/rtl_tcp.py +0 -0
  29. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/synth.py +0 -0
  30. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock/time_consensus.py +0 -0
  31. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/SOURCES.txt +0 -0
  32. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/dependency_links.txt +0 -0
  33. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/entry_points.txt +0 -0
  34. {rdsclock-0.2.0 → rdsclock-0.2.2}/src/rdsclock.egg-info/top_level.txt +0 -0
  35. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_channelizer.py +0 -0
  36. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_cli.py +0 -0
  37. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_cli_with_fake_sdr.py +0 -0
  38. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_decoder_synthetic.py +0 -0
  39. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_plot.py +0 -0
  40. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rds_clock.py +0 -0
  41. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_real_iq_regression.py +0 -0
  42. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_real_recordings.py +0 -0
  43. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_recon.py +0 -0
  44. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_recon_offline.py +0 -0
  45. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_rtl_tcp.py +0 -0
  46. {rdsclock-0.2.0 → rdsclock-0.2.2}/tests/test_time_consensus.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rdsclock
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -26,6 +26,8 @@ Provides-Extra: audio
26
26
  Requires-Dist: sounddevice>=0.4.6; extra == "audio"
27
27
  Provides-Extra: plot
28
28
  Requires-Dist: matplotlib>=3.7.0; extra == "plot"
29
+ Provides-Extra: fast
30
+ Requires-Dist: numba>=0.60.0; extra == "fast"
29
31
  Provides-Extra: dev
30
32
  Requires-Dist: pytest>=7.4.0; extra == "dev"
31
33
  Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
@@ -265,11 +267,13 @@ the local antenna picks up.
265
267
 
266
268
  ## Status
267
269
 
268
- - **0.2.0** — current release. First version that decodes real FM
269
- broadcasts (earlier 0.1.x worked only on synthetic IQ see the
270
- 0.2.0 entry in [`CHANGELOG.md`](CHANGELOG.md)). Pre-1.0; the CLI
271
- and on-disk formats may still change.
272
- - 236 tests including a real-IQ regression backed by a 6 s capture
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
+ - 240+ tests including a real-IQ regression backed by a 6 s capture
273
277
  of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
274
278
  (tracked by SonarCloud).
275
279
 
@@ -229,11 +229,13 @@ the local antenna picks up.
229
229
 
230
230
  ## Status
231
231
 
232
- - **0.2.0** — current release. First version that decodes real FM
233
- broadcasts (earlier 0.1.x worked only on synthetic IQ see the
234
- 0.2.0 entry in [`CHANGELOG.md`](CHANGELOG.md)). Pre-1.0; the CLI
235
- and on-disk formats may still change.
236
- - 236 tests including a real-IQ regression backed by a 6 s capture
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
+ - 240+ tests including a real-IQ regression backed by a 6 s capture
237
239
  of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
238
240
  (tracked by SonarCloud).
239
241
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rdsclock"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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"
@@ -36,6 +36,9 @@ audio = [
36
36
  plot = [
37
37
  "matplotlib>=3.7.0",
38
38
  ]
39
+ fast = [
40
+ "numba>=0.60.0",
41
+ ]
39
42
  dev = [
40
43
  "pytest>=7.4.0",
41
44
  "pytest-timeout>=2.1.0",
@@ -19,4 +19,4 @@ Modules:
19
19
  scan / recon / demo.
20
20
  """
21
21
 
22
- __version__ = "0.2.0"
22
+ __version__ = "0.2.2"
@@ -10,7 +10,7 @@ audio sink is needed; the rest of the package works without it.
10
10
  """
11
11
 
12
12
  import numpy as np
13
- from scipy.signal import firwin, lfilter, lfilter_zi
13
+ from scipy.signal import firwin, lfilter, lfilter_zi, resample_poly
14
14
 
15
15
  from .rtl_tcp import RtlTcpClient
16
16
 
@@ -33,12 +33,11 @@ def design_audio_filter(
33
33
  ) -> tuple[np.ndarray, int]:
34
34
  """Design the mono-audio LPF used during decimation from ``fs_in`` to ``fs_out``.
35
35
 
36
- Returns ``(taps, decimation_factor)``. ``fs_in`` must be an integer
37
- multiple of ``fs_out``.
36
+ Returns ``(taps, decimation_factor)``. Integer SDR/audio ratios return the
37
+ real decimation factor; rational ratios return ``1`` so callers can apply
38
+ polyphase resampling after the same low-pass stage.
38
39
  """
39
- if fs_in % fs_out != 0:
40
- raise ValueError(f"SDR sample rate ({fs_in}) must be a multiple of audio rate ({fs_out})")
41
- decim = fs_in // fs_out
40
+ decim = fs_in // fs_out if fs_in % fs_out == 0 else 1
42
41
  nyq = fs_in / 2.0
43
42
  taps = firwin(numtaps=numtaps, cutoff=cutoff_hz / nyq).astype(np.float32)
44
43
  return taps, decim
@@ -53,13 +52,15 @@ def fm_audio_from_iq(
53
52
  ) -> np.ndarray:
54
53
  """Convert an IQ buffer into a mono audio float32 buffer at ``fs_out``.
55
54
 
56
- Steps: FM phase demodulation → LPF (cutoff = ``cutoff_hz``) → decimation
57
- to ``fs_out`` → optional peak normalisation to ±0.5 to avoid clipping.
55
+ Steps: FM phase demodulation → LPF (cutoff = ``cutoff_hz``) → rate
56
+ conversion to ``fs_out`` → optional peak normalisation to ±0.5 to avoid
57
+ clipping. Integer SDR/audio ratios use the fast FIR decimation path;
58
+ rational ratios use polyphase resampling after the same LPF stage.
58
59
  """
59
60
  taps, decim = design_audio_filter(fs_in, fs_out, cutoff_hz=cutoff_hz)
60
61
  fm = fm_demod(iq)
61
62
  audio, _ = lfilter(taps, 1.0, fm), None
62
- audio = audio[::decim]
63
+ audio = audio[::decim] if fs_in % fs_out == 0 else resample_poly(audio, fs_out, fs_in)
63
64
  if normalise:
64
65
  peak = float(np.max(np.abs(audio)) + 1e-6)
65
66
  audio = (audio / peak) * 0.5
@@ -88,8 +89,10 @@ def play_iq_live(
88
89
  ) from exc
89
90
 
90
91
  chunk_samples = chunk_samples or fs_sdr // 10 # 100 ms per block
91
- taps, decim = design_audio_filter(fs_sdr, fs_audio)
92
- zi = lfilter_zi(taps, 1.0)
92
+ integer_decim = fs_sdr % fs_audio == 0
93
+ if integer_decim:
94
+ taps, decim = design_audio_filter(fs_sdr, fs_audio)
95
+ zi = lfilter_zi(taps, 1.0)
93
96
 
94
97
  sd.default.samplerate = fs_audio
95
98
  sd.default.channels = 1
@@ -117,11 +120,14 @@ def play_iq_live(
117
120
  try:
118
121
  while True:
119
122
  iq = client.read_iq(chunk_samples, settle_s=0.0)
120
- fm = fm_demod(iq)
121
- audio, zi = lfilter(taps, 1.0, fm, zi=zi)
122
- audio = audio[::decim]
123
- peak = float(np.max(np.abs(audio)) + 1e-6)
124
- audio = (audio / peak) * 0.5
123
+ if integer_decim:
124
+ fm = fm_demod(iq)
125
+ audio, zi = lfilter(taps, 1.0, fm, zi=zi)
126
+ audio = audio[::decim]
127
+ peak = float(np.max(np.abs(audio)) + 1e-6)
128
+ audio = (audio / peak) * 0.5
129
+ else:
130
+ audio = fm_audio_from_iq(iq, fs_in=fs_sdr, fs_out=fs_audio)
125
131
  stream.write(audio.astype("float32"))
126
132
  except KeyboardInterrupt:
127
133
  print("\nInterrupted by operator (Ctrl+C).")
@@ -151,20 +157,7 @@ def play_iq_file(
151
157
  if len(iq) == 0 or np.max(np.abs(iq[:1000])) > 100:
152
158
  iq = dsp.read_iq_u8(path)
153
159
 
154
- # If the file was captured at the standard 250 kS/s, decimate by 5 to
155
- # reach 50 kS/s, then by ~1.04 to 48 kHz. We keep it simple: design a
156
- # filter using a chosen integer ratio and accept whatever audio rate
157
- # the file allows.
158
- if fs_in % fs_audio == 0:
159
- audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=fs_audio)
160
- else:
161
- # Fall back: drop to the largest fs_audio divisor of fs_in.
162
- from math import gcd
163
-
164
- target = gcd(fs_in, fs_audio)
165
- audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=target)
166
- fs_audio = target
167
-
160
+ audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=fs_audio)
168
161
  duration_s = len(audio) / fs_audio
169
162
  print(f"Playing {path}: {duration_s:.1f}s of audio @ {fs_audio} Hz (Ctrl+C to stop)…")
170
163
  try:
@@ -8,6 +8,11 @@ for FM-demodulated baseband).
8
8
  import numpy as np
9
9
  from scipy.signal import filtfilt, firwin, lfilter, resample_poly
10
10
 
11
+ try:
12
+ from numba import njit as _numba_njit
13
+ except ImportError: # pragma: no cover - optional acceleration dependency
14
+ _numba_njit = None
15
+
11
16
  # RDS / FM constants
12
17
  FM_CHANNEL_BW_HZ = 100_000 # half-width of an FM channel (~200 kHz total)
13
18
  RDS_CARRIER_HZ = 57_000
@@ -165,9 +170,7 @@ def symbol_lpf(x: np.ndarray, fs: float, cutoff: float = SYMBOL_LPF_HZ) -> np.nd
165
170
  return filtfilt(taps, [1.0], x)
166
171
 
167
172
 
168
- def costas_loop_bpsk(samples: np.ndarray, alpha: float = 0.3, beta: float = 0.005) -> np.ndarray:
169
- """Second-order Costas loop for BPSK. Stabilises carrier phase."""
170
- samples = np.asarray(samples, dtype=np.complex64)
173
+ def _costas_loop_bpsk_python(samples: np.ndarray, alpha: float, beta: float) -> np.ndarray:
171
174
  out = np.empty_like(samples)
172
175
  phase = 0.0
173
176
  freq = 0.0
@@ -184,6 +187,19 @@ def costas_loop_bpsk(samples: np.ndarray, alpha: float = 0.3, beta: float = 0.00
184
187
  return out
185
188
 
186
189
 
190
+ _COSTAS_LOOP_BPSK_NUMBA = (
191
+ _numba_njit(cache=True)(_costas_loop_bpsk_python) if _numba_njit is not None else None
192
+ )
193
+
194
+
195
+ def costas_loop_bpsk(samples: np.ndarray, alpha: float = 0.3, beta: float = 0.005) -> np.ndarray:
196
+ """Second-order Costas loop for BPSK. Stabilises carrier phase."""
197
+ samples = np.asarray(samples, dtype=np.complex64)
198
+ if _COSTAS_LOOP_BPSK_NUMBA is not None:
199
+ return _COSTAS_LOOP_BPSK_NUMBA(samples, alpha, beta) # pragma: no cover
200
+ return _costas_loop_bpsk_python(samples, alpha, beta)
201
+
202
+
187
203
  def agc(samples: np.ndarray, eps: float = 1e-9) -> np.ndarray:
188
204
  """Normalize complex baseband amplitude by its mean magnitude."""
189
205
  samples = np.asarray(samples, dtype=np.complex64)
@@ -41,23 +41,40 @@ OFFSET_BY_NAME = {
41
41
  GROUP_BLOCKS = 4
42
42
  GROUP_BITS = BLOCK_BITS * GROUP_BLOCKS # 104
43
43
 
44
+ _BLOCK_WORD_MASK = (1 << CRC_BITS) - 1
45
+ _DATA_WORD_MASK = (1 << DATA_BITS) - 1
46
+ _CRC_TOP_BIT = 1 << CRC_BITS
47
+ _CRC_POLY_WITH_TOP_BIT = CRC_POLY | _CRC_TOP_BIT
48
+ _BLOCK_WEIGHTS = (1 << np.arange(BLOCK_BITS - 1, -1, -1, dtype=np.uint32)).astype(np.uint32)
44
49
 
45
- def crc10(dataword: int) -> int:
46
- """Compute the 10-bit RDS CRC of a 16-bit dataword.
47
50
 
48
- Standard shift-register implementation: feed 16 data bits into
49
- a 10-bit register tapped by ``CRC_POLY``, then flush 10 zero bits.
50
- """
51
- reg = 0
51
+ def _build_crc10_table() -> np.ndarray:
52
+ datawords = np.arange(1 << DATA_BITS, dtype=np.uint32)
53
+ reg = np.zeros_like(datawords)
52
54
  for i in range(DATA_BITS - 1, -1, -1):
53
- reg = (reg << 1) | ((dataword >> i) & 1)
54
- if reg & (1 << CRC_BITS):
55
- reg ^= CRC_POLY | (1 << CRC_BITS)
55
+ reg = (reg << 1) | ((datawords >> i) & 1)
56
+ reg = np.where((reg & _CRC_TOP_BIT) != 0, reg ^ _CRC_POLY_WITH_TOP_BIT, reg)
56
57
  for _ in range(CRC_BITS):
57
58
  reg <<= 1
58
- if reg & (1 << CRC_BITS):
59
- reg ^= CRC_POLY | (1 << CRC_BITS)
60
- return reg & ((1 << CRC_BITS) - 1)
59
+ reg = np.where((reg & _CRC_TOP_BIT) != 0, reg ^ _CRC_POLY_WITH_TOP_BIT, reg)
60
+ return (reg & _BLOCK_WORD_MASK).astype(np.uint16)
61
+
62
+
63
+ _CRC10_TABLE = _build_crc10_table()
64
+
65
+
66
+ def _crc10_many(datawords: np.ndarray) -> np.ndarray:
67
+ return _CRC10_TABLE[np.asarray(datawords, dtype=np.uint32) & _DATA_WORD_MASK]
68
+
69
+
70
+ def crc10(dataword: int) -> int:
71
+ """Compute the 10-bit RDS CRC of a 16-bit dataword.
72
+
73
+ Table-driven equivalent of the standard shift-register implementation:
74
+ feed 16 data bits into a 10-bit register tapped by ``CRC_POLY``, then
75
+ flush 10 zero bits.
76
+ """
77
+ return int(_CRC10_TABLE[int(dataword) & _DATA_WORD_MASK])
61
78
 
62
79
 
63
80
  def encode_block(dataword: int, offset_word: int) -> int:
@@ -73,7 +90,7 @@ def block_dataword(block26: int) -> int:
73
90
 
74
91
 
75
92
  def block_checkword(block26: int) -> int:
76
- return block26 & ((1 << CRC_BITS) - 1)
93
+ return block26 & _BLOCK_WORD_MASK
77
94
 
78
95
 
79
96
  def block_valid(block26: int, block_no: int, version_b: bool | None = None) -> bool:
@@ -120,12 +137,30 @@ def blocks_to_bits(blocks: Iterable[int]) -> np.ndarray:
120
137
 
121
138
  def bits_to_word(bits: Sequence[int]) -> int:
122
139
  """Pack a bit sequence into an int (MSB first)."""
140
+ bit_array = np.asarray(bits, dtype=np.uint8)
141
+ if bit_array.size == BLOCK_BITS:
142
+ return int(_bits_to_words_26(bit_array))
123
143
  word = 0
124
- for b in bits:
144
+ for b in bit_array:
125
145
  word = (word << 1) | (int(b) & 1)
126
146
  return word
127
147
 
128
148
 
149
+ def _bits_to_words_26(bit_windows: np.ndarray, assume_binary: bool = False) -> np.ndarray:
150
+ bit_windows = np.asarray(bit_windows, dtype=np.uint8)
151
+ if not assume_binary:
152
+ bit_windows = np.bitwise_and(bit_windows, 1)
153
+ return bit_windows @ _BLOCK_WEIGHTS
154
+
155
+
156
+ def _coerce_bits(bits: np.ndarray) -> np.ndarray:
157
+ if not isinstance(bits, np.ndarray):
158
+ bits = np.asarray(bits, dtype=np.uint8)
159
+ if bits.dtype != np.uint8:
160
+ bits = bits.astype(np.uint8)
161
+ return np.bitwise_and(bits, 1)
162
+
163
+
129
164
  def find_groups_in_bitstream(bits: np.ndarray) -> list[bytearray]:
130
165
  """Slide over a bitstream and extract groups of 4 consecutively valid blocks.
131
166
 
@@ -135,52 +170,57 @@ def find_groups_in_bitstream(bits: np.ndarray) -> list[bytearray]:
135
170
 
136
171
  Returns a list of 8-byte bytearrays (4 × big-endian 16-bit datawords).
137
172
  """
138
- if not isinstance(bits, np.ndarray):
139
- bits = np.asarray(bits, dtype=np.uint8)
140
- if bits.dtype != np.uint8:
141
- bits = bits.astype(np.uint8)
142
-
173
+ bits = _coerce_bits(bits)
143
174
  n = len(bits)
144
175
  groups: list[bytearray] = []
145
- i = 0
146
- while i + GROUP_BITS <= n:
147
- # Block A
148
- w0 = bits_to_word(bits[i : i + BLOCK_BITS])
149
- if not block_valid(w0, 0):
150
- i += 1
151
- continue
152
- # Block B (validated against offset B)
153
- w1 = bits_to_word(bits[i + BLOCK_BITS : i + 2 * BLOCK_BITS])
154
- if not block_valid(w1, 1):
155
- i += 1
156
- continue
157
- # Derive version from block B (bit 11 of the dataword)
158
- b_data = block_dataword(w1)
159
- version_b = bool((b_data >> 11) & 0x1)
160
- # Block C: require the matching offset (C or C')
161
- w2 = bits_to_word(bits[i + 2 * BLOCK_BITS : i + 3 * BLOCK_BITS])
162
- if not block_valid(w2, 2, version_b=version_b):
163
- i += 1
164
- continue
165
- # Block D
166
- w3 = bits_to_word(bits[i + 3 * BLOCK_BITS : i + 4 * BLOCK_BITS])
167
- if not block_valid(w3, 3):
168
- i += 1
169
- continue
170
- buf = bytearray(8)
171
- for idx, word in enumerate((w0, w1, w2, w3)):
172
- dw = block_dataword(word)
173
- buf[idx * 2] = (dw >> 8) & 0xFF
174
- buf[idx * 2 + 1] = dw & 0xFF
175
- groups.append(buf)
176
- i += GROUP_BITS
176
+ if n < GROUP_BITS:
177
+ return groups
178
+
179
+ windows = np.lib.stride_tricks.sliding_window_view(bits, BLOCK_BITS)
180
+ words = _bits_to_words_26(windows, assume_binary=True)
181
+ data = (words >> CRC_BITS) & _DATA_WORD_MASK
182
+ check = words & _BLOCK_WORD_MASK
183
+ expected = _crc10_many(data)
184
+
185
+ valid_a = (check ^ OFFSET_A) == expected
186
+ valid_b = (check ^ OFFSET_B) == expected
187
+ valid_c = (check ^ OFFSET_C) == expected
188
+ valid_c_prime = (check ^ OFFSET_C_PRIME) == expected
189
+ valid_d = (check ^ OFFSET_D) == expected
190
+
191
+ start_count = n - GROUP_BITS + 1
192
+ block_b_data = data[BLOCK_BITS : BLOCK_BITS + start_count]
193
+ version_b = ((block_b_data >> 11) & 1).astype(bool)
194
+ block_c_valid = np.where(
195
+ version_b,
196
+ valid_c_prime[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
197
+ valid_c[2 * BLOCK_BITS : 2 * BLOCK_BITS + start_count],
198
+ )
199
+ group_starts = np.flatnonzero(
200
+ valid_a[:start_count]
201
+ & valid_b[BLOCK_BITS : BLOCK_BITS + start_count]
202
+ & block_c_valid
203
+ & valid_d[3 * BLOCK_BITS : 3 * BLOCK_BITS + start_count]
204
+ )
205
+
206
+ next_scan_start = 0
207
+ group_offsets = np.array([0, BLOCK_BITS, 2 * BLOCK_BITS, 3 * BLOCK_BITS], dtype=np.intp)
208
+ for start in group_starts:
209
+ group_start = int(start)
210
+ if group_start >= next_scan_start:
211
+ buf = bytearray(8)
212
+ for idx, dw_raw in enumerate(data[group_start + group_offsets]):
213
+ dw = int(dw_raw)
214
+ buf[idx * 2] = (dw >> 8) & 0xFF
215
+ buf[idx * 2 + 1] = dw & 0xFF
216
+ groups.append(buf)
217
+ next_scan_start = group_start + GROUP_BITS
177
218
  return groups
178
219
 
179
220
 
180
221
  def count_valid_blocks(bits: np.ndarray) -> int:
181
222
  """Diagnostic: count 26-bit windows that pass CRC for any block position 0..3."""
182
- if not isinstance(bits, np.ndarray):
183
- bits = np.asarray(bits, dtype=np.uint8)
223
+ bits = _coerce_bits(bits)
184
224
  total = 0
185
225
  for i in range(0, len(bits) - BLOCK_BITS + 1, BLOCK_BITS):
186
226
  word = bits_to_word(bits[i : i + BLOCK_BITS])
@@ -13,6 +13,7 @@ Supported types:
13
13
  4A — Clock-Time
14
14
  """
15
15
 
16
+ from collections import deque
16
17
  from collections.abc import Iterable, Sequence
17
18
  from dataclasses import dataclass, field
18
19
  from datetime import datetime
@@ -31,6 +32,10 @@ class StationInfo:
31
32
  pi: int | None = None
32
33
  pty: int | None = None
33
34
  ps_chars: list[str] = field(default_factory=lambda: [" "] * PS_LEN)
35
+ _ps_candidate: list[str] = field(default_factory=lambda: [" "] * PS_LEN)
36
+ _ps_segments_seen: int = 0
37
+ _ps_history: deque[str] = field(default_factory=lambda: deque(maxlen=4))
38
+ _ps_stable_count: int = 0
34
39
  rt_chars: list[str] = field(default_factory=lambda: [" "] * RT_LEN)
35
40
  rt_ab_flag: int | None = None
36
41
  clock_times: list[ClockTime] = field(default_factory=list)
@@ -38,8 +43,25 @@ class StationInfo:
38
43
 
39
44
  @property
40
45
  def ps_name(self) -> str:
46
+ """Validated Programme Service name.
47
+
48
+ Dynamic-PS stations often scroll text through the PS field. This value
49
+ remains empty until the same complete 8-character frame has been seen
50
+ in two consecutive PS completion cycles, then exposes the validated
51
+ frame with the historical right-padding stripped.
52
+ """
41
53
  return "".join(self.ps_chars).rstrip()
42
54
 
55
+ @property
56
+ def validated_ps_name(self) -> str:
57
+ """Alias for :attr:`ps_name`, explicit about the validation semantics."""
58
+ return self.ps_name
59
+
60
+ @property
61
+ def latest_ps_candidate(self) -> str:
62
+ """Most recently received complete 8-character PS frame, without validation."""
63
+ return self._ps_history[-1] if self._ps_history else ""
64
+
43
65
  @property
44
66
  def rt_text(self) -> str:
45
67
  text = "".join(self.rt_chars)
@@ -83,8 +105,23 @@ def parse_group(group_bytes: Sequence[int], info: StationInfo) -> StationInfo:
83
105
  if group_type == 0:
84
106
  seg = b & 0x3 # 2-bit segment address (0..3)
85
107
  # Block D carries two PS characters
86
- info.ps_chars[seg * 2] = _safe_char((d >> 8) & 0xFF)
87
- info.ps_chars[seg * 2 + 1] = _safe_char(d & 0xFF)
108
+ bit = 1 << seg
109
+ if not info._ps_segments_seen & bit:
110
+ info._ps_candidate[seg * 2] = _safe_char((d >> 8) & 0xFF)
111
+ info._ps_candidate[seg * 2 + 1] = _safe_char(d & 0xFF)
112
+ info._ps_segments_seen |= bit
113
+ if info._ps_segments_seen == 0b1111:
114
+ candidate = "".join(info._ps_candidate)
115
+ previous = info._ps_history[-1] if info._ps_history else None
116
+ info._ps_history.append(candidate)
117
+ if candidate == previous:
118
+ info._ps_stable_count += 1
119
+ else:
120
+ info._ps_stable_count = 1
121
+ if info._ps_stable_count >= 2:
122
+ info.ps_chars[:] = list(candidate)
123
+ info._ps_candidate = [" "] * PS_LEN
124
+ info._ps_segments_seen = 0
88
125
 
89
126
  elif group_type == 2:
90
127
  ab_flag = (b >> 4) & 0x1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rdsclock
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -26,6 +26,8 @@ Provides-Extra: audio
26
26
  Requires-Dist: sounddevice>=0.4.6; extra == "audio"
27
27
  Provides-Extra: plot
28
28
  Requires-Dist: matplotlib>=3.7.0; extra == "plot"
29
+ Provides-Extra: fast
30
+ Requires-Dist: numba>=0.60.0; extra == "fast"
29
31
  Provides-Extra: dev
30
32
  Requires-Dist: pytest>=7.4.0; extra == "dev"
31
33
  Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
@@ -265,11 +267,13 @@ the local antenna picks up.
265
267
 
266
268
  ## Status
267
269
 
268
- - **0.2.0** — current release. First version that decodes real FM
269
- broadcasts (earlier 0.1.x worked only on synthetic IQ see the
270
- 0.2.0 entry in [`CHANGELOG.md`](CHANGELOG.md)). Pre-1.0; the CLI
271
- and on-disk formats may still change.
272
- - 236 tests including a real-IQ regression backed by a 6 s capture
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
+ - 240+ tests including a real-IQ regression backed by a 6 s capture
273
277
  of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
274
278
  (tracked by SonarCloud).
275
279
 
@@ -11,5 +11,8 @@ pytest-cov>=4.1.0
11
11
  ruff>=0.5.0
12
12
  black>=24.0
13
13
 
14
+ [fast]
15
+ numba>=0.60.0
16
+
14
17
  [plot]
15
18
  matplotlib>=3.7.0
@@ -41,9 +41,10 @@ class TestDesignAudioFilter:
41
41
  assert taps.dtype == np.float32
42
42
  assert len(taps) == 129 # default numtaps
43
43
 
44
- def test_rejects_non_integer_ratio(self):
45
- with pytest.raises(ValueError):
46
- design_audio_filter(1_000_000, 48_000)
44
+ def test_non_integer_ratio_returns_filter_without_decimation(self):
45
+ taps, decim = design_audio_filter(1_000_000, 48_000)
46
+ assert decim == 1
47
+ assert taps.dtype == np.float32
47
48
 
48
49
 
49
50
  class TestFmAudioFromIq:
@@ -65,6 +66,18 @@ class TestFmAudioFromIq:
65
66
  # Normalisation pushes peak to 0.5.
66
67
  assert np.max(np.abs(audio)) == pytest.approx(0.5, rel=0.01)
67
68
 
69
+ def test_rational_resampling_ratio_succeeds(self):
70
+ fs_in = 250_000
71
+ fs_out = 48_000
72
+ n = np.arange(fs_in // 10)
73
+ iq = np.exp(1j * 2 * np.pi * 1_000 * n / fs_in).astype(np.complex64)
74
+
75
+ audio = fm_audio_from_iq(iq, fs_in=fs_in, fs_out=fs_out)
76
+
77
+ expected_len = len(iq) * fs_out / fs_in
78
+ assert abs(len(audio) - expected_len) <= 2
79
+ assert audio.dtype == np.float32
80
+
68
81
  def test_skip_normalisation(self):
69
82
  fs_in = 1_200_000
70
83
  n = np.arange(fs_in // 2)
@@ -160,7 +160,7 @@ def test_audio_play_file_complex64_and_u8_paths(tmp_path, monkeypatch):
160
160
  u8_path = tmp_path / "u8-ish.iq"
161
161
  np.full(512, 200 + 0j, dtype=np.complex64).tofile(u8_path)
162
162
  play_iq_file(str(u8_path), fs_in=50_000, fs_audio=48_000)
163
- assert sd.play_calls[-1][1] == 2_000
163
+ assert sd.play_calls[-1][1] == 48_000
164
164
 
165
165
 
166
166
  def test_audio_play_file_keyboard_interrupt_stops(tmp_path, monkeypatch, capsys):
@@ -207,6 +207,31 @@ def test_audio_play_live_streams_until_keyboard_interrupt(
207
207
  assert "Interrupted" in capsys.readouterr().out
208
208
 
209
209
 
210
+ def test_audio_play_live_supports_rational_audio_rate(monkeypatch, capsys):
211
+ sd = _FakeSoundDevice()
212
+ clients: list[_FakeAudioClient] = []
213
+
214
+ class RecordingClient(_FakeAudioClient):
215
+ def __init__(self, *args, **kwargs):
216
+ super().__init__(*args, **kwargs)
217
+ clients.append(self)
218
+
219
+ monkeypatch.setitem(sys.modules, "sounddevice", sd)
220
+ monkeypatch.setattr("rdsclock.audio.RtlTcpClient", RecordingClient)
221
+
222
+ play_iq_live(
223
+ 95.5,
224
+ fs_sdr=50_000,
225
+ fs_audio=48_000,
226
+ chunk_samples=256,
227
+ )
228
+
229
+ assert ("sample_rate", 50_000) in clients[0].calls
230
+ assert sd.stream_writes
231
+ assert sd.stream_writes[0].dtype == np.float32
232
+ assert "Interrupted" in capsys.readouterr().out
233
+
234
+
210
235
  @pytest.mark.parametrize("func,args", [(play_iq_file, ("x.iq",)), (play_iq_live, (95.5,))])
211
236
  def test_audio_play_requires_sounddevice(func, args, monkeypatch):
212
237
  real_import = builtins.__import__
@@ -171,7 +171,7 @@ class TestAudioPlaybackCoverage:
171
171
  ):
172
172
  audio.play_iq_file("recording.iq", fs_in=250_000, fs_audio=48_000)
173
173
 
174
- fm_audio_from_iq.assert_called_once_with(iq, fs_in=250_000, fs_out=2_000)
174
+ fm_audio_from_iq.assert_called_once_with(iq, fs_in=250_000, fs_out=48_000)
175
175
  sd_stop.assert_called_once()
176
176
 
177
177
 
@@ -78,6 +78,19 @@ class TestCostas:
78
78
  # After a short transient, the signal still has constant phase
79
79
  assert np.max(np.abs(np.imag(out[20:]))) < 1.0
80
80
 
81
+ def test_python_fallback_matches_public_wrapper(self, monkeypatch):
82
+ samples = np.array([1 + 0j, 0.5 + 0.2j, -1 + 0.1j], dtype=np.complex64)
83
+ monkeypatch.setattr(dsp, "_COSTAS_LOOP_BPSK_NUMBA", None)
84
+ wrapper = dsp.costas_loop_bpsk(samples, alpha=0.1, beta=0.002)
85
+ direct = dsp._costas_loop_bpsk_python(samples, alpha=0.1, beta=0.002)
86
+ np.testing.assert_array_equal(wrapper, direct)
87
+
88
+ def test_python_fallback_wraps_phase_both_directions(self):
89
+ positive = np.array([1 + 1j, 1 + 0j], dtype=np.complex64)
90
+ negative = np.array([1 - 1j, 1 + 0j], dtype=np.complex64)
91
+ assert dsp._costas_loop_bpsk_python(positive, alpha=4.0, beta=0.0).shape == positive.shape
92
+ assert dsp._costas_loop_bpsk_python(negative, alpha=4.0, beta=0.0).shape == negative.shape
93
+
81
94
 
82
95
  class TestBiphaseRecovery:
83
96
  def test_agc_normalizes_mean_magnitude(self):
@@ -10,6 +10,8 @@ from rdsclock.rds_blocks import (
10
10
  OFFSET_C,
11
11
  OFFSET_C_PRIME,
12
12
  OFFSETS,
13
+ _bits_to_words_26,
14
+ _crc10_many,
13
15
  bits_to_word,
14
16
  block_dataword,
15
17
  block_valid,
@@ -25,6 +27,19 @@ from rdsclock.rds_blocks import (
25
27
  )
26
28
 
27
29
 
30
+ def _crc10_reference(dataword: int) -> int:
31
+ reg = 0
32
+ for i in range(DATA_BITS - 1, -1, -1):
33
+ reg = (reg << 1) | ((dataword >> i) & 1)
34
+ if reg & (1 << 10):
35
+ reg ^= 0x5B9 | (1 << 10)
36
+ for _ in range(10):
37
+ reg <<= 1
38
+ if reg & (1 << 10):
39
+ reg ^= 0x5B9 | (1 << 10)
40
+ return reg & ((1 << 10) - 1)
41
+
42
+
28
43
  class TestCrc10:
29
44
  def test_zero_input(self):
30
45
  assert crc10(0) == 0
@@ -45,6 +60,16 @@ class TestCrc10:
45
60
  b = crc10(0x1235)
46
61
  assert a != b
47
62
 
63
+ def test_matches_shift_register_for_full_u16_space(self):
64
+ expected = np.fromiter(
65
+ (_crc10_reference(v) for v in range(1 << DATA_BITS)),
66
+ dtype=np.uint16,
67
+ count=1 << DATA_BITS,
68
+ )
69
+ actual = _crc10_many(np.arange(1 << DATA_BITS, dtype=np.uint32))
70
+ np.testing.assert_array_equal(actual, expected)
71
+ assert crc10(0x1FFFF) == int(expected[-1])
72
+
48
73
 
49
74
  class TestEncodeBlock:
50
75
  def test_roundtrip(self):
@@ -116,6 +141,14 @@ class TestBitstream:
116
141
  bits = [1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0]
117
142
  assert bits_to_word(bits) == 0xCCCC
118
143
 
144
+ def test_bits_to_word_26_uses_vectorized_weights(self):
145
+ bits = np.array(
146
+ [1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1],
147
+ dtype=np.uint8,
148
+ )
149
+ assert bits_to_word(bits) == 0b10110010101011100101101001
150
+ np.testing.assert_array_equal(_bits_to_words_26(bits[None, :]), [bits_to_word(bits)])
151
+
119
152
  def test_find_groups_in_clean_bitstream(self):
120
153
  # Encode 3 groups with different data, then find them all.
121
154
  all_blocks = []
@@ -152,6 +185,9 @@ class TestBitstream:
152
185
  a, _, _, _ = group_bytes_to_words(g)
153
186
  assert a in (0xBABE, 0xCAFE)
154
187
 
188
+ def test_find_groups_short_stream_returns_empty(self):
189
+ assert find_groups_in_bitstream(np.zeros(GROUP_BITS - 1, dtype=np.uint8)) == []
190
+
155
191
 
156
192
  class TestDifferential:
157
193
  def test_roundtrip(self):
@@ -14,30 +14,80 @@ from rdsclock.rds_groups import (
14
14
  )
15
15
 
16
16
 
17
+ def _validated_ps_groups(pi: int, ps_name: str) -> list[bytearray]:
18
+ groups = encode_ps_groups(pi=pi, ps_name=ps_name)
19
+ return groups + groups
20
+
21
+
17
22
  class TestPsParsing:
18
23
  def test_single_segment(self):
19
24
  g = encode_group_0a(pi=0xABCD, ps_segment_index=0, ps_chars="RM")
20
25
  info = StationInfo()
21
26
  parse_group(g, info)
22
27
  assert info.pi == 0xABCD
23
- assert info.ps_name == "RM"
28
+ assert info.ps_name == ""
29
+ assert info.latest_ps_candidate == ""
24
30
 
25
31
  def test_all_segments(self):
26
- groups = encode_ps_groups(pi=0x3F44, ps_name="RMF FM ")
32
+ groups = _validated_ps_groups(pi=0x3F44, ps_name="RMF FM ")
27
33
  info = parse_groups(groups)
28
34
  assert info.pi == 0x3F44
29
35
  assert info.ps_name == "RMF FM"
36
+ assert info.validated_ps_name == "RMF FM"
37
+ assert info.latest_ps_candidate == "RMF FM "
30
38
 
31
39
  def test_short_name_padded(self):
32
- groups = encode_ps_groups(pi=0x1234, ps_name="ABC")
40
+ groups = _validated_ps_groups(pi=0x1234, ps_name="ABC")
33
41
  info = parse_groups(groups)
34
42
  assert info.ps_name == "ABC"
35
43
 
36
44
  def test_full_eight_chars(self):
37
- groups = encode_ps_groups(pi=0x4321, ps_name="RadioZET")
45
+ groups = _validated_ps_groups(pi=0x4321, ps_name="RadioZET")
38
46
  info = parse_groups(groups)
39
47
  assert info.ps_name == "RadioZET"
40
48
 
49
+ def test_single_rotation_is_only_latest_candidate(self):
50
+ groups = encode_ps_groups(pi=0x3001, ps_name="JEDYNKA ")
51
+ info = parse_groups(groups)
52
+ assert info.ps_name == ""
53
+ assert info.latest_ps_candidate == "JEDYNKA "
54
+ assert info._ps_stable_count == 1
55
+
56
+ def test_static_ps_validates_after_two_rotations(self):
57
+ groups = _validated_ps_groups(pi=0x3001, ps_name="JEDYNKA ")
58
+ info = parse_groups(groups)
59
+ assert info.ps_name == "JEDYNKA"
60
+ assert info.validated_ps_name == "JEDYNKA"
61
+ assert info.latest_ps_candidate == "JEDYNKA "
62
+
63
+ def test_dynamic_ps_requires_consecutive_matching_completions(self):
64
+ plus = encode_ps_groups(pi=0x3002, ps_name="+PLUS+ ")
65
+ web = encode_ps_groups(pi=0x3002, ps_name="WWW.RPL ")
66
+ info = parse_groups(plus + web + plus)
67
+ assert info.ps_name == ""
68
+ assert info.latest_ps_candidate == "+PLUS+ "
69
+ assert info._ps_stable_count == 1
70
+
71
+ for group in plus:
72
+ parse_group(group, info)
73
+
74
+ assert info.ps_name == "+PLUS+"
75
+ assert info.latest_ps_candidate == "+PLUS+ "
76
+ assert list(info._ps_history) == ["+PLUS+ ", "WWW.RPL ", "+PLUS+ ", "+PLUS+ "]
77
+
78
+ def test_duplicate_segment_does_not_overwrite_candidate_rotation(self):
79
+ groups = [
80
+ encode_group_0a(pi=0x3003, ps_segment_index=0, ps_chars="JE"),
81
+ encode_group_0a(pi=0x3003, ps_segment_index=0, ps_chars="XX"),
82
+ encode_group_0a(pi=0x3003, ps_segment_index=1, ps_chars="DY"),
83
+ encode_group_0a(pi=0x3003, ps_segment_index=2, ps_chars="NK"),
84
+ encode_group_0a(pi=0x3003, ps_segment_index=3, ps_chars="A "),
85
+ ]
86
+ info = parse_groups(groups + groups)
87
+ assert info.ps_name == "JEDYNKA"
88
+ assert info.latest_ps_candidate == "JEDYNKA "
89
+ assert info._ps_segments_seen == 0
90
+
41
91
  def test_group_count(self):
42
92
  groups = encode_ps_groups(pi=0x1111, ps_name="HELLO ")
43
93
  info = parse_groups(groups)
@@ -35,7 +35,7 @@ class TestBitstreamSynth:
35
35
  bits = rds_groups_to_bits(ps, differential=False) # no differential coding for simplicity
36
36
  groups = find_groups_in_bitstream(bits)
37
37
  assert len(groups) >= len(ps)
38
- info = parse_groups(groups[: len(ps)])
38
+ info = parse_groups(groups[: len(ps)] + groups[: len(ps)])
39
39
  assert info.pi == 0xCAFE
40
40
  assert info.ps_name == "HELLO"
41
41
 
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