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:
parent
7e8b9b268d
commit
dee262e130
17 changed files with 1383 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue