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 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
@@ -0,0 +1,6 @@
1
+ """Entry point dla `python -m rdsclock`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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).")
@@ -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