bandsaunter/bandsaunter/device.py
The Dust Council db3e0c79b9 Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:50:20 -07:00

480 lines
18 KiB
Python
Executable file

"""High level RTL-SDR device control built on the ctypes binding."""
from __future__ import annotations
import contextlib
import ctypes
import math
import os
import threading
import time
from collections import deque
from dataclasses import dataclass, field
import numpy as np
from ._quiet import suppress_stderr
from .librtlsdr import READ_ASYNC_CB, LibRtlSdr, RtlSdrError
__all__ = ["RtlSdrDevice", "DeviceInfo", "list_devices", "RtlSdrError",
"quiet_driver", "set_driver_messages",
"R820T_MIN_HZ", "R820T_MAX_HZ", "DIRECT_SAMPLING_MAX_HZ"]
# Practical tuning limits for the common R820T/R828D front ends. Anything
# below this needs direct sampling (bypassing the tuner entirely).
R820T_MIN_HZ = 24_000_000
R820T_MAX_HZ = 1_766_000_000
DIRECT_SAMPLING_MAX_HZ = 28_800_000 # usable Q-branch span at 2.4 MSPS-ish clock
# The RTL2832U only locks these sample-rate windows.
_RATE_WINDOWS = ((225_001, 300_000), (900_001, 3_200_000))
# librtlsdr wants read sizes that are a multiple of 512 bytes; 16 kB blocks are
# the size its own async reader uses and behave well over USB.
_READ_GRANULE = 512
# librtlsdr writes its own messages ("Found Rafael Micro R820T tuner",
# "Allocating 15 zero-copy buffers", "[R82XX] PLL not locked!") straight to
# file descriptor 2 from C. Those land in the middle of the live display and
# break its cursor tracking, so the header ends up drawn several times over.
# Set BANDSAUNTER_DRIVER_MESSAGES=1 to see them while debugging.
_SHOW_DRIVER_MESSAGES = os.environ.get("BANDSAUNTER_DRIVER_MESSAGES", "") not in \
("", "0", "no", "false")
def set_driver_messages(show: bool) -> None:
"""Let the driver's own messages through again.
They are worth seeing while diagnosing a device -- "Detached kernel
driver", the tuner it found -- and only a nuisance during the live
display, which they draw straight over.
"""
global _SHOW_DRIVER_MESSAGES
_SHOW_DRIVER_MESSAGES = bool(show)
@contextlib.contextmanager
def quiet_driver():
"""Silence the driver's own chatter for the duration of a C call.
Only around the call itself, so a Python traceback on stderr is never
swallowed: the window is a few microseconds of library code.
"""
with suppress_stderr(not _SHOW_DRIVER_MESSAGES):
yield
@dataclass
class DeviceInfo:
index: int
name: str
manufacturer: str = "?"
product: str = "?"
serial: str = "?"
def describe(self) -> str:
return (f"[{self.index}] {self.name} "
f"(sn: {self.serial})")
def list_devices() -> list[DeviceInfo]:
"""Enumerate attached RTL-SDR devices (empty list if none / no driver)."""
try:
lib = LibRtlSdr()
except RtlSdrError:
return []
out = []
with quiet_driver():
for i in range(lib.device_count()):
manu, prod, serial = lib.usb_strings(i)
out.append(DeviceInfo(i, lib.device_name(i), manu, prod, serial))
return out
def clamp_sample_rate(rate: int) -> int:
"""Snap a requested sample rate into a window the RTL2832U can actually lock."""
rate = int(rate)
for lo, hi in _RATE_WINDOWS:
if lo <= rate <= hi:
return rate
# Choose the nearest legal edge.
edges = [e for w in _RATE_WINDOWS for e in w]
return min(edges, key=lambda e: abs(e - rate))
@dataclass
class RtlSdrDevice:
"""Owns one dongle and serialises all access to it.
The scanner retunes constantly, so this class keeps track of the settling
cost of each retune and flushes the USB pipeline so that samples handed
back are guaranteed to have been captured *after* the tune completed.
"""
index: int = 0
sample_rate: int = 2_048_000
gain: float | str = "auto" # dB, or "auto" for the tuner AGC
ppm: int = 0
agc: bool = False # RTL2832 digital AGC
bias_tee: bool = False
offset_tuning: bool = False
direct_sampling: int | str = "auto" # 0/1/2 or "auto" (enable below 24 MHz)
settle_seconds: float = 0.006
_lib: LibRtlSdr | None = field(default=None, init=False, repr=False)
_dev: ctypes.c_void_p | None = field(default=None, init=False, repr=False)
_buf: ctypes.Array | None = field(default=None, init=False, repr=False)
_buf_len: int = field(default=0, init=False, repr=False)
_ds_mode: int = field(default=0, init=False, repr=False)
_center: int = field(default=0, init=False, repr=False)
_gains: list[float] = field(default_factory=list, init=False, repr=False)
tuner: str = field(default="unknown", init=False)
retunes: int = field(default=0, init=False)
samples_read: int = field(default=0, init=False)
_streaming: bool = field(default=False, init=False, repr=False)
_stream_thread: object = field(default=None, init=False, repr=False)
_stream_cb: object = field(default=None, init=False, repr=False)
_stream_chunks: object = field(default=None, init=False, repr=False)
_stream_lock: object = field(default=None, init=False, repr=False)
_stream_ready: object = field(default=None, init=False, repr=False)
_stream_error: object = field(default=None, init=False, repr=False)
_stream_have: int = field(default=0, init=False, repr=False)
_stream_max: int = field(default=0, init=False, repr=False)
_stream_dropped: int = field(default=0, init=False, repr=False)
_partial: object = field(default=None, init=False, repr=False)
# -- lifecycle -----------------------------------------------------
def open(self) -> "RtlSdrDevice":
self._lib = LibRtlSdr()
n = self._lib.device_count()
if n == 0:
raise RtlSdrError(
"no RTL-SDR device found.\n"
"Check `lsusb` for a Realtek RTL2832/2838, make sure the DVB-T "
"kernel module is blacklisted, and that you have permission to "
"open the USB device."
)
if self.index >= n:
raise RtlSdrError(f"device index {self.index} out of range (found {n})")
with quiet_driver():
self._dev = self._lib.open(self.index)
self.tuner = self._lib.get_tuner_type(self._dev)
self._gains = self._lib.get_tuner_gains(self._dev)
self.sample_rate = clamp_sample_rate(self.sample_rate)
self._lib.set_sample_rate(self._dev, self.sample_rate)
self.sample_rate = (self._lib.get_sample_rate(self._dev)
or self.sample_rate)
if self.ppm:
self._lib.set_freq_correction(self._dev, self.ppm)
self._lib.set_agc_mode(self._dev, self.agc)
if self.bias_tee:
self._lib.set_bias_tee(self._dev, True)
if self.offset_tuning:
self._lib.set_offset_tuning(self._dev, True)
self.apply_gain(self.gain)
self._lib.reset_buffer(self._dev)
return self
def close(self) -> None:
self.stop_stream()
if self._dev is not None and self._lib is not None:
try:
if self.bias_tee:
self._lib.set_bias_tee(self._dev, False)
except RtlSdrError:
pass
with quiet_driver():
self._lib.close(self._dev)
self._dev = None
def __enter__(self):
return self.open()
def __exit__(self, *exc):
self.close()
return False
@property
def is_open(self) -> bool:
return self._dev is not None
# -- configuration -------------------------------------------------
@property
def available_gains(self) -> list[float]:
return list(self._gains)
def apply_gain(self, gain: float | str) -> None:
"""Set tuner gain. ``"auto"`` hands control to the tuner AGC."""
self.gain = gain
if isinstance(gain, str) and gain.lower() in ("auto", "agc", ""):
self._lib.set_tuner_gain_mode(self._dev, False)
return
self._lib.set_tuner_gain_mode(self._dev, True)
target = float(gain)
if self._gains:
target = min(self._gains, key=lambda g: abs(g - target))
self._lib.set_tuner_gain(self._dev, target)
@property
def current_gain(self) -> float | str:
if isinstance(self.gain, str):
return "auto"
try:
return self._lib.get_tuner_gain(self._dev)
except RtlSdrError:
return self.gain
def set_sample_rate(self, rate: int) -> int:
rate = clamp_sample_rate(rate)
if rate != self.sample_rate:
with quiet_driver():
self._lib.set_sample_rate(self._dev, rate)
self.sample_rate = self._lib.get_sample_rate(self._dev) or rate
self._lib.reset_buffer(self._dev)
return self.sample_rate
def _wants_direct_sampling(self, hz: float) -> int:
if self.direct_sampling == "auto":
return 2 if hz < R820T_MIN_HZ else 0
return int(self.direct_sampling)
def _set_direct_sampling(self, mode: int) -> None:
if mode == self._ds_mode:
return
with quiet_driver():
ok = self._lib.set_direct_sampling(self._dev, mode)
if ok:
self._ds_mode = mode
# Switching the signal path invalidates whatever is in flight.
self._lib.reset_buffer(self._dev)
elif mode:
raise RtlSdrError(
"this dongle does not support direct sampling, so frequencies "
f"below {R820T_MIN_HZ/1e6:.0f} MHz cannot be tuned"
)
def tune(self, hz: float, settle: bool = True) -> int:
"""Tune the front end. Returns the frequency the hardware reports."""
hz = int(round(hz))
mode = self._wants_direct_sampling(hz)
self._set_direct_sampling(mode)
if mode == 0 and not (R820T_MIN_HZ <= hz <= R820T_MAX_HZ):
raise RtlSdrError(
f"{hz/1e6:.4f} MHz is outside the tuner's range "
f"({R820T_MIN_HZ/1e6:.0f}-{R820T_MAX_HZ/1e6:.0f} MHz)"
)
with quiet_driver():
self._lib.set_center_freq(self._dev, hz)
self._center = self._lib.get_center_freq(self._dev) or hz
self.retunes += 1
if settle and self.settle_seconds > 0:
time.sleep(self.settle_seconds)
return self._center
@property
def center_freq(self) -> int:
return self._center
@property
def direct_sampling_mode(self) -> int:
"""0 = normal tuner path, 1 = I branch, 2 = Q branch."""
return self._ds_mode
def can_tune(self, hz: float) -> bool:
mode = self._wants_direct_sampling(hz)
if mode:
return 0 < hz <= DIRECT_SAMPLING_MAX_HZ
return R820T_MIN_HZ <= hz <= R820T_MAX_HZ
# -- capture -------------------------------------------------------
def _ensure_buffer(self, n_bytes: int) -> ctypes.Array:
if self._buf is None or self._buf_len < n_bytes:
self._buf = (ctypes.c_ubyte * n_bytes)()
self._buf_len = n_bytes
return self._buf
def flush(self) -> None:
"""Drop anything the RTL2832 already queued (call right after a retune)."""
self._lib.reset_buffer(self._dev)
def read_samples(self, count: int, flush: bool = False) -> np.ndarray:
"""Read ``count`` complex samples, returned as complex64 in [-1, 1).
``flush=True`` discards the in-flight USB buffer first so the samples
are known to post-date the most recent tune.
"""
if self._dev is None:
raise RtlSdrError("device is not open")
if flush:
self._lib.reset_buffer(self._dev)
n_bytes = int(count) * 2
# Round up to the USB granule; librtlsdr short-reads otherwise.
n_bytes = int(math.ceil(n_bytes / _READ_GRANULE) * _READ_GRANULE)
buf = self._ensure_buffer(n_bytes)
got = self._lib.read_sync(self._dev, buf, n_bytes)
if got <= 0:
raise RtlSdrError("USB read returned no data")
raw = np.frombuffer(buf, dtype=np.uint8, count=got)
if raw.size % 2:
raw = raw[:-1]
# 127.4 rather than 127.5 matches the RTL2832's actual DC bias.
iq = raw.astype(np.float32)
iq -= 127.4
iq *= np.float32(1.0 / 128.0)
out = iq.view(np.complex64) if iq.flags.c_contiguous else iq.copy().view(np.complex64)
out = out[:count]
self.samples_read += out.size
return out
def read_seconds(self, seconds: float, flush: bool = False) -> np.ndarray:
return self.read_samples(int(self.sample_rate * seconds), flush=flush)
# -- continuous streaming --------------------------------------------
# Synchronous reads lose whatever the dongle sends between calls: the USB
# host only moves data while a transfer is outstanding, so every
# microsecond spent demodulating is a microsecond of samples thrown away.
# The recording then holds less than it should and plays back too fast.
# Streaming keeps a ring of transfers queued so the capture is gapless.
def start_stream(self, buf_num: int = 15, buf_len: int = 262144,
max_seconds: float = 4.0) -> None:
if self._streaming:
return
if self._dev is None:
raise RtlSdrError("device is not open")
self._stream_chunks = deque()
self._stream_have = 0
self._stream_max = int(max_seconds * self.sample_rate * 2)
self._stream_dropped = 0
self._stream_lock = threading.Lock()
self._stream_ready = threading.Event()
self._stream_error = None
self._partial = np.zeros(0, dtype=np.complex64)
def _on_buffer(buf_ptr, length, _ctx):
try:
raw = np.ctypeslib.as_array(buf_ptr, shape=(int(length),)).copy()
except Exception:
return
with self._stream_lock:
if self._stream_have + raw.size > self._stream_max:
# The consumer has fallen behind for real; drop the oldest
# so the capture stays current, and record that it happened.
while self._stream_chunks and \
self._stream_have + raw.size > self._stream_max:
old = self._stream_chunks.popleft()
self._stream_have -= old.size
self._stream_dropped += old.size
self._stream_chunks.append(raw)
self._stream_have += raw.size
self._stream_ready.set()
self._stream_cb = READ_ASYNC_CB(_on_buffer)
def _run():
try:
with quiet_driver():
self._lib.read_async(self._dev, self._stream_cb,
buf_num, buf_len)
except RtlSdrError as exc:
self._stream_error = exc
self._stream_ready.set()
with quiet_driver():
self._lib.reset_buffer(self._dev)
self._streaming = True
self._stream_thread = threading.Thread(target=_run, daemon=True,
name="rtlsdr-stream")
self._stream_thread.start()
def stop_stream(self) -> None:
if not self._streaming:
return
self._streaming = False
try:
with quiet_driver():
self._lib.cancel_async(self._dev)
except Exception:
pass
if self._stream_thread is not None:
self._stream_thread.join(timeout=2.0)
self._stream_thread = None
self._stream_cb = None
@property
def streaming(self) -> bool:
return self._streaming
@property
def dropped_samples(self) -> int:
return self._stream_dropped // 2
def read_stream(self, count: int, timeout: float = 3.0) -> np.ndarray:
"""Pull ``count`` complex samples from the running stream."""
if not self._streaming:
return self.read_samples(count)
need_bytes = int(count) * 2
deadline = time.time() + timeout
parts = []
have = 0
if self._partial.size:
parts.append(self._partial)
have += self._partial.size * 2
self._partial = np.zeros(0, dtype=np.complex64)
while have < need_bytes:
if self._stream_error is not None:
raise self._stream_error
with self._stream_lock:
chunk = self._stream_chunks.popleft() if self._stream_chunks else None
if chunk is not None:
self._stream_have -= chunk.size
elif not self._stream_chunks:
self._stream_ready.clear()
if chunk is None:
if time.time() > deadline:
raise RtlSdrError("timed out waiting for samples")
self._stream_ready.wait(0.2)
continue
parts.append(self._to_complex(chunk))
have += chunk.size
out = np.concatenate([p if p.dtype == np.complex64 else p for p in parts])
if out.size > count:
self._partial = out[count:]
out = out[:count]
self.samples_read += out.size
return out
@staticmethod
def _to_complex(raw: np.ndarray) -> np.ndarray:
if raw.size % 2:
raw = raw[:-1]
iq = raw.astype(np.float32)
iq -= 127.4
iq *= np.float32(1.0 / 128.0)
return iq.view(np.complex64)
# -- diagnostics ---------------------------------------------------
def status(self) -> dict:
return {
"index": self.index,
"tuner": self.tuner,
"sample_rate": self.sample_rate,
"center_freq": self._center,
"gain": self.current_gain,
"ppm": self.ppm,
"direct_sampling": self._ds_mode,
"retunes": self.retunes,
"samples_read": self.samples_read,
}