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

@ -1024,3 +1024,64 @@ def test_no_callsigns_means_no_map(tmp_path, monkeypatch):
meta={"category": "voice"})
assert main([str(tmp_path), "--kml"]) == 1
assert not list(tmp_path.glob("*.kml"))
# -- waterfalls --------------------------------------------------------------
def _with_waterfall(directory, mhz=146.94, when="2026-08-22_10_00_00",
meta=None):
"""A recording with a drawing of the signal beside it."""
wav = make_capture(directory, mhz, when, "nfm",
meta=meta or {"category": "digital",
"classification": "OOK / ASK data burst"})
(directory / (wav.stem + "_waterfall.png")).write_bytes(b"\x89PNG\r\n\x1a\n")
return wav
def test_a_waterfall_is_offered_where_there_is_nothing_to_read(tmp_path):
"""A data burst has no transcript and no picture off the air, so the
drawing of it is the only thing the panel can offer."""
_with_waterfall(tmp_path)
b = browser(tmp_path)
cap = b.view[0]
assert cap.waterfall.endswith("_waterfall.png")
assert cap.picture_headline == "waterfall"
out = frame(b)
assert "waterfall" in out
assert "_waterfall.png" in out
def test_the_list_still_says_what_the_signal_was(tmp_path):
""""waterfall" says less about a capture than "OOK / ASK data burst"
does, and the summary column has room for one of them."""
_with_waterfall(tmp_path)
listing = frame(browser(tmp_path)).split("recordings in")[1]
assert "OOK / ASK data burst" in listing
def test_a_picture_off_the_air_outranks_a_drawing_of_the_signal(tmp_path):
"""One is a transmission and the other is a view of one."""
wav = _with_waterfall(tmp_path, meta={"category": "image",
"image_kind": "SSTV",
"image_mode": "Martin M1"})
(tmp_path / (wav.stem + ".png")).write_bytes(b"\x89PNG\r\n\x1a\n")
cap = browser(tmp_path).view[0]
assert cap.picture_headline.startswith("SSTV")
assert cap.waterfall # still there, still listed
assert any(p.endswith("_waterfall.png") for p in cap.images)
def test_o_prints_the_drawing_when_there_is_nothing_else(tmp_path):
"""There is no listening to a data burst."""
_with_waterfall(tmp_path)
b = browser(tmp_path)
assert b.handle("o") is False
assert b.message.endswith("_waterfall.png")
def test_the_drawing_moves_and_is_deleted_with_the_recording(tmp_path):
wav = _with_waterfall(tmp_path)
b = browser(tmp_path)
assert any(p.name.endswith("_waterfall.png") for p in b.view[0].files())
b.handle("N")
assert (tmp_path / "noise" / (wav.stem + "_waterfall.png")).exists()