rdsclock 0.2.1__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.
- {rdsclock-0.2.1 → rdsclock-0.2.2}/PKG-INFO +7 -7
- {rdsclock-0.2.1 → rdsclock-0.2.2}/README.md +6 -6
- {rdsclock-0.2.1 → rdsclock-0.2.2}/pyproject.toml +1 -1
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/__init__.py +1 -1
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/audio.py +23 -30
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rds_groups.py +39 -2
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/PKG-INFO +7 -7
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_audio.py +16 -3
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_coverage_edges.py +26 -1
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_coverage_extra.py +1 -1
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rds_groups.py +54 -4
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_synth.py +1 -1
- {rdsclock-0.2.1 → rdsclock-0.2.2}/LICENSE +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/setup.cfg +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/__main__.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/channelizer.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/cli.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/decoder.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/dsp.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/plot.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/py.typed +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rds_blocks.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rds_clock.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/recon.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/rtl_tcp.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/synth.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock/time_consensus.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/SOURCES.txt +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/dependency_links.txt +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/entry_points.txt +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/requires.txt +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/src/rdsclock.egg-info/top_level.txt +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_channelizer.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_cli.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_cli_with_fake_sdr.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_decoder_synthetic.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_dsp.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_plot.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rds_blocks.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rds_clock.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_real_iq_regression.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_real_recordings.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_recon.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_recon_offline.py +0 -0
- {rdsclock-0.2.1 → rdsclock-0.2.2}/tests/test_rtl_tcp.py +0 -0
- {rdsclock-0.2.1 → 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.
|
|
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
|
|
@@ -267,12 +267,12 @@ the local antenna picks up.
|
|
|
267
267
|
|
|
268
268
|
## Status
|
|
269
269
|
|
|
270
|
-
- **0.2.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
276
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
277
277
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
278
278
|
(tracked by SonarCloud).
|
|
@@ -229,12 +229,12 @@ the local antenna picks up.
|
|
|
229
229
|
|
|
230
230
|
## Status
|
|
231
231
|
|
|
232
|
-
- **0.2.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
238
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
239
239
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
240
240
|
(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.
|
|
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"
|
|
@@ -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)``.
|
|
37
|
-
|
|
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
|
|
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``) →
|
|
57
|
-
to ``fs_out`` → optional peak normalisation to ±0.5 to avoid
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
87
|
-
info.
|
|
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.
|
|
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
|
|
@@ -267,12 +267,12 @@ the local antenna picks up.
|
|
|
267
267
|
|
|
268
268
|
## Status
|
|
269
269
|
|
|
270
|
-
- **0.2.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
276
|
- 240+ tests including a real-IQ regression backed by a 6 s capture
|
|
277
277
|
of Polskie Radio Trójka 98.8 from Warsaw; line coverage **100 %**
|
|
278
278
|
(tracked by SonarCloud).
|
|
@@ -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
|
|
45
|
-
|
|
46
|
-
|
|
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] ==
|
|
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=
|
|
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
|
|
|
@@ -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 == "
|
|
28
|
+
assert info.ps_name == ""
|
|
29
|
+
assert info.latest_ps_candidate == ""
|
|
24
30
|
|
|
25
31
|
def test_all_segments(self):
|
|
26
|
-
groups =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|