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

277
bandsaunter/config.py Executable file
View file

@ -0,0 +1,277 @@
"""Scanner configuration: defaults, validation, and YAML profiles."""
from __future__ import annotations
import os
from dataclasses import dataclass, field, asdict, fields
from pathlib import Path
import yaml
from .ranges import ScanRange, parse_range_list
__all__ = ["ScanConfig", "DEFAULT_CONFIG_DIR", "DEFAULT_CONFIG_PATH",
"load_config", "save_config", "list_profiles", "profile_path",
"load_default", "save_default", "delete_profile",
"is_first_run", "DEFAULT_OUTPUT_DIR"]
DEFAULT_CONFIG_DIR = Path(
os.environ.get("BANDSAUNTER_CONFIG_DIR",
Path.home() / ".config" / "bandsaunter")
)
# Settings saved here are picked up by every run, before any command-line
# option is applied. Named profiles live alongside it in the same directory.
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
# Suggested on first run. Somewhere visible rather than under a dot
# directory: these are recordings people want to browse and play.
DEFAULT_OUTPUT_DIR = "~/bandsaunter"
def is_first_run(directory: Path | None = None) -> bool:
"""True when no settings have ever been saved."""
return not (Path(directory or DEFAULT_CONFIG_DIR) / "config.yaml").exists()
@dataclass
class ScanConfig:
"""Everything that controls a scan run.
The two dwell settings the scanner is built around:
``record_seconds``
Record for at most this long on one signal before going back to
scanning, even if the signal is still up. 0 means "no limit", which
is what you want for capturing a whole conversation.
``hang_seconds``
How long the channel must stay quiet before the transmission is
treated as over. Gaps shorter than this are recorded straight
through, so a two-way exchange stays in one file across the natural
pauses between overs. "Quiet" means no real signal: silence, static
and interference all count towards it, so a burst of noise during a
pause does not hold the receiver on a finished conversation.
"""
# -- what to scan ---------------------------------------------------
ranges: list[ScanRange] = field(default_factory=list)
# -- the two headline dwell settings --------------------------------
record_seconds: float = 30.0 # max record time per hit (0 = unlimited)
hang_seconds: float = 2.0 # quiet time before the scan resumes
max_record_seconds: float = 900.0 # absolute ceiling, even when record is 0
# -- detection ------------------------------------------------------
threshold_db: float = 10.0 # dB of margin over the noise
min_record_seconds: float = 0.5 # discard blips shorter than this
dwell_seconds: float = 0.05 # capture time per tuner step while sweeping
probe_seconds: float = 0.4 # look before choosing a demodulator
resolution_hz: float = 3_000.0 # FFT bin width during the sweep
squelch_margin_db: float = 3.0 # hysteresis: drop below thr - this to end
adaptive_floor: bool = True # smooth the per-bin noise baseline
detector: str = "peak" # "peak" holds bursts, "avg" is quieter
detector_bias_db: float | None = None # None = derive it from the detector
# -- content gate: only keep captures that carry something ----------
require_signal: bool = True # discard captures with no real content
accept: list[str] = field(
default_factory=lambda: ["voice", "cw", "digital"])
min_signal_score: float = 0.45 # confidence needed to keep a capture
min_voice_score: float = 0.45 # speech-likeness needed to call it voice
verify_seconds: float = 1.5 # how often to re-check a live capture
verify_max_seconds: float = 6.0 # give up on a contentless capture by here
max_detections_per_step: int = 4
# -- radio ----------------------------------------------------------
device_index: int = 0
sample_rate: int = 2_048_000
gain: float | str = "auto"
ppm: int = 0
agc: bool = False
bias_tee: bool = False
offset_tuning: bool = False
direct_sampling: int | str = "auto"
usable_fraction: float = 0.75
dc_guard_hz: float = 8_000.0 # LO offset that keeps the DC spike clear
# -- recording ------------------------------------------------------
output_dir: str = "~/bandsaunter"
save_audio: bool = True
save_iq: bool = False
audio_rate: int = 16_000
iq_format: str = "cf32" # cf32 or cs16
classify: bool = True
decode_morse: bool = True
# -- one file per frequency ------------------------------------------
combine_by_frequency: bool = False
combine_tolerance_hz: float = 6_250.0
combine_keep_individual: bool = False
announce_timestamps: bool = True
announce_frequency: bool = False
announce_engine: str = "auto"
# -- speech to text ---------------------------------------------------
transcribe: bool = False
transcribe_engine: str = "auto"
transcribe_model: str = "base.en"
transcribe_language: str = "en"
transcribe_min_seconds: float = 1.0
# -- behaviour ------------------------------------------------------
lockout: list[float] = field(default_factory=list)
lockout_width: float = 12_500.0
revisit_seconds: float = 8.0 # ignore a frequency again this soon
max_cycles: int = 0 # 0 = run forever
max_runtime_seconds: float = 0.0 # 0 = no limit
quiet: bool = False
log_file: str = "scan_log.jsonl"
# ------------------------------------------------------------------
def validate(self) -> list[str]:
"""Return a list of human-readable problems (empty when config is sane)."""
errs = []
if not self.ranges:
errs.append("no frequency ranges configured")
if self.record_seconds < 0:
errs.append("record_seconds cannot be negative")
if self.hang_seconds < 0:
errs.append("hang_seconds cannot be negative")
if self.min_record_seconds < 0:
errs.append("min_record_seconds cannot be negative")
if self.max_record_seconds < 0:
errs.append("max_record_seconds cannot be negative")
if self.record_seconds and self.min_record_seconds > self.record_seconds:
errs.append("min_record_seconds is longer than record_seconds, so "
"no recording would ever be kept")
if not (0.005 <= self.dwell_seconds <= 5.0):
errs.append("dwell_seconds should be between 0.005 and 5")
if self.threshold_db <= 0:
errs.append("threshold_db must be positive")
if not (0.1 <= self.usable_fraction <= 0.95):
errs.append("usable_fraction should be between 0.1 and 0.95")
if self.audio_rate < 4000:
errs.append("audio_rate is too low to carry voice")
from .quality import CATEGORIES
bad = [c for c in self.accept if c not in CATEGORIES]
if bad:
errs.append("unknown accept categories: " + ", ".join(bad)
+ " (choose from " + ", ".join(CATEGORIES) + ")")
if self.require_signal and not self.accept:
errs.append("require_signal is on but no categories are accepted, "
"so nothing could ever be recorded")
if not (0.0 <= self.min_signal_score <= 1.0):
errs.append("min_signal_score must be between 0 and 1")
if self.detector not in ("peak", "avg"):
errs.append('detector must be "peak" or "avg"')
if self.iq_format not in ("cf32", "cs16"):
errs.append("iq_format must be cf32 or cs16")
for r in self.ranges:
if r.stop <= 0 or r.start <= 0:
errs.append(f"range {r.label!r} has a non-positive frequency")
return errs
# -- serialisation ---------------------------------------------------
def to_dict(self) -> dict:
d = asdict(self)
d["ranges"] = [r.to_dict() for r in self.ranges]
return d
@classmethod
def from_dict(cls, d: dict) -> "ScanConfig":
d = dict(d or {})
raw = d.pop("ranges", [])
known = {f.name for f in fields(cls)}
unknown = set(d) - known
for k in unknown:
d.pop(k)
cfg = cls(**{k: v for k, v in d.items() if k in known})
out = []
for item in raw:
if isinstance(item, dict):
out.append(ScanRange.from_dict(item))
elif isinstance(item, str):
out.extend(parse_range_list(item))
cfg.ranges = out
if unknown:
cfg._unknown_keys = sorted(unknown) # surfaced by the CLI
return cfg
def copy(self) -> "ScanConfig":
return ScanConfig.from_dict(self.to_dict())
def profile_path(name: str, directory: Path | None = None) -> Path:
directory = Path(directory or DEFAULT_CONFIG_DIR)
if name.endswith((".yaml", ".yml")):
p = Path(name)
return p if p.is_absolute() or p.parent != Path(".") else directory / p.name
return directory / f"{name}.yaml"
def save_config(cfg: ScanConfig, name: str, directory: Path | None = None) -> Path:
path = profile_path(name, directory)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as fh:
yaml.safe_dump(cfg.to_dict(), fh, sort_keys=False, default_flow_style=False)
return path
def load_config(name_or_path: str, directory: Path | None = None) -> ScanConfig:
path = Path(name_or_path)
if not path.exists():
path = profile_path(name_or_path, directory)
if not path.exists():
raise FileNotFoundError(f"no such profile or config file: {name_or_path}")
with open(path) as fh:
data = yaml.safe_load(fh) or {}
cfg = ScanConfig.from_dict(data)
cfg._source_path = str(path)
return cfg
def list_profiles(directory: Path | None = None) -> list[Path]:
directory = Path(directory or DEFAULT_CONFIG_DIR)
if not directory.exists():
return []
return sorted(p for p in directory.glob("*.yaml"))
def load_default(directory: Path | None = None) -> tuple["ScanConfig", Path | None]:
"""Load the saved settings, or defaults if none have been saved.
Returns ``(config, path)`` where ``path`` is None when nothing was loaded,
so the caller can tell the user where its settings came from.
"""
path = Path(directory or DEFAULT_CONFIG_DIR) / "config.yaml"
if not path.exists():
return ScanConfig(), None
try:
with open(path) as fh:
data = yaml.safe_load(fh) or {}
except (OSError, yaml.YAMLError):
return ScanConfig(), None
cfg = ScanConfig.from_dict(data)
cfg._source_path = str(path)
return cfg, path
def save_default(cfg: "ScanConfig", directory: Path | None = None) -> Path:
"""Write the settings that every later run should start from."""
path = Path(directory or DEFAULT_CONFIG_DIR) / "config.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".yaml.tmp")
with open(tmp, "w") as fh:
yaml.safe_dump(cfg.to_dict(), fh, sort_keys=False,
default_flow_style=False)
tmp.replace(path) # atomic, so a crash cannot leave a half file
return path
def delete_profile(name: str, directory: Path | None = None) -> bool:
path = profile_path(name, directory)
if path.exists():
path.unlink()
return True
return False