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

879
bandsaunter/cli.py Executable file
View file

@ -0,0 +1,879 @@
"""Command line interface."""
from __future__ import annotations
import argparse
import json
import signal
import sys
import time
from pathlib import Path
from rich.console import Console
from rich.prompt import Confirm
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from . import __version__
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
is_first_run, list_profiles, load_config, load_default,
save_config, save_default)
from .device import RtlSdrError, list_devices, set_driver_messages
from .librtlsdr import load_error
from . import settings as st
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
from .scanner import Scanner, ScannerCallbacks
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
from .ui import KeyReader, ScanDisplay, print_band_table, print_hit
console = Console()
# ---------------------------------------------------------------------------
# argument parsing
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="bandsaunter",
description="Scan, record and identify signals with an RTL-SDR.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
bandsaunter interactive setup
bandsaunter scan -r 144M-148M -r 420M-450M two ranges
bandsaunter scan -b gmrs -b marine-vhf band-plan presets
bandsaunter scan -b 2m --record 30 --hang 3 record 30 s max, 3 s squelch tail
bandsaunter scan -b 2m --record 0 --hang 6 whole conversations, gaps and all
bandsaunter scan -r 14.0M-14.35M --mode cw HF CW (needs direct sampling)
bandsaunter config settings menu
bandsaunter config hang_seconds=5 set one setting and save it
bandsaunter bands --category Aviation browse the US band plan
bandsaunter devices list attached dongles
bandsaunter scan -b 2m --simulate try it without hardware
""")
p.add_argument("--version", action="version", version=f"bandsaunter {__version__}")
sub = p.add_subparsers(dest="command")
# -- scan ------------------------------------------------------------
s = sub.add_parser("scan", help="run a scan",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
g = s.add_argument_group("what to scan")
g.add_argument("-r", "--range", action="append", default=[], metavar="SPEC",
help="frequency range, e.g. 144M-148M or 462M-468M/12.5k@nfm; "
"repeat for as many pairs as you like")
g.add_argument("-b", "--band", action="append", default=[], metavar="KEY",
help="US band plan preset key (see `bandsaunter bands`); repeatable")
g.add_argument("--mode", default=None,
choices=["auto", "nfm", "wfm", "am", "usb", "lsb", "cw", "raw"],
help="force a demodulator for every range")
g.add_argument("-p", "--profile", metavar="NAME",
help="load a saved profile")
g.add_argument("--save-profile", metavar="NAME",
help="save the resulting configuration and exit")
st.add_arguments(s)
g = s.add_argument_group("presentation")
g.add_argument("--plain", action="store_true",
help="line-per-hit output instead of the live display")
g.add_argument("--simulate", action="store_true",
help="use a synthetic receiver instead of real hardware")
g.add_argument("--dry-run", action="store_true",
help="show the sweep plan and exit")
g.add_argument("--no-config", action="store_true",
help="ignore the saved settings file and start from defaults")
g.add_argument("--keep-carriers", action="store_true",
help="also record steady unmodulated carriers")
g.add_argument("--settings", action="store_true",
help="open the settings menu before scanning")
g.add_argument("--save", action="store_true",
help="save the resulting settings as the default and exit")
# -- bands ------------------------------------------------------------
b = sub.add_parser("bands", help="browse the US band plan")
b.add_argument("term", nargs="?", help="search term")
b.add_argument("-c", "--category", help="show one category")
b.add_argument("--categories", action="store_true", help="list categories")
b.add_argument("--json", action="store_true", help="machine-readable output")
# -- config -----------------------------------------------------------
c = sub.add_parser("config", help="view or change the saved settings")
c.add_argument("assignment", nargs="*", metavar="KEY=VALUE",
help="set one or more settings, e.g. hang_seconds=5")
c.add_argument("--show", action="store_true", help="print every setting")
c.add_argument("--path", action="store_true",
help="print where the settings file lives")
c.add_argument("--edit", action="store_true",
help="open the settings menu")
c.add_argument("--reset", action="store_true",
help="delete the saved settings")
c.add_argument("--describe", metavar="KEY",
help="explain one setting in full")
# -- transcribe ---------------------------------------------------------
tr = sub.add_parser("transcribe",
help="transcribe recordings, or check the recognisers")
tr.add_argument("path", nargs="*",
help="WAV files or directories of them")
tr.add_argument("--engines", action="store_true",
help="list the speech recognisers and which are installed")
tr.add_argument("--engine", default=None, metavar="NAME")
tr.add_argument("--model", default=None, metavar="NAME")
tr.add_argument("--language", default=None, metavar="CODE")
tr.add_argument("--stdout", action="store_true",
help="print instead of writing _transcription.txt files")
# -- devices ----------------------------------------------------------
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
d.add_argument("--test", action="store_true",
help="open the device and capture a test block")
# -- profiles ----------------------------------------------------------
pr = sub.add_parser("profiles", help="list saved profiles")
pr.add_argument("--show", metavar="NAME", help="print one profile")
# -- analyse ------------------------------------------------------------
a = sub.add_parser("analyze", aliases=["analyse"],
help="identify a signal in a recorded file")
a.add_argument("path", help=".cf32/.cs16 IQ file or a .wav from a recording")
a.add_argument("--rate", type=float, help="sample rate of the file (Hz)")
a.add_argument("--freq", type=float, default=0.0,
help="centre frequency in Hz, for band-aware naming")
a.add_argument("--morse", action="store_true", help="force a CW decode")
return p
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _build_config(args) -> tuple[ScanConfig, Path | None]:
"""Layer the configuration: saved settings, then a profile, then flags.
Returns the config and where its saved settings came from, so the user can
be told what is in force.
"""
source = None
if getattr(args, "no_config", False):
cfg = ScanConfig()
else:
cfg, source = load_default()
if args.profile:
cfg = load_config(args.profile)
source = Path(cfg._source_path) if hasattr(cfg, "_source_path") else None
ranges: list[ScanRange] = []
for spec in args.range:
ranges.extend(parse_range_list(spec))
for key in args.band:
ranges.extend(parse_range_list(key))
if ranges:
cfg.ranges = ranges
if args.mode and args.mode != "auto":
for r in cfg.ranges:
r.mode = args.mode
# Everything else comes straight off the shared settings table, so a flag
# cannot exist without the matching entry in the in-app menu.
st.apply_args(cfg, args)
if getattr(args, "keep_carriers", False) and "carrier" not in cfg.accept:
cfg.accept = list(cfg.accept) + ["carrier"]
return cfg, source
def _print_plan(cfg: ScanConfig) -> None:
try:
steps = build_plan(cfg.ranges, cfg.sample_rate, cfg.usable_fraction,
dc_guard=cfg.dc_guard_hz)
except RangeError as exc:
console.print(f"[red]{exc}[/red]")
return
t = Table(title="sweep plan", box=None, header_style="bold")
t.add_column("range")
t.add_column("from", justify="right")
t.add_column("to", justify="right")
t.add_column("mode", justify="center")
t.add_column("steps", justify="right")
counts: dict[int, int] = {}
for s in steps:
counts[s.range_index] = counts.get(s.range_index, 0) + 1
for i, r in enumerate(cfg.ranges):
if not r.enabled:
continue
t.add_row(r.label, fmt_hz(r.start), fmt_hz(r.stop), r.mode,
str(counts.get(i, 0)))
console.print(t)
cycle = len(steps) * cfg.dwell_seconds
total = sum(r.span for r in cfg.ranges if r.enabled)
gate = (", ".join(cfg.accept) if cfg.require_signal
else "everything above the squelch (content check off)")
combine = ""
if cfg.combine_by_frequency:
combine = "\nOne file per frequency"
if cfg.announce_timestamps:
from .announce import available_engine
chosen = (available_engine() if cfg.announce_engine == "auto"
else cfg.announce_engine)
using = (f"{chosen}" if chosen and chosen != "builtin"
else "the built-in synthesiser")
combine += f", timestamps spoken by {using}"
combine += "."
console.print(
f"[grey62]{len(steps)} tuner steps, {fmt_hz(total)} of spectrum, "
f"about {cycle:.1f} s per sweep (excluding time spent recording)."
f"\nSquelch +{cfg.threshold_db:g} dB; "
+ (f"record up to {cfg.record_seconds:g} s per signal"
if cfg.record_seconds else "record for as long as the signal lasts")
+ f"; resume after {cfg.hang_seconds:g} s of quiet "
f"(shorter gaps are recorded through)."
f"\nRecording: {gate}.{combine}[/grey62]")
def _maybe_first_run(cfg: ScanConfig, args) -> None:
"""Ask where to save, the first time, when there is someone to ask.
Skipped when the settings file is being ignored, when nothing is going to
be written, and when input is not a terminal -- a script must never block
on a question.
"""
if not is_first_run() or getattr(args, "no_config", False):
return
if getattr(args, "dry_run", False) or getattr(args, "save_profile", None):
return
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return
try:
first_run_setup(console, cfg)
except TUIAbort:
console.print()
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
console.print("[magenta]Using the built-in simulator "
"(no hardware involved).[/magenta]")
return SimulatedDevice(sample_rate=cfg.sample_rate, realtime=True).open()
from .device import RtlSdrDevice
dev = RtlSdrDevice(index=cfg.device_index, sample_rate=cfg.sample_rate,
gain=cfg.gain, ppm=cfg.ppm, agc=cfg.agc,
bias_tee=cfg.bias_tee,
offset_tuning=cfg.offset_tuning,
direct_sampling=cfg.direct_sampling)
return dev.open()
# ---------------------------------------------------------------------------
# commands
# ---------------------------------------------------------------------------
def cmd_scan(args) -> int:
try:
cfg, source = _build_config(args)
except (RangeError, FileNotFoundError, ValueError, st.SettingError) as exc:
console.print(f"[red]{exc}[/red]")
return 2
_maybe_first_run(cfg, args)
if getattr(args, "settings", False):
try:
settings_menu(console, cfg)
except TUIAbort:
return 0
if not cfg.ranges:
cfg = run_tui(console, cfg, source)
if cfg is None:
return 0
elif source and not cfg.quiet:
console.print(f"[grey62]settings from {source}[/grey62]")
if args.save_profile:
path = save_config(cfg, args.save_profile)
console.print(f"[green]saved profile to {path}[/green]")
return 0
if getattr(args, "save", False):
path = save_default(cfg)
console.print(f"[green]saved as the default settings: {path}[/green]")
return 0
errs = cfg.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
if args.dry_run:
_print_plan(cfg)
return 0
if not cfg.quiet:
_print_plan(cfg)
try:
device = _make_device(cfg, args.simulate)
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)), title="[red]cannot open the receiver",
border_style="red"))
console.print("[grey62]Try `bandsaunter devices` to check what is "
"attached, or `--simulate` to run without "
"hardware.[/grey62]")
return 1
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
try:
scanner.prepare()
except (ValueError, RangeError, RtlSdrError) as exc:
console.print(f"[red]{exc}[/red]")
device.close()
return 2
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
rc = (_run_plain(scanner, cfg) if (args.plain or cfg.quiet or
not sys.stdout.isatty())
else _run_live(scanner, cfg))
_print_summary(scanner)
return rc
def _run_plain(scanner: Scanner, cfg: ScanConfig) -> int:
scanner.cb.on_record_end = lambda hit: print_hit(console, hit)
if not cfg.quiet:
scanner.cb.on_status = lambda m: console.print(f"[grey62]{m}[/grey62]")
scanner.cb.on_error = lambda e: console.print(f"[red]{type(e).__name__}: {e}[/red]")
console.print("[grey62]scanning -- Ctrl-C to stop[/grey62]")
try:
scanner.run()
except KeyboardInterrupt:
scanner.stop()
return 0
def _run_live(scanner: Scanner, cfg: ScanConfig) -> int:
display = ScanDisplay(scanner)
display.attach()
import threading
worker = threading.Thread(target=scanner.run, daemon=True, name="scan")
with KeyReader() as keys:
# crop rather than let an oversized frame scroll: a display taller
# than the terminal cannot be redrawn in place, and every refresh
# would leave another copy behind.
with Live(display.render(), console=console, refresh_per_second=8,
screen=False, transient=False,
vertical_overflow="crop") as live:
worker.start()
try:
while worker.is_alive():
key = keys.get()
if key:
_handle_key(key, scanner, display)
live.update(display.render())
time.sleep(0.1)
except KeyboardInterrupt:
scanner.stop()
finally:
scanner.stop()
worker.join(timeout=5.0)
live.update(display.render())
return 0
def _handle_key(key: str, scanner: Scanner, display: ScanDisplay) -> None:
k = key.lower()
if k == "q":
scanner.stop()
elif k == "p":
scanner.pause(not scanner.paused)
display.on_status("paused" if scanner.paused else "resumed")
elif k == "s":
scanner.skip()
display.on_status("skipping this signal")
elif k == "l":
freq = display._rec.frequency or scanner.stats.current_freq
if freq:
scanner.lockout(freq)
display.on_status(f"locked out {fmt_hz(freq)}")
scanner.skip()
elif k in ("+", "="):
scanner.cfg.threshold_db += 1.0
display.on_status(f"squelch +{scanner.cfg.threshold_db:g} dB")
elif k == "-":
scanner.cfg.threshold_db = max(1.0, scanner.cfg.threshold_db - 1.0)
display.on_status(f"squelch +{scanner.cfg.threshold_db:g} dB")
def _print_summary(scanner: Scanner) -> None:
st = scanner.stats
console.print()
console.rule("[bold]scan summary[/bold]", style="blue")
console.print(
f" ran for {st.elapsed:.0f} s over {st.cycles} sweep(s), "
f"{st.steps_done} tuner steps\n"
f" {st.detections} detection(s), {st.recordings} recording(s) kept, "
f"{st.discarded} discarded\n"
f" {st.seconds_recorded:.0f} s of audio captured")
if st.truncated:
limit = scanner.cfg.record_seconds
console.print(
f"[yellow] {st.truncated} recording(s) were cut off at the "
f"{limit:g} s record limit while the signal was still "
f"transmitting.[/yellow]\n"
f"[grey62] Set 'Record for' to 0 (or --record 0) to follow a "
f"transmission to its end; 'Wait for quiet' then decides when to "
f"move on.[/grey62]")
worker = getattr(scanner, "transcriber", None)
if worker is not None and (worker.written or worker.empty or worker.dropped):
bits = [f"{worker.written} transcript(s) written"]
if worker.empty:
bits.append(f"{worker.empty} with no recognisable speech")
if worker.dropped:
bits.append(f"{worker.dropped} skipped, the recogniser fell behind")
console.print(f"[grey62] {', '.join(bits)}[/grey62]")
if st.rejected_by_category:
drops = ", ".join(f"{n} {cat}"
for cat, n in sorted(st.rejected_by_category.items(),
key=lambda kv: -kv[1]) if cat)
if drops:
console.print(f"[grey62] discarded without recording: {drops}[/grey62]")
if scanner.hits:
counts: dict[str, int] = {}
for h in scanner.hits:
counts[h.classification or "unclassified"] = \
counts.get(h.classification or "unclassified", 0) + 1
t = Table(box=None, header_style="bold")
t.add_column("identified as")
t.add_column("count", justify="right")
for label, n in sorted(counts.items(), key=lambda kv: -kv[1]):
t.add_row(label, str(n))
console.print(t)
out = Path(scanner.cfg.output_dir)
console.print(f"[grey62]recordings in {out.resolve()}, "
f"log in {(out / scanner.cfg.log_file).name} "
f"and .csv[/grey62]")
def cmd_config(args) -> int:
cfg, source = load_default()
if args.path:
print(DEFAULT_CONFIG_PATH)
return 0
if args.reset:
if DEFAULT_CONFIG_PATH.exists():
DEFAULT_CONFIG_PATH.unlink()
console.print(f"[green]deleted {DEFAULT_CONFIG_PATH}[/green]")
else:
console.print("[yellow]nothing saved to delete[/yellow]")
return 0
if args.describe:
setting = st.by_key(args.describe) or next(iter(st.search(args.describe)), None)
if setting is None:
console.print(f"[red]no setting called {args.describe!r}[/red]")
return 1
from .tui import setting_help
setting_help(console, setting, cfg)
return 0
if args.assignment:
changed = []
for item in args.assignment:
key, _, value = item.partition("=")
setting = st.by_key(key.strip())
if setting is None:
console.print(f"[red]no setting called {key.strip()!r}[/red]")
matches = st.search(key.strip())
if matches:
console.print("[grey62]did you mean: "
+ ", ".join(m.key for m in matches[:5])
+ "[/grey62]")
return 2
try:
setattr(cfg, setting.key, st.parse_value(setting, value))
except st.SettingError as exc:
console.print(f"[red]{setting.key}: {exc}[/red]")
return 2
changed.append(setting)
errs = [e for e in cfg.validate() if "frequency ranges" not in e]
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
path = save_default(cfg)
for setting in changed:
console.print(f"[green]{setting.key} = "
f"{st.format_value(setting, getattr(cfg, setting.key))}"
f"[/green]")
console.print(f"[grey62]saved to {path}[/grey62]")
return 0
if args.edit or not args.show:
try:
settings_menu(console, cfg)
except TUIAbort:
console.print()
return 0
try:
save = Confirm.ask("save these settings as the default",
default=True)
except (EOFError, KeyboardInterrupt):
save = False
if save:
console.print(f"[green]saved to {save_default(cfg)}[/green]")
return 0
default = ScanConfig()
console.print(f"[grey62]{'saved settings: ' + str(source) if source else 'no settings file yet; showing defaults'}[/grey62]")
for group in st.GROUPS:
t = Table(title=group, box=None, header_style="bold", title_justify="left")
t.add_column("key", style="cyan")
t.add_column("value")
t.add_column("default", style="grey62")
t.add_column("what it does", style="grey62", overflow="fold")
for setting in st.in_group(group):
value = st.format_value(setting, getattr(cfg, setting.key))
dflt = st.format_value(setting, getattr(default, setting.key))
t.add_row(setting.key, value, "" if value == dflt else dflt,
setting.help)
console.print(t)
console.print()
return 0
def cmd_bands(args) -> int:
if args.categories:
t = Table(title="band plan categories", box=None, header_style="bold")
t.add_column("category")
t.add_column("presets", justify="right")
for c in CATEGORIES:
t.add_row(c, str(len(in_category(c))))
console.print(t)
return 0
if args.category:
presets = in_category(args.category)
if not presets:
matches = [c for c in CATEGORIES
if args.category.lower() in c.lower()]
if len(matches) == 1:
presets = in_category(matches[0])
else:
console.print(f"[red]no such category: {args.category}[/red]")
console.print("[grey62]try: " + ", ".join(CATEGORIES) + "[/grey62]")
return 2
title = args.category
elif args.term:
presets = search(args.term)
title = f"matching {args.term!r}"
if not presets:
console.print(f"[yellow]nothing matched {args.term!r}[/yellow]")
return 1
else:
presets = list(PRESETS)
title = f"US band plan ({len(PRESETS)} presets)"
if args.json:
print(json.dumps([{
"key": p.key, "name": p.name, "category": p.category,
"start": p.start, "stop": p.stop, "step": p.step,
"mode": p.mode, "bandwidth": p.bandwidth, "note": p.note,
"members": list(p.members),
} for p in presets], indent=2))
return 0
print_band_table(console, presets, title)
console.print("[grey62]use a key with: bandsaunter scan -b <key>[/grey62]")
return 0
def cmd_transcribe(args) -> int:
from .transcribe import available_engine, describe_engines, transcribe
from .recorder import read_wav
if args.engines or not args.path:
t = Table(title="speech recognisers", box=None, header_style="bold")
t.add_column("engine", style="cyan")
t.add_column("installed")
t.add_column("how to get it", style="grey62")
for name, present, how in describe_engines():
t.add_row(name,
"[green]yes[/green]" if present else "[red]no[/red]", how)
console.print(t)
chosen = available_engine()
console.print(f"[grey62]{'auto would use ' + chosen if chosen else
'nothing installed — transcription is unavailable'}"
f"[/grey62]")
if not args.path:
return 0 if chosen else 1
cfg, _ = load_default()
engine = args.engine or cfg.transcribe_engine
model = args.model or cfg.transcribe_model
language = args.language if args.language is not None \
else cfg.transcribe_language
files: list[Path] = []
for item in args.path:
p = Path(item)
if p.is_dir():
files.extend(sorted(p.glob("*.wav")))
elif p.exists():
files.append(p)
else:
console.print(f"[red]no such file: {p}[/red]")
return 1
if not files:
console.print("[yellow]nothing to transcribe[/yellow]")
return 1
failures = 0
for wav in files:
try:
audio, rate = read_wav(wav)
except (OSError, ValueError) as exc:
console.print(f"[red]{wav.name}: {exc}[/red]")
failures += 1
continue
console.print(f"[grey62]{wav.name}: {audio.size / rate:.1f} s[/grey62]")
result = transcribe(audio, rate, engine, model, language)
if result is None:
console.print("[red]no speech recogniser installed — "
"see `bandsaunter transcribe --engines`[/red]")
return 1
if result.note:
console.print(f"[yellow]{result.note}[/yellow]")
text = result.text.strip() or "[no speech recognised]"
if args.stdout:
console.print(text)
else:
out = wav.with_suffix("")
out = out.with_name(out.name + "_transcription.txt")
out.write_text(text + "\n")
console.print(f" [green]{out.name}[/green]: {text[:70]}")
return 1 if failures else 0
def cmd_devices(args) -> int:
# This is the command people run when something is wrong, so let the
# driver say what it is doing.
set_driver_messages(True)
err = load_error()
if err:
console.print(Panel(Text(err), title="[red]librtlsdr not available",
border_style="red"))
return 1
devs = list_devices()
if not devs:
console.print("[yellow]No RTL-SDR devices found.[/yellow]")
console.print(
"[grey62]Check `lsusb` for a Realtek RTL2832/RTL2838. If it is "
"listed, the DVB-T kernel driver has probably claimed it:\n"
" echo 'blacklist dvb_usb_rtl28xxu' | "
"sudo tee /etc/modprobe.d/blacklist-rtl.conf\n"
" sudo rmmod dvb_usb_rtl28xxu[/grey62]")
return 1
t = Table(title="RTL-SDR devices", box=None, header_style="bold")
t.add_column("#", justify="right")
t.add_column("name")
t.add_column("manufacturer")
t.add_column("product")
t.add_column("serial")
for d in devs:
t.add_row(str(d.index), d.name, d.manufacturer, d.product, d.serial)
console.print(t)
if args.test:
from .device import RtlSdrDevice
import numpy as np
for d in devs:
try:
with RtlSdrDevice(index=d.index) as dev:
dev.tune(100_000_000)
x = dev.read_samples(65536, flush=True)
rms = float(np.sqrt(np.mean(np.abs(x) ** 2)))
gains = dev.available_gains
console.print(
f" [green]device {d.index} works[/green]: tuner "
f"{dev.tuner}, {len(gains)} gain steps "
f"({min(gains):.1f}-{max(gains):.1f} dB), "
f"test capture RMS {rms:.4f}")
except RtlSdrError as exc:
console.print(f" [red]device {d.index}: {exc}[/red]")
return 1
return 0
def cmd_profiles(args) -> int:
if args.show:
try:
cfg = load_config(args.show)
except (OSError, FileNotFoundError) as exc:
console.print(f"[red]{exc}[/red]")
return 1
console.print_json(json.dumps(cfg.to_dict(), default=str))
return 0
profiles = list_profiles()
if not profiles:
console.print(f"[yellow]no profiles in {DEFAULT_CONFIG_DIR}[/yellow]")
console.print("[grey62]create one with: "
"bandsaunter scan -b 2m --save-profile myscan[/grey62]")
return 0
t = Table(title=f"profiles in {DEFAULT_CONFIG_DIR}", box=None,
header_style="bold")
t.add_column("name")
t.add_column("ranges", justify="right")
t.add_column("record", justify="right")
t.add_column("hang", justify="right")
for p in profiles:
try:
cfg = load_config(str(p))
t.add_row(p.stem, str(len(cfg.ranges)),
f"{cfg.record_seconds:g}s", f"{cfg.hang_seconds:g}s")
except Exception:
t.add_row(p.stem, "[red]unreadable[/red]", "", "")
console.print(t)
return 0
def cmd_analyze(args) -> int:
import numpy as np
from .classify import classify
from .morse import decode_morse
path = Path(args.path)
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
rate = args.rate
freq = args.freq
# A recording directory carries its own metadata; use it when present.
meta_file = path.parent / "meta.json"
if meta_file.exists():
try:
meta = json.loads(meta_file.read_text())
freq = freq or meta.get("frequency_hz", 0.0)
if rate is None:
rate = (meta.get("iq_rate") if path.suffix != ".wav"
else meta.get("audio_rate"))
except (OSError, ValueError):
pass
if path.suffix == ".wav":
import wave
with wave.open(str(path)) as w:
rate = rate or w.getframerate()
raw = w.readframes(w.getnframes())
audio = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
console.print(f"[grey62]{path.name}: {audio.size/rate:.1f} s of audio "
f"at {rate:g} Hz[/grey62]")
m = decode_morse(audio, rate)
if m.is_morse or args.morse:
console.print(Panel(
Text.from_markup(
f'[bold]{m.text.strip() or "(nothing decoded)"}[/bold]\n\n'
f"[grey62]{m.wpm:.0f} WPM, tone {m.tone_hz:.0f} Hz, "
f"confidence {m.confidence}[/grey62]"),
title="CW / Morse", border_style="green"))
else:
console.print("[yellow]no Morse found in this audio "
f"({'; '.join(m.notes) or 'no keyed tone'})[/yellow]")
return 0
if rate is None:
console.print("[red]--rate is required for raw IQ files[/red]")
return 2
if path.suffix == ".cs16":
raw = np.fromfile(path, dtype="<i2").astype(np.float32) / 32768.0
iq = raw[0::2] + 1j * raw[1::2]
else:
iq = np.fromfile(path, dtype=np.complex64)
console.print(f"[grey62]{path.name}: {iq.size} samples, "
f"{iq.size/rate:.2f} s at {rate:g} Hz[/grey62]")
cls = classify(iq, rate, freq_hz=freq or 0.0, snr_db=20.0)
body = [f"[bold]{cls.label}[/bold] ({cls.confidence*100:.0f}% confident)"]
for r in cls.reasons:
body.append(f"[grey62]- {r}[/grey62]")
if cls.alternatives:
body.append("[grey62]other candidates: " +
", ".join(f"{n} ({c*100:.0f}%)" for n, c in cls.alternatives) +
"[/grey62]")
console.print(Panel(Text.from_markup("\n".join(body)),
title="identification", border_style="green"))
f = cls.features
if f:
t = Table(box=None, header_style="bold")
t.add_column("measurement")
t.add_column("value", justify="right")
rows = [("occupied bandwidth", fmt_hz(f.bandwidth)),
("spectral flatness", f"{f.flatness:.3f}"),
("envelope variation", f"{f.env_cv:.3f}"),
("rms deviation", fmt_hz(f.fdev_rms)),
("discriminator levels", str(f.freq_modes)),
("symbol rate", f"{f.baud:.0f} baud" if f.baud else "-"),
("CTCSS tone", f"{f.ctcss_hz:.1f} Hz" if f.ctcss_hz else "-"),
("on/off contrast", f"{f.ook_contrast_db:.1f} dB")]
for k, v in rows:
t.add_row(k, v)
console.print(t)
return 0
# ---------------------------------------------------------------------------
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command is None:
cfg, source = load_default()
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
try:
first_run_setup(console, cfg)
except TUIAbort:
console.print()
return 0
cfg = run_tui(console, cfg, source)
if cfg is None:
return 0
try:
device = _make_device(cfg, False)
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return 1
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
try:
scanner.prepare()
except (ValueError, RangeError, RtlSdrError) as exc:
console.print(f"[red]{exc}[/red]")
return 2
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
_run_live(scanner, cfg)
_print_summary(scanner)
return 0
handlers = {
"scan": cmd_scan, "bands": cmd_bands, "devices": cmd_devices,
"config": cmd_config, "transcribe": cmd_transcribe,
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
}
try:
return handlers[args.command](args)
except KeyboardInterrupt:
console.print("\n[grey62]interrupted[/grey62]")
return 130
except BrokenPipeError:
return 0
if __name__ == "__main__":
sys.exit(main())