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