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:
commit
db3e0c79b9
39 changed files with 13473 additions and 0 deletions
315
bandsaunter/ranges.py
Executable file
315
bandsaunter/ranges.py
Executable 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue