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>
This commit is contained in:
The Dust Council 2026-08-21 20:50:20 -07:00
commit db3e0c79b9
39 changed files with 13473 additions and 0 deletions

315
bandsaunter/ranges.py Executable file
View file

@ -0,0 +1,315 @@
"""Frequency ranges, parsing, and turning them into a sweep plan."""
from __future__ import annotations
import math
import re
from dataclasses import asdict, dataclass
from .bandplan import BandPreset, by_key, fmt_hz, presets_covering
__all__ = ["ScanRange", "TuneStep", "parse_frequency", "parse_range",
"parse_range_list", "build_plan", "fmt_hz", "RangeError"]
class RangeError(ValueError):
"""Raised for un-parseable user frequency input."""
_SUFFIX = {
"": 1.0, "hz": 1.0,
"k": 1e3, "khz": 1e3,
"m": 1e6, "mhz": 1e6,
"g": 1e9, "ghz": 1e9,
}
_NUM = re.compile(r"^\s*([0-9]*\.?[0-9]+)\s*([a-zA-Z]*)\s*$")
def parse_frequency(text: str, default_unit: str = "") -> float:
"""Parse ``146.52M``, ``146520000``, ``433.92 MHz``, ``14074k`` -> Hz.
A bare number with no suffix is read as MHz when it is small enough to be
unambiguous (under 10000), otherwise as Hz -- which is how people
actually write frequencies.
"""
if text is None:
raise RangeError("empty frequency")
s = str(text).strip().replace(",", "").replace("_", "")
if not s:
raise RangeError("empty frequency")
m = _NUM.match(s)
if not m:
raise RangeError(f"cannot read {text!r} as a frequency")
value, suffix = float(m.group(1)), m.group(2).lower()
if not suffix:
suffix = default_unit.lower()
if not suffix:
# No unit anywhere: guess from magnitude.
return value * 1e6 if value < 10_000 else value
if suffix not in _SUFFIX:
raise RangeError(f"unknown frequency unit {m.group(2)!r} in {text!r}")
return value * _SUFFIX[suffix]
@dataclass
class ScanRange:
"""One start/stop pair the user wants swept."""
start: float
stop: float
step: float = 12_500.0
mode: str = "auto"
bandwidth: float = 0.0
label: str = ""
preset_key: str = ""
enabled: bool = True
threshold_db: float | None = None
def __post_init__(self):
self.start = float(self.start)
self.stop = float(self.stop)
if self.stop < self.start:
self.start, self.stop = self.stop, self.start
if self.stop == self.start:
# A single frequency: give it enough width to actually measure.
pad = max(self.step, 12_500.0) / 2.0
self.start -= pad
self.stop += pad
if self.step <= 0:
self.step = 12_500.0
if not self.label:
self.label = f"{fmt_hz(self.start)}-{fmt_hz(self.stop)}"
@property
def span(self) -> float:
return self.stop - self.start
@classmethod
def from_preset(cls, preset: BandPreset, **over) -> "ScanRange":
kw = dict(start=preset.start, stop=preset.stop, step=preset.step,
mode=preset.mode, bandwidth=preset.bandwidth,
label=preset.name, preset_key=preset.key)
kw.update(over)
return cls(**kw)
def _covering(self, hz: float, need_bandwidth: bool = False):
"""Band-plan segments to resolve against at one frequency.
A range that came from a band-plan preset resolves against segments of
the same service first. Without that, scanning the whole of 70 cm
would hand 433-435 MHz to the ISM preset that overlaps it there, and
an amateur repeater would be treated as an unlicensed device.
"""
covering = [p for p in presets_covering(hz)
if (p.bandwidth > 0 if need_bandwidth else p.mode != "auto")]
own = by_key(self.preset_key) if self.preset_key else None
if own is not None:
same = [p for p in covering if p.category == own.category]
if same:
return same
return covering
def resolved_mode(self, freq: float | None = None) -> str:
"""The demodulator to use, resolving ``auto`` against the band plan."""
if self.mode and self.mode != "auto":
return self.mode
hz = freq if freq is not None else 0.5 * (self.start + self.stop)
# Whole-band entries are skipped: they defer to the segments beneath
# them, so resolving to one would hand "auto" straight back.
covering = self._covering(hz)
if covering:
return min(covering, key=lambda p: p.span).mode
return "nfm"
def resolved_bandwidth(self, freq: float | None = None) -> float:
if self.bandwidth > 0:
return self.bandwidth
hz = freq if freq is not None else 0.5 * (self.start + self.stop)
covering = self._covering(hz, need_bandwidth=True)
if covering:
return min(covering, key=lambda p: p.span).bandwidth
return max(self.step, 12_500.0)
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> "ScanRange":
known = {f for f in cls.__dataclass_fields__}
return cls(**{k: v for k, v in d.items() if k in known})
def describe(self) -> str:
state = "" if self.enabled else " (disabled)"
return (f"{self.label}: {fmt_hz(self.start)} - {fmt_hz(self.stop)} "
f"step {fmt_hz(self.step)} mode {self.mode}{state}")
_RANGE_SPLIT = re.compile(r"\s*(?:-{1,2}|\.{2}|to|:{2})\s*", re.I)
def parse_range(text: str) -> ScanRange:
"""Parse one range specification.
Accepted forms::
144M-148M explicit start and stop
144-148M unit carried over to both ends
146.52M a single frequency
144M-148M/25k with an explicit step
144M-148M/25k@nfm with a step and a demodulator
gmrs any band-plan preset key
"""
if text is None:
raise RangeError("empty range")
s = str(text).strip()
if not s:
raise RangeError("empty range")
mode = "auto"
if "@" in s:
s, _, mode = s.partition("@")
mode = mode.strip().lower() or "auto"
step = None
if "/" in s:
s, _, step_s = s.partition("/")
step = parse_frequency(step_s, default_unit="khz")
s = s.strip()
preset = by_key(s)
if preset is not None and preset.is_group:
raise RangeError(
f"{preset.key!r} covers {len(preset.expand())} separate ranges; "
"it can only be used where a list of ranges is accepted")
if preset is not None:
r = ScanRange.from_preset(preset)
if step:
r.step = step
if mode != "auto":
r.mode = mode
return r
parts = [p for p in _RANGE_SPLIT.split(s) if p.strip()]
if len(parts) == 1:
hz = parse_frequency(parts[0])
r = ScanRange(hz, hz, step or 12_500.0, mode)
elif len(parts) == 2:
# "144-148M": the unit on the right end applies to the left too.
right_unit = _NUM.match(parts[1])
unit = right_unit.group(2) if right_unit else ""
lo = parse_frequency(parts[0], default_unit=unit)
hi = parse_frequency(parts[1])
r = ScanRange(lo, hi, step or 12_500.0, mode)
else:
raise RangeError(f"cannot read {text!r} as a frequency range")
return r
def parse_range_list(text: str | list[str]) -> list[ScanRange]:
"""Parse a comma/newline separated list into ranges (unlimited count).
A band-plan key that stands for a set of others -- ``all-cw``, say --
expands here into one range per member.
"""
if isinstance(text, (list, tuple)):
items = list(text)
else:
items = re.split(r"[,\n;]+", str(text))
out = []
for item in items:
item = item.strip()
if not item:
continue
preset = by_key(item)
if preset is not None and preset.is_group:
out.extend(ScanRange.from_preset(m) for m in preset.expand())
else:
out.append(parse_range(item))
return out
# ---------------------------------------------------------------------------
# Sweep planning
# ---------------------------------------------------------------------------
@dataclass
class TuneStep:
"""One tuner position, plus the slice of its spectrum we trust."""
center: float
low: float # first frequency this step is responsible for
high: float # last frequency this step is responsible for
range_index: int
range_label: str = ""
mode: str = "auto"
index: int = 0 # position in the plan; keys the noise-floor memory
@property
def width(self) -> float:
return self.high - self.low
def describe(self) -> str:
return f"{fmt_hz(self.center)} covering {fmt_hz(self.low)}-{fmt_hz(self.high)}"
def build_plan(ranges: list[ScanRange], sample_rate: float,
usable_fraction: float = 0.75,
dc_guard: float = 8_000.0,
max_steps: int = 200_000,
tunable=None) -> list[TuneStep]:
"""Split every enabled range into tuner steps.
The tuner is deliberately *not* centred on the span it covers. An RTL2832
always shows a DC spike at whatever it is tuned to, so a centred step would
blank a hole in the middle of every range -- and for a range narrower than
one capture, that hole is the whole point of interest. Instead each step
parks the local oscillator ``dc_guard`` below the span it is responsible
for, so the covered frequencies sit entirely on one side of DC, clear of
both the spike and the anti-alias roll-off.
``tunable`` is an optional predicate; when the offset LO would fall outside
the hardware's range the step falls back to centring.
"""
if sample_rate <= 0 or usable_fraction <= 0:
raise RangeError("sample rate and usable fraction must be positive")
# Half the usable width, because only one side of DC is used per step.
usable = float(sample_rate) * float(usable_fraction) / 2.0
if usable <= dc_guard:
raise RangeError("usable bandwidth is too small for the DC guard band")
steps: list[TuneStep] = []
for i, r in enumerate(ranges):
if not r.enabled:
continue
n = max(1, int(math.ceil(r.span / usable)))
if len(steps) + n > max_steps:
raise RangeError(
f"plan would need over {max_steps} tuner steps; "
"narrow the ranges or raise the sample rate"
)
width = r.span / n
for k in range(n):
low = r.start + k * width
high = low + width
center = low - dc_guard
if tunable is not None and not tunable(center):
center = 0.5 * (low + high) # fall back rather than lose the step
steps.append(TuneStep(
center=center,
low=low, high=high,
range_index=i, range_label=r.label,
mode=r.mode, index=len(steps),
))
return steps
def plan_summary(ranges: list[ScanRange], steps: list[TuneStep],
dwell_seconds: float) -> str:
total = sum(r.span for r in ranges if r.enabled)
n_en = sum(1 for r in ranges if r.enabled)
cycle = len(steps) * dwell_seconds
return (f"{n_en} range(s), {fmt_hz(total)} total span, {len(steps)} tuner "
f"steps, ~{cycle:.1f} s per full sweep")