bandsaunter/bandsaunter/config.py
The Dust Council dee262e130 Draw the waterfall for everything that never spoke
Every capture that is not voice, or whose voice yields five characters
or fewer of transcript, now gets a PNG of the waterfall it would have
painted on screen: spectrogram from the IQ where it was kept, from the
demodulated audio otherwise, captioned and labelled either way.

The browser shows it in the picture panel, but only when there is no
transcript, Morse or decoded data to show instead.

  bandsaunter waterfall [PATH...] [--all] [--redraw] [--min-chars N]

draws them after the fact for recordings already on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-03 18:36:00 -07:00

337 lines
14 KiB
Python
Executable file

"""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 Lockout, 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", "remember_lockouts"]
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
# -- trunking control channels --------------------------------------
skip_control: bool = True # spot them, abandon them, move on
control_seconds: float = 20.0 # unbroken carrier a look-alike must hold
lockout_control: bool = False # also add them to the lock-out list
# -- 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
decode_data: bool = True # read packets out of data signals
decode_images: bool = True # SSTV, weather satellites and shortwave fax
# A picture of every capture nobody can read: not voice, or voice
# that produced no words worth the name.
waterfall: bool = True
waterfall_min_chars: int = 5
# -- 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
# -- callsigns and the map --------------------------------------------
callsign_lookup: bool = True # ask the licence database who they are
kml_file: str = "callsigns.kml" # map of who was heard ("" = none)
# -- behaviour ------------------------------------------------------
lockout: list[Lockout] = field(default_factory=list)
lockout_width: float = 12_500.0
save_lockouts: bool = True # write runtime lock-outs back to disk
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
plain: bool = False # line per hit instead of the live display
log_file: str = "scan_log.jsonl"
# ------------------------------------------------------------------
def __post_init__(self):
# Lock-outs arrive as bare numbers from older settings files, as
# dicts from newer ones, and as either from callers. One type from
# here on.
self.lockout = [Lockout.coerce(v) for v in self.lockout]
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 remember_lockouts(cfg: "ScanConfig",
directory: Path | None = None) -> Path | None:
"""Write the lock-out list back to the settings file it came from.
Only that one key: a scan's config also holds whatever was passed on the
command line for this run, and saving all of it would quietly make those
one-off options permanent. So the file on disk is read, its lock-outs
replaced, and the rest left exactly as it was.
Returns where it was written, or None if it could not be -- locking out a
birdie must never be the thing that ends a scan.
A run started with --no-config has no settings file, and writing one
anyway would be the opposite of what was asked: the flag exists to leave
the saved settings alone. So without a source to write back to, nothing
is written.
"""
source = getattr(cfg, "_source_path", None)
if not source and directory is None:
return None
path = Path(source) if source else (Path(directory) / "config.yaml")
try:
data = {}
if path.exists():
with open(path) as fh:
data = yaml.safe_load(fh) or {}
data["lockout"] = [lk.to_dict() for lk in cfg.lockout]
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w") as fh:
yaml.safe_dump(data, fh, sort_keys=False, default_flow_style=False)
tmp.replace(path)
return path
except (OSError, yaml.YAMLError):
return None
def delete_profile(name: str, directory: Path | None = None) -> bool:
path = profile_path(name, directory)
if path.exists():
path.unlink()
return True
return False