rdsclock 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- rdsclock/__main__.py +6 -0
- rdsclock/audio.py +174 -0
- rdsclock/channelizer.py +111 -0
- rdsclock/cli.py +852 -0
- rdsclock/decoder.py +182 -0
- rdsclock/dsp.py +252 -0
- rdsclock/plot.py +171 -0
- rdsclock/py.typed +0 -0
- rdsclock/rds_blocks.py +237 -0
- rdsclock/rds_clock.py +175 -0
- rdsclock/rds_groups.py +188 -0
- rdsclock/recon.py +380 -0
- rdsclock/rtl_tcp.py +128 -0
- rdsclock/synth.py +254 -0
- rdsclock/time_consensus.py +325 -0
- rdsclock-0.1.0.dist-info/METADATA +492 -0
- rdsclock-0.1.0.dist-info/RECORD +22 -0
- rdsclock-0.1.0.dist-info/WHEEL +5 -0
- rdsclock-0.1.0.dist-info/entry_points.txt +2 -0
- rdsclock-0.1.0.dist-info/licenses/LICENSE +201 -0
- rdsclock-0.1.0.dist-info/top_level.txt +1 -0
rdsclock/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""rdsclock — passive RDS Clock-Time receiver for FM via RTL-SDR.
|
|
2
|
+
|
|
3
|
+
Package focused on RDS (Radio Data System) decoding, with special
|
|
4
|
+
emphasis on Group 4A (Clock-Time) — recovering UTC from FM broadcast
|
|
5
|
+
in GPS-denied or NTP-unavailable environments.
|
|
6
|
+
|
|
7
|
+
Modules:
|
|
8
|
+
dsp DSP primitives: filters, FM demod, Costas, clock recovery.
|
|
9
|
+
rds_blocks Block-level CRC, syndromes, bitstream → byte groups.
|
|
10
|
+
rds_clock MJD ↔ datetime, Group 4A encode/decode (IEC 62106-2:2021).
|
|
11
|
+
rds_groups PS / RT / Clock-Time parser and encoder.
|
|
12
|
+
synth Synthetic IQ generator: bits → BPSK 57 kHz → MPX → FM IQ + AWGN.
|
|
13
|
+
decoder End-to-end IQ → groups → StationInfo pipeline.
|
|
14
|
+
channelizer Wide-band capture → N narrow per-station channels.
|
|
15
|
+
rtl_tcp Minimal rtl_tcp client (context manager).
|
|
16
|
+
time_consensus Multi-source time consensus, trust scoring, holdover.
|
|
17
|
+
recon Continuous passive time receiver (live + offline modes).
|
|
18
|
+
cli Command-line interface: generate / decode / live / multi /
|
|
19
|
+
scan / recon / demo.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
rdsclock/__main__.py
ADDED
rdsclock/audio.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""FM audio playback helpers — live RTL-SDR or recorded IQ.
|
|
2
|
+
|
|
3
|
+
This module is intentionally **separate** from the decoder pipeline:
|
|
4
|
+
``decoder`` cares about RDS payload bits, while ``audio`` produces
|
|
5
|
+
listenable mono audio. The two paths can run side by side on the
|
|
6
|
+
same IQ stream (audio for the operator, RDS for time sync).
|
|
7
|
+
|
|
8
|
+
The ``sounddevice`` dependency is optional and only imported when an
|
|
9
|
+
audio sink is needed; the rest of the package works without it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy.signal import firwin, lfilter, lfilter_zi
|
|
14
|
+
|
|
15
|
+
from .rtl_tcp import RtlTcpClient
|
|
16
|
+
|
|
17
|
+
DEFAULT_AUDIO_RATE = 48_000
|
|
18
|
+
DEFAULT_LIVE_FS = 1_200_000 # 1.2 MS/s → decimation x25 to 48 kHz
|
|
19
|
+
DEFAULT_AUDIO_CUTOFF = 16_000 # mono FM upper edge (~15 kHz audio + margin)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fm_demod(iq: np.ndarray) -> np.ndarray:
|
|
23
|
+
"""FM demodulation by instantaneous-phase differentiation."""
|
|
24
|
+
z = iq[1:] * np.conj(iq[:-1])
|
|
25
|
+
return np.angle(z).astype(np.float32)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def design_audio_filter(
|
|
29
|
+
fs_in: int,
|
|
30
|
+
fs_out: int = DEFAULT_AUDIO_RATE,
|
|
31
|
+
cutoff_hz: float = DEFAULT_AUDIO_CUTOFF,
|
|
32
|
+
numtaps: int = 129,
|
|
33
|
+
) -> tuple[np.ndarray, int]:
|
|
34
|
+
"""Design the mono-audio LPF used during decimation from ``fs_in`` to ``fs_out``.
|
|
35
|
+
|
|
36
|
+
Returns ``(taps, decimation_factor)``. ``fs_in`` must be an integer
|
|
37
|
+
multiple of ``fs_out``.
|
|
38
|
+
"""
|
|
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
|
|
42
|
+
nyq = fs_in / 2.0
|
|
43
|
+
taps = firwin(numtaps=numtaps, cutoff=cutoff_hz / nyq).astype(np.float32)
|
|
44
|
+
return taps, decim
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fm_audio_from_iq(
|
|
48
|
+
iq: np.ndarray,
|
|
49
|
+
fs_in: int,
|
|
50
|
+
fs_out: int = DEFAULT_AUDIO_RATE,
|
|
51
|
+
cutoff_hz: float = DEFAULT_AUDIO_CUTOFF,
|
|
52
|
+
normalise: bool = True,
|
|
53
|
+
) -> np.ndarray:
|
|
54
|
+
"""Convert an IQ buffer into a mono audio float32 buffer at ``fs_out``.
|
|
55
|
+
|
|
56
|
+
Steps: FM phase demodulation → LPF (cutoff = ``cutoff_hz``) → decimation
|
|
57
|
+
to ``fs_out`` → optional peak normalisation to ±0.5 to avoid clipping.
|
|
58
|
+
"""
|
|
59
|
+
taps, decim = design_audio_filter(fs_in, fs_out, cutoff_hz=cutoff_hz)
|
|
60
|
+
fm = fm_demod(iq)
|
|
61
|
+
audio, _ = lfilter(taps, 1.0, fm), None
|
|
62
|
+
audio = audio[::decim]
|
|
63
|
+
if normalise:
|
|
64
|
+
peak = float(np.max(np.abs(audio)) + 1e-6)
|
|
65
|
+
audio = (audio / peak) * 0.5
|
|
66
|
+
return audio.astype(np.float32)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def play_iq_live(
|
|
70
|
+
freq_mhz: float,
|
|
71
|
+
host: str = "localhost",
|
|
72
|
+
port: int = 1234,
|
|
73
|
+
fs_sdr: int = DEFAULT_LIVE_FS,
|
|
74
|
+
fs_audio: int = DEFAULT_AUDIO_RATE,
|
|
75
|
+
chunk_samples: int | None = None,
|
|
76
|
+
gain_db: float | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Stream live FM audio from an RTL-SDR via rtl_tcp.
|
|
79
|
+
|
|
80
|
+
Press Ctrl+C to stop. Requires ``sounddevice``.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
import sounddevice as sd
|
|
84
|
+
except ImportError as exc:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"live FM playback requires the optional 'sounddevice' dependency; "
|
|
87
|
+
"install with: pip install 'rdsclock[audio]'"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
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)
|
|
93
|
+
|
|
94
|
+
sd.default.samplerate = fs_audio
|
|
95
|
+
sd.default.channels = 1
|
|
96
|
+
|
|
97
|
+
print(f"Connecting to rtl_tcp {host}:{port}…")
|
|
98
|
+
with RtlTcpClient(host=host, port=port) as client:
|
|
99
|
+
info = client.info
|
|
100
|
+
print(f" Tuner: {info.tuner_type}, gain count: {info.gain_count}")
|
|
101
|
+
client.set_sample_rate(fs_sdr)
|
|
102
|
+
if gain_db is None:
|
|
103
|
+
client.set_gain_mode_auto()
|
|
104
|
+
print(" Gain: AGC")
|
|
105
|
+
else:
|
|
106
|
+
client.set_gain_mode_manual(int(gain_db * 10))
|
|
107
|
+
print(f" Gain: manual {gain_db} dB")
|
|
108
|
+
client.set_frequency(int(freq_mhz * 1e6))
|
|
109
|
+
# Discard a warm-up chunk so AGC/PLL settle before audio starts.
|
|
110
|
+
_ = client.read_iq(chunk_samples, settle_s=0.2)
|
|
111
|
+
print(
|
|
112
|
+
f"Playing FM {freq_mhz} MHz @ {fs_sdr / 1e6:.2f} MS/s "
|
|
113
|
+
f"→ audio {fs_audio} Hz (Ctrl+C to stop)…"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with sd.OutputStream(dtype="float32") as stream:
|
|
117
|
+
try:
|
|
118
|
+
while True:
|
|
119
|
+
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
|
|
125
|
+
stream.write(audio.astype("float32"))
|
|
126
|
+
except KeyboardInterrupt:
|
|
127
|
+
print("\nInterrupted by operator (Ctrl+C).")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def play_iq_file(
|
|
131
|
+
path: str,
|
|
132
|
+
fs_in: int = 250_000,
|
|
133
|
+
fs_audio: int = DEFAULT_AUDIO_RATE,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Play back a previously captured ``.iq`` file as mono FM audio.
|
|
136
|
+
|
|
137
|
+
Autodetects the on-disk format (``uint8`` rtl_sdr vs ``complex64``)
|
|
138
|
+
using the same heuristic as :func:`rdsclock.decoder.decode_file`.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
import sounddevice as sd
|
|
142
|
+
except ImportError as exc:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"audio playback requires the optional 'sounddevice' dependency; "
|
|
145
|
+
"install with: pip install 'rdsclock[audio]'"
|
|
146
|
+
) from exc
|
|
147
|
+
|
|
148
|
+
from . import dsp
|
|
149
|
+
|
|
150
|
+
iq = dsp.read_iq_complex64(path)
|
|
151
|
+
if len(iq) == 0 or np.max(np.abs(iq[:1000])) > 100:
|
|
152
|
+
iq = dsp.read_iq_u8(path)
|
|
153
|
+
|
|
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
|
+
|
|
168
|
+
duration_s = len(audio) / fs_audio
|
|
169
|
+
print(f"Playing {path}: {duration_s:.1f}s of audio @ {fs_audio} Hz (Ctrl+C to stop)…")
|
|
170
|
+
try:
|
|
171
|
+
sd.play(audio, samplerate=fs_audio, blocking=True)
|
|
172
|
+
except KeyboardInterrupt:
|
|
173
|
+
sd.stop()
|
|
174
|
+
print("\nInterrupted by operator (Ctrl+C).")
|
rdsclock/channelizer.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Digital channelizer: wide-band IQ → N narrow per-station channels.
|
|
2
|
+
|
|
3
|
+
The classic way to decode several FM stations with a single RTL-SDR:
|
|
4
|
+
capture (for example) 2.4 MS/s centred between the stations of interest,
|
|
5
|
+
then for each station shift it to baseband and decimate to 250 kS/s.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from math import gcd
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy.signal import firwin, lfilter, resample_poly
|
|
15
|
+
|
|
16
|
+
from . import dsp
|
|
17
|
+
from .decoder import DecodeResult, decode_iq
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ChannelSpec:
|
|
22
|
+
"""Specification of one channel to extract from a wide-band capture."""
|
|
23
|
+
|
|
24
|
+
freq_hz: float # absolute station frequency, e.g. 95.5e6
|
|
25
|
+
label: str = "" # human-friendly name, e.g. "RMF FM 95.5"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_channel(
|
|
29
|
+
iq_wide: np.ndarray,
|
|
30
|
+
fs_wide: float,
|
|
31
|
+
f_center: float,
|
|
32
|
+
f_channel: float,
|
|
33
|
+
fs_out: float = dsp.DEFAULT_INPUT_FS,
|
|
34
|
+
half_bw: float = 100_000.0,
|
|
35
|
+
) -> np.ndarray:
|
|
36
|
+
"""Extract a single FM channel (typically 250 kS/s) from a wide capture.
|
|
37
|
+
|
|
38
|
+
1. Frequency shift the channel to DC.
|
|
39
|
+
2. Lowpass to ``half_bw`` (≈100 kHz for FM).
|
|
40
|
+
3. Decimate to ``fs_out``.
|
|
41
|
+
"""
|
|
42
|
+
if fs_out > fs_wide:
|
|
43
|
+
raise ValueError("fs_out must be <= fs_wide")
|
|
44
|
+
delta = f_channel - f_center
|
|
45
|
+
n = np.arange(len(iq_wide), dtype=np.float64)
|
|
46
|
+
mixed = (iq_wide * np.exp(-1j * 2 * np.pi * delta * n / fs_wide)).astype(np.complex64)
|
|
47
|
+
|
|
48
|
+
# Lowpass designed at fs_wide with cutoff = half_bw
|
|
49
|
+
n_taps = 257
|
|
50
|
+
taps = firwin(numtaps=n_taps, cutoff=half_bw, fs=fs_wide).astype(np.float32)
|
|
51
|
+
filtered = lfilter(taps, 1.0, mixed)
|
|
52
|
+
|
|
53
|
+
a = int(round(fs_out))
|
|
54
|
+
b = int(round(fs_wide))
|
|
55
|
+
g = gcd(a, b)
|
|
56
|
+
up, down = a // g, b // g
|
|
57
|
+
return resample_poly(filtered, up, down).astype(np.complex64)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ChannelDecodeResult:
|
|
62
|
+
spec: ChannelSpec
|
|
63
|
+
iq_samples: int
|
|
64
|
+
result: DecodeResult
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decode_channels(
|
|
68
|
+
iq_wide: np.ndarray,
|
|
69
|
+
fs_wide: float,
|
|
70
|
+
f_center: float,
|
|
71
|
+
channels: Sequence[ChannelSpec],
|
|
72
|
+
fs_out: float = dsp.DEFAULT_INPUT_FS,
|
|
73
|
+
max_workers: int = 4,
|
|
74
|
+
progress: Callable[[str], None] | None = None,
|
|
75
|
+
) -> list[ChannelDecodeResult]:
|
|
76
|
+
"""For each channel: extract → decode. Parallel via ``ThreadPoolExecutor``."""
|
|
77
|
+
|
|
78
|
+
def emit(msg: str) -> None:
|
|
79
|
+
if progress:
|
|
80
|
+
progress(msg)
|
|
81
|
+
|
|
82
|
+
def worker(spec: ChannelSpec) -> ChannelDecodeResult:
|
|
83
|
+
emit(f"channel {spec.label or f'{spec.freq_hz / 1e6:.2f}MHz'}: extract")
|
|
84
|
+
iq_chan = extract_channel(iq_wide, fs_wide, f_center, spec.freq_hz, fs_out=fs_out)
|
|
85
|
+
emit(f"channel {spec.label or f'{spec.freq_hz / 1e6:.2f}MHz'}: decode")
|
|
86
|
+
res = decode_iq(iq_chan, fs=fs_out)
|
|
87
|
+
return ChannelDecodeResult(spec=spec, iq_samples=len(iq_chan), result=res)
|
|
88
|
+
|
|
89
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
90
|
+
results = list(pool.map(worker, channels))
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def auto_center(freqs_hz: Sequence[float]) -> float:
|
|
95
|
+
"""Mid-point of a set of station frequencies (used to centre the SDR)."""
|
|
96
|
+
if not freqs_hz:
|
|
97
|
+
raise ValueError("freqs_hz must be non-empty")
|
|
98
|
+
return (max(freqs_hz) + min(freqs_hz)) / 2.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def required_bandwidth(freqs_hz: Sequence[float], guard_hz: float = 200_000.0) -> float:
|
|
102
|
+
"""Minimum sample rate needed to capture all stations with a guard band."""
|
|
103
|
+
if not freqs_hz:
|
|
104
|
+
raise ValueError("freqs_hz must be non-empty")
|
|
105
|
+
span = max(freqs_hz) - min(freqs_hz)
|
|
106
|
+
return span + 2 * guard_hz
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fits_in_window(freqs_hz: Sequence[float], fs_wide: float, guard_hz: float = 200_000.0) -> bool:
|
|
110
|
+
"""Whether all stations fit inside a single capture at ``fs_wide``."""
|
|
111
|
+
return required_bandwidth(freqs_hz, guard_hz=guard_hz) <= fs_wide
|