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
This commit is contained in:
The Dust Council 2026-09-03 18:36:00 -07:00
parent 7e8b9b268d
commit dee262e130
17 changed files with 1383 additions and 35 deletions

View file

@ -127,6 +127,19 @@ examples:
tr.add_argument("--stdout", action="store_true",
help="print instead of writing _transcription.txt files")
# -- waterfall ---------------------------------------------------------
wf = sub.add_parser("waterfall",
help="draw recordings that produced no readable words")
wf.add_argument("path", nargs="*",
help="WAV files or directories of them "
"(default: the scanner's output directory)")
wf.add_argument("--all", action="store_true",
help="draw every recording, not only the unreadable ones")
wf.add_argument("--redraw", action="store_true",
help="draw again where a picture already exists")
wf.add_argument("--min-chars", type=int, default=None, metavar="N",
help="a transcript shorter than this counts as none")
# -- devices ----------------------------------------------------------
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
d.add_argument("--test", action="store_true",
@ -713,6 +726,101 @@ def cmd_transcribe(args) -> int:
return 1 if failures else 0
def cmd_waterfall(args) -> int:
"""Draw the captures nobody can read, for a directory already recorded."""
import json as _json
from .recorder import read_wav
from .waterfall import draw_for_recording, waterfall_path
cfg, _ = load_default()
floor = args.min_chars if args.min_chars is not None \
else cfg.waterfall_min_chars
targets = args.path or [cfg.output_dir]
files: list[Path] = []
for item in targets:
p = Path(item).expanduser()
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]no recordings to draw[/yellow]")
return 1
drawn = skipped = failed = 0
for wav in files:
out = waterfall_path(wav)
if out.exists() and not args.redraw:
skipped += 1
continue
hit = {}
meta = wav.with_suffix(".json")
if meta.exists():
try:
hit = _json.loads(meta.read_text()).get("hit") or {}
except (OSError, ValueError):
hit = {}
transcript = ""
words = wav.with_name(wav.stem + "_transcription.txt")
if words.exists():
try:
transcript = words.read_text().strip()
except OSError:
transcript = ""
transcript = transcript or str(hit.get("transcript") or "")
# The same rule the scanner applies as it records: voice that
# produced words worth the name is readable, and everything else is
# a picture waiting to be drawn.
readable = (str(hit.get("category") or "") == "voice"
and len(transcript) > floor)
if readable and not args.all:
skipped += 1
continue
try:
audio, rate = read_wav(wav)
except (OSError, ValueError) as exc:
console.print(f"[red]{wav.name}: {exc}[/red]")
failed += 1
continue
iq = str(hit.get("iq_path") or "")
try:
picture = draw_for_recording(
wav, audio=audio, rate=rate,
frequency=float(hit.get("frequency") or 0.0),
mode=str(hit.get("mode") or ""),
classification=str(hit.get("classification") or ""),
iq_path=iq, iq_rate=float(hit.get("iq_rate") or 0.0),
iq_format=cfg.iq_format, out_path=out)
except (OSError, ValueError) as exc:
console.print(f"[red]{wav.name}: {exc}[/red]")
failed += 1
continue
if picture is None:
failed += 1
continue
drawn += 1
console.print(f" [green]{out.name}[/green] "
f"[grey62]{picture.summary()}[/grey62]")
if meta.exists():
try:
body = _json.loads(meta.read_text())
if isinstance(body.get("hit"), dict):
body["hit"]["waterfall_path"] = picture.path
meta.write_text(_json.dumps(body, indent=2, default=str))
except (OSError, ValueError):
pass
console.print(f"[bold]{drawn}[/bold] drawn, {skipped} skipped"
+ (f", [red]{failed} failed[/red]" if failed else ""))
return 1 if failed and not drawn 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.
@ -1061,7 +1169,7 @@ def main(argv=None) -> int:
"scan": cmd_scan, "bands": cmd_bands, "devices": cmd_devices,
"config": cmd_config, "transcribe": cmd_transcribe,
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
"adsb": cmd_adsb,
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
}
try:
return handlers[args.command](args)