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
277
bandsaunter/config.py
Executable file
277
bandsaunter/config.py
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue