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

@ -876,6 +876,53 @@ picture is still a picture.
> demodulates. Where a decoded byte stream begins with its magic number it is
> named; nothing here fetches or renders one.
## Waterfalls
Most of what a scanner records cannot be turned into words. A data burst, a
keyed carrier, a pager, a trunking control channel, a stretch of something
unidentified — the classifier names what it can and the rest is a WAV file
that tells you nothing until you open it in something else.
A waterfall says something about every signal there is, because it shows the
shape of the thing rather than its meaning: how wide it is, how long it
lasted, whether it was keyed, swept, hopping or steady, and whether it was
one signal or three side by side. So **every capture that produced no
readable words gets one drawn beside it** as a PNG — no voice, or voice that
came back from the recogniser with fewer than five characters, which is what
a recogniser handed something that is not speech reliably does.
```bash
bandsaunter waterfall # draw a directory already recorded
bandsaunter waterfall --all # including the ones that read fine
bandsaunter waterfall --redraw recordings/
```
Time runs down the picture and frequency across it, which is the way a
receiver draws one. The frequency scale is on top, the seconds down the
left, and the caption underneath says what the capture was.
**It says which picture it is, and that matters.** Where the raw IQ was kept
(`--save-iq`) this draws the radio spectrum around the tuned frequency — the
waterfall an operator would have been watching. Where only the audio was
kept, which is the usual case, it draws the demodulated audio instead: after
an FM detector the frequency axis is no longer radio frequency, and a picture
that did not say so would be a lie told in a convincing font. The caption
ends in `RF SPECTRUM` or `DEMODULATED AUDIO` accordingly.
The PNG is written the same way the SSTV and satellite pictures are — from
zlib and struct, with no imaging library — including the 5×7 font the axis
labels are drawn with, so a machine with nothing installed but numpy draws
the same picture as one with everything.
`saunterbrowse` marks the capture `waterfall`, gives the path in full, and
`o` prints it: there is no listening to a data burst. A decoded pager
message or a Morse ident still wins the panel, because a waterfall is a view
of a signal rather than a reading of one.
Pictures are around half a megabyte each — 542 of them for one directory of
677 recordings came to 302 MB, against 1.8 GB of audio. `--no-waterfall`
turns it off.
## Aircraft
```bash

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-01"
VERSION_REVISION = 2
VERSION_DATE = "2026-09-03"
VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -218,9 +218,33 @@ class Capture:
height = int(self.meta.get("image_height") or 0)
return f"{width}x{height}" if width and height else ""
@property
def waterfall(self) -> str:
"""The drawing of the signal itself, where one was made.
Not a picture off the air like SSTV or a satellite pass: a picture
*of* the capture, drawn for the ones that produced no readable
words, which is most of them.
"""
saved = str(self.meta.get("waterfall_path", "")).strip()
if saved and Path(saved).is_file():
return saved
beside = self.path.with_name(self.path.stem + "_waterfall.png")
return str(beside) if beside.is_file() else ""
@property
def picture_headline(self) -> str:
"""Whatever picture this capture has, however it came to exist.
A decoded transmission and a drawing of the signal are said
differently on purpose: one is a picture somebody sent, the other is
a view of one. Both open in the same viewer, so both belong here.
"""
return self.image_headline or ("waterfall" if self.waterfall else "")
@property
def image_headline(self) -> str:
"""What kind of picture it is, in one line."""
"""What kind of picture it is. Pictures off the air only."""
if not (self.image_kind or self.image_path):
return ""
bits = [self.image_kind or "image"]
@ -777,7 +801,7 @@ class Browser:
return True
# And by what kind of picture it is: "/sstv" and "/apt" are how
# anybody would look for the ones worth keeping.
if cap.image_headline and q in cap.image_headline.lower():
if cap.picture_headline and q in cap.picture_headline.lower():
return True
return q in cap.transcript.lower()
@ -1016,6 +1040,22 @@ class Browser:
return [line.plain.rstrip()
for line in text.wrap(self.console, width)]
@staticmethod
def _picture_panel_wins(cap) -> bool:
"""Whether the top panel is a picture rather than words.
A picture off the air always wins: it *is* the transmission. A
waterfall wins only when there is nothing else, because it says less
about a capture than a decoded pager message or a Morse ident does
-- it is a view of the signal, not a reading of it.
"""
if cap is None:
return False
if cap.image_headline:
return True
return bool(cap.waterfall) and not (cap.transcript or cap.morse_complete
or cap.morse or cap.decoded)
def _transcript_height(self) -> int:
"""Tall enough for the words, but never more than a third of the screen.
@ -1030,7 +1070,7 @@ class Browser:
# ceiling when there are any, and never fewer than enough to show them.
ceiling = max(6, min(20 if calls else 16, screen // (2 if calls else 3)))
cap = self.current
if cap is not None and cap.image_headline:
if self._picture_panel_wins(cap):
body = len(self._image_lines(cap))
else:
body = (len(self._transcript_lines()) or len(self._morse_lines())
@ -1083,7 +1123,7 @@ class Browser:
height = self._transcript_height()
if cap is None:
return Panel("", border_style="bright_black", height=height)
if cap.image_headline:
if self._picture_panel_wins(cap):
return self._image_panel(cap, height)
morse = self._morse_lines()
if morse:
@ -1163,7 +1203,7 @@ class Browser:
so the most useful thing it can do is say exactly what to open.
"""
width = max(20, (self.console.size.width or 80) - 2 - 2 * pad)
lines = [cap.image_headline[:width], ""]
lines = [cap.picture_headline[:width], ""]
files = cap.images
if not files:
return lines + ["the picture file is not beside the recording"]
@ -1190,7 +1230,8 @@ class Browser:
for line in calls[1:]:
body.append("\n")
body.append(line, style="not bold white")
return Panel(body, title="picture", title_align="left",
title = "waterfall" if not cap.image_headline else "picture"
return Panel(body, title=title, title_align="left",
border_style="magenta", padding=(1, 3), height=height)
@staticmethod
@ -1242,6 +1283,11 @@ class Browser:
style="yellow")
if cap.image_kind:
line.append(f" {len(cap.images)} file(s)", style="magenta")
elif cap.waterfall:
# Not in the summary column: "waterfall" says less about a
# capture than "OOK / ASK data burst" does, and the summary has
# room for one of them.
line.append(" waterfall", style="magenta")
checks = cap.meta.get("data_checks") or []
if checks:
# A checksum that came out right is the strongest thing anyone
@ -1449,7 +1495,7 @@ class Browser:
room = max(3, screen - 4 - (len(calls) + 1 if calls else 0))
lines = self._transcript_lines(pad=4)
title_word = "transcript"
if not lines and cap is not None and cap.image_headline:
if not lines and cap is not None and cap.picture_headline:
lines = self._image_lines(cap, pad=4)
title_word = "picture"
if not lines and cap is not None and cap.morse:
@ -1573,7 +1619,7 @@ class Browser:
# it all" when a long paging capture overflows it, and the key has
# to mean what the panel says it means.
if cap is not None and (cap.transcript or cap.morse
or cap.decoded or cap.image_headline):
or cap.decoded or cap.picture_headline):
self.reading = True
self.read_top = 0
else:
@ -1583,8 +1629,10 @@ class Browser:
if cap is not None:
# The picture where there is one: on a capture that turned
# out to be an image, the PNG is what anybody wants to pipe
# into something else, not the audio it arrived as.
self.message = cap.image_path or str(cap.path)
# into something else, not the audio it arrived as. A
# waterfall counts, on a capture that has nothing else --
# there is no listening to a data burst.
self.message = cap.image_path or cap.waterfall or str(cap.path)
return False
elif key in _FILING_KEYS:
self._file_into(_FILING_KEYS[key])

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)

View file

@ -110,6 +110,10 @@ class ScanConfig:
decode_morse: bool = True
decode_data: bool = True # read packets out of data signals
decode_images: bool = True # SSTV, weather satellites and shortwave fax
# A picture of every capture nobody can read: not voice, or voice
# that produced no words worth the name.
waterfall: bool = True
waterfall_min_chars: int = 5
# -- one file per frequency ------------------------------------------
combine_by_frequency: bool = False

View file

@ -95,6 +95,9 @@ class HitRecord:
image_height: int = 0
image_complete: bool = True
image_paths: list[str] = field(default_factory=list) # the extra channels
# And a picture of the signal itself, for the captures that produced
# no readable words -- which is most of them.
waterfall_path: str = ""
category: str = "" # voice / cw / digital / carrier / noise
signal_score: float = 0.0

View file

@ -10,6 +10,7 @@ rules from the configuration decide when to leave a signal:
from __future__ import annotations
import json
import math
import threading
import time
@ -30,6 +31,7 @@ from .device import RtlSdrDevice, RtlSdrError
from .kml import KmlLog
from .images import ImageDecode
from .pictures import find_image
from .waterfall import draw_for_recording, waterfall_path
from .morse import decode_morse, find_morse
from .quality import Assessment, assess
from .ranges import Lockout, TuneStep, build_plan
@ -936,7 +938,12 @@ class Scanner:
hit.meta_path = str(rec.write_metadata(hit))
# Queued after the sidecar exists, so the transcriber can record the
# result in it -- and record nothing when there was no speech.
self._submit_transcription(rec, hit)
queued = self._submit_transcription(rec, hit)
# A capture nobody will read gets drawn instead. Where a transcript
# is on its way the decision waits for it, because a recogniser can
# still come back with nothing and the picture is wanted either way.
if not queued:
self._draw_waterfall(rec, hit)
self.stats.recordings += 1
self.stats.seconds_recorded += duration
self.hits.append(hit)
@ -1095,17 +1102,68 @@ class Scanner:
return (longer if len(longer.complete_text) > len(morse.complete_text)
else morse)
def _submit_transcription(self, rec: Recording, hit: HitRecord) -> None:
def _readable(self, hit: HitRecord, transcript: str = "") -> bool:
"""Did this capture produce words worth having?
Voice with a transcript longer than a handful of characters, and
nothing else. A recogniser handed a data burst reliably produces
one short word of nothing in particular, which is why the bar is a
length rather than merely "did it say anything".
"""
if hit.category != "voice":
return False
return len(transcript.strip()) > self.cfg.waterfall_min_chars
def _draw_waterfall(self, rec: Recording, hit: HitRecord) -> None:
"""Draw the capture, for the captures nobody can read.
A waterfall is the one view that says something about every signal
there is, because it shows the shape of the thing rather than its
meaning. For a data burst, a keyed carrier or a stretch of
something unidentified it is the only view there is.
"""
if not self.cfg.waterfall or hit.waterfall_path:
return
audio, rate = None, float(rec.audio_rate)
if rec.audio_path.exists():
try:
audio, rate = read_wav(rec.audio_path)
except (OSError, ValueError):
audio = None
if audio is None or not audio.size:
audio = rec.classification_audio(active_only=False)
try:
drawn = draw_for_recording(
rec.audio_path, audio=audio, rate=rate,
frequency=hit.frequency, mode=hit.mode,
classification=hit.classification,
iq_path=hit.iq_path, iq_rate=rec.iq_rate,
iq_format=self.cfg.iq_format,
out_path=waterfall_path(rec.dir / f"{rec.stem}.wav"))
except (OSError, ValueError) as exc:
self._error(exc)
return
if drawn is None:
return
hit.waterfall_path = drawn.path
rec.write_metadata(hit)
def _submit_transcription(self, rec: Recording, hit: HitRecord) -> bool:
"""Queue a voice capture for speech recognition.
Only voice: running a recogniser over Morse or a data burst wastes
seconds per capture and produces nothing.
Returns whether a job was queued, because what happens to a capture
no recogniser will ever see is decided by the caller: it gets a
picture instead, and that decision cannot be made until it is known
that no words are coming.
"""
worker = self.transcriber
if worker is None or hit.category != "voice":
return
return False
if hit.duration < self.cfg.transcribe_min_seconds:
return
return False
audio, rate = None, rec.audio_rate
if rec.audio_path.exists():
@ -1116,7 +1174,7 @@ class Scanner:
if audio is None or not audio.size:
audio = rec.classification_audio(active_only=False)
if audio is None or not audio.size:
return
return False
# Beside the recording, sharing its name. When captures are being
# combined by frequency there is one recording per frequency, so the
@ -1128,11 +1186,11 @@ class Scanner:
else:
path = rec.dir / f"{rec.stem}_transcription.txt"
append = False
worker.submit(audio, rate, path,
datetime.fromtimestamp(rec.started_at), hit.frequency,
append=append,
meta_path=Path(hit.meta_path) if hit.meta_path else None,
recording=rec.audio_path.name)
return worker.submit(
audio, rate, path, datetime.fromtimestamp(rec.started_at),
hit.frequency, append=append,
meta_path=Path(hit.meta_path) if hit.meta_path else None,
recording=rec.audio_path.name, audio_path=rec.audio_path)
# Families whose signals carry bits. Voice and Morse are excluded not to
# save the work -- it is a fraction of a second -- but because a decoder
@ -1365,6 +1423,14 @@ class Scanner:
is best-effort: a scan must not fail because a website did not
answer.
"""
# A capture the recogniser could not read is drawn instead, and this
# is the first moment that is known: the words decide it, and the
# words arrive here.
if len(result.text.strip()) <= self.cfg.waterfall_min_chars:
try:
self._waterfall_for_job(job)
except Exception as exc:
self._error(exc)
if self.callsigns is None:
return
try:
@ -1377,6 +1443,48 @@ class Scanner:
except Exception as exc:
self._error(exc)
def _waterfall_for_job(self, job) -> None:
"""Draw a capture whose transcript came back empty or near enough.
Works from the samples the job is already carrying rather than
reading the file again, and from the sidecar for the caption -- by
the time this runs the scan has moved on and the Recording it came
from is gone.
"""
if not self.cfg.waterfall or job.audio_path is None:
return
mode = classification = ""
meta = {}
if job.meta_path is not None and job.meta_path.exists():
try:
meta = json.loads(job.meta_path.read_text()).get("hit") or {}
except (OSError, ValueError):
meta = {}
mode = str(meta.get("mode") or "")
classification = str(meta.get("classification") or "")
if meta.get("waterfall_path"):
return # already drawn
drawn = draw_for_recording(
job.audio_path, audio=job.audio, rate=job.rate,
frequency=job.frequency, mode=mode, classification=classification)
if drawn is None:
return
self._note_waterfall(job.meta_path, drawn.path)
@staticmethod
def _note_waterfall(meta_path, picture: str) -> None:
"""Record the picture in the capture's sidecar, if there is one."""
if meta_path is None or not Path(meta_path).exists():
return
try:
body = json.loads(Path(meta_path).read_text())
hit = body.get("hit")
if isinstance(hit, dict):
hit["waterfall_path"] = picture
Path(meta_path).write_text(json.dumps(body, indent=2, default=str))
except (OSError, ValueError):
pass
def _announce_callsign(self, entry) -> None:
"""Say on the display who has just identified themselves."""
if self.heard.get(entry.call, 0) > 1:

View file

@ -318,6 +318,18 @@ _TABLE: tuple[Setting, ...] = (
"Some transmissions are pictures rather than sounds. This looks for "
"them in every recording and writes what it finds beside the audio.",
flags=("--images",), off_flags=("--no-images",)),
S("waterfall", "Draw a waterfall", "Output", "bool",
"picture every capture that produced no readable words",
"A waterfall says something about every signal there is, because it "
"shows the shape of the thing rather than its meaning. Drawn for the "
"captures that cannot be read any other way.",
flags=("--waterfall",), off_flags=("--no-waterfall",)),
S("waterfall_min_chars", "Words that count as readable", "Output", "int",
"a transcript shorter than this counts as no transcript",
"Below this many characters a transcript says nothing a picture would "
"not say better, so the capture gets one.",
minimum=0, maximum=200, flags=("--waterfall-min-chars",),
metavar="N"),
S("decode_morse", "Decode CW to text", "Output", "bool",
"decode keyed carriers as Morse",
"Speed is measured from the signal, so nothing needs configuring. "
@ -720,6 +732,20 @@ _GUIDANCE: dict[str, str] = {
"recognised by its own header rather than guessed at, so this costs "
"a moment per recording and finds nothing where there is nothing. "
"What it does find is written as a PNG beside the audio.",
"waterfall":
"Most of what a scanner records cannot be turned into words: a data "
"burst, a keyed carrier, a pager, a control channel, a stretch of "
"something unidentified. A waterfall shows the shape of a signal "
"rather than its meaning -- how wide it is, how long it lasted, "
"whether it was keyed, swept, hopping or steady, and whether it was "
"one signal or three side by side -- so every capture that produced "
"no readable words gets one drawn beside it as a PNG. Where the raw "
"IQ was kept it draws the radio spectrum; otherwise the demodulated "
"audio, and it says on the picture which it is.",
"waterfall_min_chars":
"How much transcript counts as having read a capture. Under this, "
"the recogniser found a word or two of nothing in particular, and a "
"picture of the signal is worth more than the word.",
"decode_morse":
"Turn keyed carriers into readable text, with the sending speed. "
"Morse is still in daily use by amateurs and by beacons, and this "

View file

@ -383,6 +383,7 @@ class Job:
append: bool
meta_path: Path | None = None
recording: str = "" # filename of the audio, for reference
audio_path: Path | None = None # and where that file is
class TranscriptionWorker:
@ -396,7 +397,9 @@ class TranscriptionWorker:
language: str = "en", max_queue: int = 32,
on_done=None, on_error=None):
# on_done(path, Transcript, Job) -- called on the worker thread once
# a transcript has been written, never for a capture that was silent.
# a capture has been through the recogniser, including when it came
# back with nothing. A silent capture is a result too, and the
# caller has something to do about it.
self.engine = engine
self.model = model
self.language = language
@ -433,14 +436,16 @@ class TranscriptionWorker:
# -- work --------------------------------------------------------------
def submit(self, audio: np.ndarray, rate: float, path: Path,
when: datetime, frequency: float, append: bool = False,
meta_path: Path | None = None, recording: str = "") -> bool:
meta_path: Path | None = None, recording: str = "",
audio_path: Path | None = None) -> bool:
if audio is None or audio.size == 0:
return False
try:
self._queue.put_nowait(
Job(np.asarray(audio, dtype=np.float32), float(rate),
Path(path), when, float(frequency), append,
Path(meta_path) if meta_path else None, recording))
Path(meta_path) if meta_path else None, recording,
Path(audio_path) if audio_path else None))
return True
except queue.Full:
self.dropped += 1
@ -471,8 +476,11 @@ class TranscriptionWorker:
if not body:
# Nothing was said. Writing a file to announce that leaves a
# directory full of placeholders, so write nothing at all; the
# count is reported at the end of the scan instead.
# count is reported at the end of the scan instead. The caller
# is still told, because "the recogniser found nothing" is a
# result and there is something to do about it.
self.empty += 1
self._finished(job, result)
return
if job.append:
@ -484,11 +492,15 @@ class TranscriptionWorker:
fh.write(f"{body}\n")
self.written += 1
self._record_in_metadata(job, body)
if self.on_done:
try:
self.on_done(job.path, result, job)
except Exception:
pass
self._finished(job, result)
def _finished(self, job: Job, result: Transcript) -> None:
if not self.on_done:
return
try:
self.on_done(job.path, result, job)
except Exception:
pass
@staticmethod
def _record_in_metadata(job: Job, text: str) -> None:

441
bandsaunter/waterfall.py Normal file
View file

@ -0,0 +1,441 @@
"""A picture of what a capture looked like, for the ones nobody can read.
Most of what a scanner records cannot be turned into words. A data burst, a
keyed carrier, a pager, a trunking control channel, a stretch of something
unidentified -- the classifier names what it can and the rest is a WAV file
that tells you nothing until you open it in something else. A waterfall is
the one view that says something about every signal there is, because it
shows the shape of the thing rather than its meaning: how wide it is, how
long it lasted, whether it was keyed, swept, hopping or steady, and whether
it was one signal or three side by side.
So every capture that produced no readable words gets one drawn beside it.
Two sources, and they are not the same picture. Where the raw IQ was kept
this draws the radio spectrum around the tuned frequency, which is the
waterfall an operator would have been watching. Where only the audio was
kept -- the usual case, since IQ is off by default -- it draws the
demodulated audio instead, and says so on the image, because after an FM
detector the frequency axis is no longer radio frequency and a picture that
did not say so would be a lie told in a convincing font.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from scipy import signal as sps
from .images import write_png
__all__ = ["Waterfall", "render_waterfall", "write_waterfall",
"draw_for_recording", "waterfall_path", "caption_for",
"read_iq", "COLOURS"]
# ---------------------------------------------------------------------------
# How it looks
# ---------------------------------------------------------------------------
# The classic receiver waterfall ramp: the noise floor is nearly black, and
# each step up the scale is a distinctly different hue rather than a
# brighter version of the last, so a signal 6 dB above its neighbour looks
# different rather than merely lighter.
COLOURS = (
(0.00, (0, 0, 12)),
(0.15, (0, 0, 96)),
(0.35, (0, 120, 160)),
(0.55, (0, 190, 90)),
(0.72, (220, 220, 0)),
(0.87, (230, 110, 0)),
(1.00, (255, 255, 245)),
)
BACKGROUND = (16, 16, 20)
INK = (190, 195, 205)
GRID = (70, 74, 84)
WIDTH = 512 # spectrum bins across
MAX_ROWS = 900 # time slices down; a long capture is thinned
MIN_ROWS = 64
LEFT = 46 # room for the time scale
TOP = 11 # room for the frequency scale
BOTTOM = 20 # room for the caption
RIGHT = 6
# How far above the capture's own noise floor is drawn as full brightness.
# Fixed rather than fitted to the peak: a capture holding one loud carrier
# would otherwise scale everything else into the floor, and the point of the
# picture is what is *around* the loud thing.
FLOOR_PERCENTILE = 35.0
FLOOR_MARGIN_DB = 3.0
SPAN_DB = 45.0
# And the floor is never set more than this far below the loudest thing in
# the capture. A percentile is a fair reading of where the noise sits only
# while there is noise to read: given a clean tone in near-silence the
# quietest bins are the window's own leakage a hundred and fifty decibels
# down, and a floor set there paints the whole skirt of the tone white.
MAX_RANGE_DB = 80.0
# ---------------------------------------------------------------------------
# A 5x7 font, because a PNG writer with no text in it draws unreadable
# pictures. Upper case only: the labels are instrument labels, and "145.5
# MHZ" in capitals is how every receiver front panel has ever written it.
# ---------------------------------------------------------------------------
_GLYPHS = {
"0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
"1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
"2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
"3": ("11111", "00010", "00100", "00010", "00001", "10001", "01110"),
"4": ("00010", "00110", "01010", "10010", "11111", "00010", "00010"),
"5": ("11111", "10000", "11110", "00001", "00001", "10001", "01110"),
"6": ("00110", "01000", "10000", "11110", "10001", "10001", "01110"),
"7": ("11111", "00001", "00010", "00100", "01000", "01000", "01000"),
"8": ("01110", "10001", "10001", "01110", "10001", "10001", "01110"),
"9": ("01110", "10001", "10001", "01111", "00001", "00010", "01100"),
"A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
"B": ("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
"C": ("01110", "10001", "10000", "10000", "10000", "10001", "01110"),
"D": ("11100", "10010", "10001", "10001", "10001", "10010", "11100"),
"E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
"F": ("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
"G": ("01110", "10001", "10000", "10111", "10001", "10001", "01111"),
"H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
"I": ("01110", "00100", "00100", "00100", "00100", "00100", "01110"),
"J": ("00111", "00010", "00010", "00010", "00010", "10010", "01100"),
"K": ("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
"L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
"M": ("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
"N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
"O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
"P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
"Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
"R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
"S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
"T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
"U": ("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
"V": ("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
"W": ("10001", "10001", "10001", "10101", "10101", "11011", "10001"),
"X": ("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
"Y": ("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
"Z": ("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
".": ("00000", "00000", "00000", "00000", "00000", "01100", "01100"),
",": ("00000", "00000", "00000", "00000", "01100", "01100", "11000"),
"-": ("00000", "00000", "00000", "11111", "00000", "00000", "00000"),
"+": ("00000", "00100", "00100", "11111", "00100", "00100", "00000"),
":": ("00000", "01100", "01100", "00000", "01100", "01100", "00000"),
"/": ("00001", "00010", "00010", "00100", "01000", "01000", "10000"),
"(": ("00010", "00100", "01000", "01000", "01000", "00100", "00010"),
")": ("01000", "00100", "00010", "00010", "00010", "00100", "01000"),
"%": ("11001", "11010", "00010", "00100", "01000", "01011", "10011"),
" ": ("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
}
GLYPH_W, GLYPH_H = 5, 7
CHAR_ADVANCE = GLYPH_W + 1
def text_width(text: str) -> int:
"""How wide a label will be, so it can be centred or right-aligned."""
return max(0, len(text) * CHAR_ADVANCE - 1)
def draw_text(canvas: np.ndarray, x: int, y: int, text: str,
colour=INK) -> None:
"""Stamp a label into an RGB array. Clipped, never wrapped.
Anything with no glyph is drawn as a space rather than refused: a label
is a convenience, and a picture that failed to be written because of one
unexpected character would be a poor trade.
"""
height, width = canvas.shape[0], canvas.shape[1]
for index, char in enumerate(text.upper()):
rows = _GLYPHS.get(char)
left = x + index * CHAR_ADVANCE
if rows is None or left >= width:
continue
for row, bits in enumerate(rows):
yy = y + row
if not 0 <= yy < height:
continue
for col, bit in enumerate(bits):
xx = left + col
if bit == "1" and 0 <= xx < width:
canvas[yy, xx] = colour
# ---------------------------------------------------------------------------
# What was drawn
# ---------------------------------------------------------------------------
@dataclass
class Waterfall:
"""One rendered waterfall and the axes it was drawn with."""
path: str = ""
width: int = 0
height: int = 0
seconds: float = 0.0
source: str = "audio" # audio / iq
centre_hz: float = 0.0 # 0 for an audio waterfall
span_hz: float = 0.0 # full width of the frequency axis
rows: int = 0 # time slices actually drawn
def summary(self) -> str:
where = ("radio spectrum" if self.source == "iq"
else "demodulated audio")
return (f"waterfall of the {where}, {self.width}x{self.height}, "
f"{self.seconds:.1f} s")
def waterfall_path(audio_path) -> Path:
"""Where a recording's waterfall goes: beside it, named for it."""
path = Path(audio_path)
return path.with_name(path.stem + "_waterfall.png")
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
def _colour_map(levels: np.ndarray) -> np.ndarray:
"""Turn a 0..1 array into RGB by interpolating between the anchors."""
stops = np.array([stop for stop, _ in COLOURS], dtype=np.float64)
table = np.array([rgb for _, rgb in COLOURS], dtype=np.float64)
flat = np.clip(levels, 0.0, 1.0).ravel()
out = np.empty((flat.size, 3), dtype=np.float64)
for channel in range(3):
out[:, channel] = np.interp(flat, stops, table[:, channel])
return out.reshape(levels.shape + (3,))
def _spectrogram(samples: np.ndarray, rate: float, rows: int,
complex_input: bool) -> tuple[np.ndarray, np.ndarray]:
"""Power per bin per time slice, and the frequency of each bin.
The transform is sized from the capture rather than fixed: a two-second
burst and a four-minute watch both have to fill the same picture, so the
hop is whatever divides the capture into the rows there is room for.
"""
nfft = 2 * WIDTH if not complex_input else WIDTH
hop = max(1, int(samples.size / max(rows, 1)))
window = np.hanning(nfft)
starts = np.arange(0, max(1, samples.size - nfft + 1), hop)
if starts.size == 0:
starts = np.array([0])
block = np.zeros((starts.size, nfft), dtype=samples.dtype)
for i, at in enumerate(starts):
piece = samples[at:at + nfft]
block[i, :piece.size] = piece
block = block * window
if complex_input:
spec = np.fft.fftshift(np.fft.fft(block, nfft, axis=1), axes=1)
freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / rate))
else:
spec = np.fft.rfft(block, nfft, axis=1)[:, :WIDTH]
freqs = np.fft.rfftfreq(nfft, 1.0 / rate)[:WIDTH]
return np.abs(spec) ** 2, freqs
def _axis_unit(low: float, high: float) -> tuple[float, str, int]:
"""One unit for the whole frequency axis, and how many decimals it needs.
Chosen once rather than per tick: an axis reading 999 then 1 then 1.001
is arithmetically correct and unreadable, and a waterfall is something
people glance at.
"""
reach = max(abs(low), abs(high))
span = max(high - low, 1e-9)
if reach >= 1e6:
scale, name = 1e6, "MHZ"
elif reach >= 1e3:
scale, name = 1e3, "KHZ"
else:
scale, name = 1.0, "HZ"
# Enough decimals that two neighbouring ticks cannot print the same.
step = span / 8.0 / scale
places = 0
while places < 6 and step < 1.0:
step *= 10.0
places += 1
return scale, name, places
def _tick_step(span: float, wanted: int = 6) -> float:
"""A round number of hertz near ``span / wanted``."""
if span <= 0:
return 1.0
rough = span / max(1, wanted)
power = 10.0 ** math.floor(math.log10(rough))
for step in (1.0, 2.0, 2.5, 5.0, 10.0):
if rough <= step * power:
return step * power
return 10.0 * power
def render_waterfall(samples: np.ndarray, rate: float, *,
complex_input: bool = False,
centre_hz: float = 0.0,
caption: str = "") -> tuple[np.ndarray, Waterfall]:
"""Draw a capture as a waterfall. Returns ``(rgb, description)``.
Time runs down the picture and frequency across it, which is the way a
receiver draws one and therefore the way anyone reading it expects to
find it.
"""
samples = np.asarray(samples)
if samples.size == 0:
raise ValueError("nothing to draw")
rate = float(rate)
seconds = samples.size / rate if rate > 0 else 0.0
rows = int(np.clip(samples.size // max(1, int(rate * 0.01)),
MIN_ROWS, MAX_ROWS))
power, freqs = _spectrogram(samples, rate, rows, complex_input)
rows = power.shape[0]
db = 10.0 * np.log10(power + 1e-20)
floor = float(np.percentile(db, FLOOR_PERCENTILE)) + FLOOR_MARGIN_DB
floor = max(floor, float(db.max()) - MAX_RANGE_DB)
levels = (db - floor) / SPAN_DB
picture = _colour_map(levels)
# The spectrum is computed at WIDTH bins, so it already fits; the rows
# are whatever the capture gave and are drawn one per line.
body = np.clip(picture, 0, 255).astype(np.uint8)
height = TOP + body.shape[0] + BOTTOM
width = LEFT + body.shape[1] + RIGHT
canvas = np.empty((height, width, 3), dtype=np.uint8)
canvas[:, :] = BACKGROUND
canvas[TOP:TOP + body.shape[0], LEFT:LEFT + body.shape[1]] = body
# -- frequency scale across the top ---------------------------------
low = float(freqs[0]) + centre_hz
high = float(freqs[-1]) + centre_hz
span = high - low
scale, unit, places = _axis_unit(low, high)
step = _tick_step(span)
tick = math.ceil(low / step) * step
while tick <= high + 1e-9:
x = LEFT + int(round((tick - low) / max(span, 1e-9)
* (body.shape[1] - 1)))
if LEFT <= x < LEFT + body.shape[1]:
column = canvas[TOP:TOP + body.shape[0], x]
canvas[TOP:TOP + body.shape[0], x] = np.maximum(
column, np.array(GRID, np.uint8))
label = f"{tick / scale:.{places}f}"
draw_text(canvas, x - text_width(label) // 2, 1, label)
tick += step
draw_text(canvas, width - RIGHT - text_width(unit), 1, unit)
# -- time scale down the left ---------------------------------------
marks = 6
for i in range(marks + 1):
at = i / marks
y = TOP + int(round(at * (body.shape[0] - 1)))
label = (f"{at * seconds:.0f}S" if seconds >= 10
else f"{at * seconds:.1f}S")
# Clamped into the body so the first one does not collide with
# the frequency scale and the last does not fall off the bottom.
draw_text(canvas, LEFT - text_width(label) - 4,
min(height - BOTTOM - GLYPH_H, max(TOP, y - GLYPH_H // 2)),
label)
if caption:
room = (width - LEFT - RIGHT) // CHAR_ADVANCE
draw_text(canvas, LEFT, height - BOTTOM + 6, caption[:room])
return canvas, Waterfall(width=width, height=height, seconds=seconds,
source="iq" if complex_input else "audio",
centre_hz=centre_hz,
span_hz=span, rows=body.shape[0])
def write_waterfall(path, samples: np.ndarray, rate: float, *,
complex_input: bool = False,
centre_hz: float = 0.0,
caption: str = "") -> Waterfall:
"""Render a capture and write it as a PNG. Returns what was drawn."""
canvas, described = render_waterfall(
samples, rate, complex_input=complex_input, centre_hz=centre_hz,
caption=caption)
described.path = str(write_png(path, canvas))
return described
# ---------------------------------------------------------------------------
# Drawing one that a scan already recorded
# ---------------------------------------------------------------------------
def read_iq(path, iq_format: str = "cf32") -> np.ndarray:
"""The raw samples a scan kept, if it kept any."""
path = Path(path)
if path.suffix == ".cs16" or iq_format == "cs16":
raw = np.fromfile(path, dtype="<i2").astype(np.float32) / 32768.0
return (raw[0::2] + 1j * raw[1::2]).astype(np.complex64)
return np.fromfile(path, dtype=np.complex64)
def caption_for(frequency: float, mode: str, seconds: float,
classification: str, source: str) -> str:
"""The line under the picture: what it is, and what it is a picture *of*.
The last part is not decoration. After an FM detector the frequency
axis is audio, not radio, and a waterfall that did not say which it was
showing would be a lie told in a convincing font.
"""
bits = []
if frequency:
bits.append(f"{frequency / 1e6:.6f} MHZ".rstrip())
if mode:
bits.append(mode.upper())
bits.append(f"{seconds:.1f} S")
if classification:
bits.append(classification.upper())
bits.append("RF SPECTRUM" if source == "iq" else "DEMODULATED AUDIO")
return " ".join(bits)
def draw_for_recording(audio_path, *, audio=None, rate: float = 0.0,
frequency: float = 0.0, mode: str = "",
classification: str = "",
iq_path: str = "", iq_rate: float = 0.0,
iq_format: str = "cf32",
out_path=None) -> Waterfall | None:
"""Draw the waterfall for one recording, from whichever samples exist.
The raw IQ is preferred where a scan kept it, because that is the
spectrum an operator would have been watching -- the audio is what is
left after a detector has already thrown most of it away. IQ is off by
default, so in practice this nearly always draws the audio.
"""
samples: np.ndarray | None = None
complex_input = False
used_rate = 0.0
if iq_path and Path(iq_path).is_file() and iq_rate > 0:
try:
samples = read_iq(iq_path, iq_format)
complex_input, used_rate = True, float(iq_rate)
except (OSError, ValueError):
samples = None
if samples is None or samples.size == 0:
if audio is None or rate <= 0:
return None
samples = np.asarray(audio, dtype=np.float64).ravel()
complex_input, used_rate = False, float(rate)
if samples.size < 256:
return None
out = Path(out_path) if out_path else waterfall_path(audio_path)
seconds = samples.size / used_rate
source = "iq" if complex_input else "audio"
return write_waterfall(
out, samples, used_rate, complex_input=complex_input,
centre_hz=frequency if complex_input else 0.0,
caption=caption_for(frequency, mode, seconds, classification, source))

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-02" "bandsaunter 2026-09-01_02" "User Commands"
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_01" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -62,6 +62,12 @@ Transcribe existing recordings, or list which speech recognisers are
installed with
.BR \-\-engines .
.TP
.B waterfall
Draw a waterfall for every recording in a directory that produced no
readable words. See
.B WATERFALLS
below.
.TP
.B devices
List attached receivers.
.TP
@ -549,6 +555,26 @@ Setting name \fBdecode_images\fR, default \fByes\fR.
Three of the things a receiver can hear are pictures: the weather satellites on 137 MHz, amateur slow-scan television, and the shortwave weather fax stations. All three are images sent as sound, so they arrive in the same recordings everything else does. Each is recognised by its own header rather than guessed at, so this costs a moment per recording and finds nothing where there is nothing. What it does find is written as a PNG beside the audio.
.RE
.TP
.B --waterfall / --no-waterfall
Draw a waterfall \[em] picture every capture that produced no readable words.
.br
Setting name \fBwaterfall\fR, default \fByes\fR.
.RS
.PP
Most of what a scanner records cannot be turned into words: a data burst, a keyed carrier, a pager, a control channel, a stretch of something unidentified. A waterfall shows the shape of a signal rather than its meaning -- how wide it is, how long it lasted, whether it was keyed, swept, hopping or steady, and whether it was one signal or three side by side -- so every capture that produced no readable words gets one drawn beside it as a PNG. Where the raw IQ was kept it draws the radio spectrum; otherwise the demodulated audio, and it says on the picture which it is.
.RE
.TP
.B --waterfall-min-chars
Words that count as readable \[em] a transcript shorter than this counts as no transcript.
.br
Setting name \fBwaterfall_min_chars\fR, default \fB5\fR.
.br
Accepts: at least 0, at most 200.
.RS
.PP
How much transcript counts as having read a capture. Under this, the recogniser found a word or two of nothing in particular, and a picture of the signal is worth more than the word.
.RE
.TP
.B --morse / --no-morse
Decode CW to text \[em] decode keyed carriers as Morse.
.br
@ -999,6 +1025,37 @@ Nothing is joined across a slash: a suffix says where the station is, not
what it is called, so
.I W1AW/B
is W1AW.
.SH WATERFALLS
Most of what a scanner records cannot be turned into words: a data burst, a
keyed carrier, a pager, a control channel, a stretch of something
unidentified. A waterfall says something about every signal there is,
because it shows the shape of the thing rather than its meaning \[em] how
wide it is, how long it lasted, whether it was keyed, swept, hopping or
steady, and whether it was one signal or three side by side.
.PP
So every capture that produced no readable words is drawn beside the audio
as a PNG: no voice, or voice the recogniser came back from with fewer than
.B \-\-waterfall\-min\-chars
characters, which is what a recogniser handed something that is not speech
reliably does. Time runs down the picture and frequency across it, with the
frequency scale on top, the seconds down the left and a caption underneath
saying what the capture was.
.PP
The caption also says what the picture is *of*, and that matters. Where the
raw IQ was kept this draws the radio spectrum around the tuned frequency,
which is the waterfall an operator would have been watching. Where only the
audio was kept \[em] the usual case, since IQ is off by default \[em] it
draws the demodulated audio instead: after an FM detector the frequency axis
is no longer radio frequency, and a picture that did not say so would be a
lie told in a convincing font.
.PP
.B bandsaunter waterfall
does the same for a directory already recorded, drawing only what cannot be
read unless
.B \-\-all
is given, and skipping what it has already drawn unless
.B \-\-redraw
is.
.SH CW AND IDENTIFICATION
Every capture is offered to a CW decoder once it has finished, whatever the
classifier made of it. Most of the Morse on the air is not a conversation:

View file

@ -197,6 +197,13 @@ message says which packages would fix it.
Over ssh there is usually no sound server at the far end. The browser and its
transcripts work regardless; only Enter has nothing to do.
.SH DETECTED CALLSIGNS
A capture that produced no readable words has a waterfall drawn beside it
instead \[em] a picture of the signal, which for a data burst or a keyed
carrier is the only view there is. The browser marks it, gives the path in
full, and
.B o
prints it: there is no listening to a data burst.
.PP
Under the transcript, headed
.BR "DETECTED CALLSIGNS:" ,
is every callsign heard in it, with the name and location on the licence.

View file

@ -120,6 +120,12 @@ Transcribe existing recordings, or list which speech recognisers are
installed with
.BR \-\-engines .
.TP
.B waterfall
Draw a waterfall for every recording in a directory that produced no
readable words. See
.B WATERFALLS
below.
.TP
.B devices
List attached receivers.
.TP
@ -412,6 +418,37 @@ Nothing is joined across a slash: a suffix says where the station is, not
what it is called, so
.I W1AW/B
is W1AW.
.SH WATERFALLS
Most of what a scanner records cannot be turned into words: a data burst, a
keyed carrier, a pager, a control channel, a stretch of something
unidentified. A waterfall says something about every signal there is,
because it shows the shape of the thing rather than its meaning \[em] how
wide it is, how long it lasted, whether it was keyed, swept, hopping or
steady, and whether it was one signal or three side by side.
.PP
So every capture that produced no readable words is drawn beside the audio
as a PNG: no voice, or voice the recogniser came back from with fewer than
.B \-\-waterfall\-min\-chars
characters, which is what a recogniser handed something that is not speech
reliably does. Time runs down the picture and frequency across it, with the
frequency scale on top, the seconds down the left and a caption underneath
saying what the capture was.
.PP
The caption also says what the picture is *of*, and that matters. Where the
raw IQ was kept this draws the radio spectrum around the tuned frequency,
which is the waterfall an operator would have been watching. Where only the
audio was kept \[em] the usual case, since IQ is off by default \[em] it
draws the demodulated audio instead: after an FM detector the frequency axis
is no longer radio frequency, and a picture that did not say so would be a
lie told in a convincing font.
.PP
.B bandsaunter waterfall
does the same for a directory already recorded, drawing only what cannot be
read unless
.B \-\-all
is given, and skipping what it has already drawn unless
.B \-\-redraw
is.
.SH CW AND IDENTIFICATION
Every capture is offered to a CW decoder once it has finished, whatever the
classifier made of it. Most of the Morse on the air is not a conversation:

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-09-02" "bandsaunter 2026-09-01_02" "User Commands"
.TH SAUNTERBROWSE 1 "2026-09-03" "bandsaunter 2026-09-03_01" "User Commands"
.SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS
@ -171,6 +171,13 @@ message says which packages would fix it.
Over ssh there is usually no sound server at the far end. The browser and its
transcripts work regardless; only Enter has nothing to do.
.SH DETECTED CALLSIGNS
A capture that produced no readable words has a waterfall drawn beside it
instead \[em] a picture of the signal, which for a data burst or a keyed
carrier is the only view there is. The browser marks it, gives the path in
full, and
.B o
prints it: there is no listening to a data burst.
.PP
Under the transcript, headed
.BR "DETECTED CALLSIGNS:" ,
is every callsign heard in it, with the name and location on the licence.

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()

View file

@ -488,6 +488,10 @@ def test_a_simulated_transmission_becomes_a_file_on_disk(tmp_path):
assert hit.image_kind == "SSTV" and hit.image_mode == "Martin M1"
assert hit.category == "image"
assert hit.kept, "a picture was decoded and then discarded"
written = list(tmp_path.glob("*.png"))
# Named, not merely globbed: every capture that produced no words has a
# waterfall drawn beside it, and an SSTV transmission produced a picture
# rather than words. Both are PNGs; only one of them is 320 across.
written = [p for p in tmp_path.glob("*.png")
if not p.name.endswith("_waterfall.png")]
assert written
assert _read_png(written[0]).shape[1] == 320

378
tests/test_waterfall.py Normal file
View file

@ -0,0 +1,378 @@
"""Drawing the captures nobody can read.
A waterfall is checked here the way a picture has to be: by reading the
pixels back and asking whether the thing that was put in shows up where it
should, rather than by looking at the file and calling it a picture.
"""
import json
import wave
from pathlib import Path
import numpy as np
import pytest
from bandsaunter import waterfall as wf
from bandsaunter.images import PNG_SIGNATURE
FS = 16000
def tone(hz: float, seconds: float = 4.0, rate: int = FS, level: float = 0.5,
seed: int = 0, keyed: bool = False) -> np.ndarray:
rng = np.random.default_rng(seed)
t = np.arange(int(seconds * rate)) / rate
x = np.sin(2 * np.pi * hz * t) * level
if keyed:
x *= (np.sin(2 * np.pi * 1.5 * t) > 0)
return x + 0.01 * rng.standard_normal(t.size)
def spectrum_body(canvas: np.ndarray) -> np.ndarray:
"""Just the drawn spectrum, with the margins and axes cut away."""
return canvas[wf.TOP:canvas.shape[0] - wf.BOTTOM,
wf.LEFT:wf.LEFT + wf.WIDTH]
def brightest_column(canvas: np.ndarray) -> int:
"""Which column of the drawn body is loudest, in body coordinates."""
body = spectrum_body(canvas)
return int(np.argmax(body.astype(np.float64).sum(axis=(0, 2))))
# -- what it draws -----------------------------------------------------------
def test_a_tone_lands_at_its_own_frequency():
"""The whole point: where the picture is bright is where the signal was."""
canvas, drawn = wf.render_waterfall(tone(2000.0), FS)
hz = brightest_column(canvas) / wf.WIDTH * (FS / 2.0)
assert abs(hz - 2000.0) < 120.0, f"the tone was drawn at {hz:.0f} Hz"
def test_two_tones_are_two_stripes():
audio = tone(1000.0) + tone(3000.0, seed=1)
canvas, _ = wf.render_waterfall(audio, FS)
profile = spectrum_body(canvas).astype(np.float64).sum(axis=(0, 2))
at = lambda hz: int(hz / (FS / 2.0) * wf.WIDTH) # noqa: E731
assert profile[at(1000)] > 3 * profile[at(2000)]
assert profile[at(3000)] > 3 * profile[at(2000)]
def test_keying_shows_as_gaps_down_the_stripe():
"""Time runs down the picture, so a keyed carrier is a dashed line."""
canvas, _ = wf.render_waterfall(tone(1500.0, seconds=6.0, keyed=True), FS)
body = spectrum_body(canvas)
column = body[:, brightest_column(canvas)].astype(np.float64).sum(axis=1)
on = column > (column.max() + column.min()) / 2
changes = int(np.count_nonzero(np.diff(on.astype(np.int8))))
assert changes >= 8, f"the keying drew as {changes} edges, not a dashed line"
def test_the_picture_has_room_for_its_axes():
canvas, drawn = wf.render_waterfall(tone(1200.0), FS)
assert canvas.shape == (drawn.height, drawn.width, 3)
assert drawn.width > wf.WIDTH and drawn.height > drawn.rows
assert drawn.seconds == pytest.approx(4.0, abs=0.05)
def test_time_and_frequency_scales_are_actually_drawn():
"""Ink outside the body, or the axes are empty margins."""
canvas, _ = wf.render_waterfall(tone(1200.0), FS)
left_margin = canvas[:, :wf.LEFT]
top_margin = canvas[:wf.TOP, :]
assert (left_margin != np.array(wf.BACKGROUND, np.uint8)).any()
assert (top_margin != np.array(wf.BACKGROUND, np.uint8)).any()
def test_a_caption_is_drawn_under_the_picture():
plain, _ = wf.render_waterfall(tone(1200.0), FS)
with_text, _ = wf.render_waterfall(tone(1200.0), FS, caption="146.520 MHZ")
bottom = slice(plain.shape[0] - wf.BOTTOM, plain.shape[0])
assert not np.array_equal(plain[bottom], with_text[bottom])
def test_an_empty_capture_is_refused_rather_than_drawn_blank():
with pytest.raises(ValueError):
wf.render_waterfall(np.zeros(0), FS)
# -- IQ says radio frequency, audio says audio -------------------------------
def test_raw_iq_is_drawn_around_the_tuned_frequency():
"""Where a scan kept the IQ, the picture is the spectrum an operator
would have been watching -- both sides of the carrier, not one."""
rate = 48000.0
t = np.arange(int(2.0 * rate)) / rate
rng = np.random.default_rng(4)
iq = (np.exp(2j * np.pi * -8000.0 * t)
+ 0.01 * (rng.standard_normal(t.size)
+ 1j * rng.standard_normal(t.size))).astype(np.complex64)
canvas, drawn = wf.render_waterfall(iq, rate, complex_input=True,
centre_hz=146.52e6)
assert drawn.source == "iq"
assert drawn.centre_hz == pytest.approx(146.52e6)
hz = (brightest_column(canvas) / wf.WIDTH - 0.5) * rate
assert abs(hz + 8000.0) < 500.0, f"drawn at {hz:.0f} Hz from centre"
def test_the_caption_says_which_picture_it_is():
"""After an FM detector the frequency axis is audio, not radio, and a
picture that did not say so would be a lie told in a convincing font."""
assert "DEMODULATED AUDIO" in wf.caption_for(146.52e6, "nfm", 4.0, "", "audio")
assert "RF SPECTRUM" in wf.caption_for(146.52e6, "nfm", 4.0, "", "iq")
said = wf.caption_for(146.52e6, "nfm", 12.5, "OOK / ASK data burst", "audio")
assert "146.520000 MHZ" in said and "NFM" in said and "12.5 S" in said
assert "OOK / ASK DATA BURST" in said
# -- the file ---------------------------------------------------------------
def test_it_writes_a_real_png(tmp_path):
out = tmp_path / "capture_waterfall.png"
drawn = wf.write_waterfall(out, tone(1200.0), FS, caption="TEST")
assert out.read_bytes()[:8] == PNG_SIGNATURE
assert drawn.path == str(out)
assert "demodulated audio" in drawn.summary()
def test_the_picture_is_named_for_the_recording(tmp_path):
assert wf.waterfall_path(tmp_path / "0146.520000MHz--x-nfm.wav").name == \
"0146.520000MHz--x-nfm_waterfall.png"
def test_a_recording_with_no_samples_draws_nothing(tmp_path):
assert wf.draw_for_recording(tmp_path / "x.wav", audio=None, rate=0) is None
assert wf.draw_for_recording(tmp_path / "x.wav",
audio=np.zeros(4), rate=FS) is None
def test_the_iq_is_preferred_where_a_scan_kept_it(tmp_path):
"""The audio is what is left after a detector threw most of it away."""
rate = 48000.0
t = np.arange(int(1.0 * rate)) / rate
rng = np.random.default_rng(5)
iq = (np.exp(2j * np.pi * 5000.0 * t)
+ 0.01 * (rng.standard_normal(t.size)
+ 1j * rng.standard_normal(t.size))).astype(np.complex64)
raw = tmp_path / "cap.cf32"
iq.tofile(raw)
drawn = wf.draw_for_recording(tmp_path / "cap.wav", audio=tone(1200.0),
rate=FS, iq_path=str(raw), iq_rate=rate,
frequency=146.52e6)
assert drawn is not None and drawn.source == "iq"
assert drawn.seconds == pytest.approx(1.0, abs=0.02)
def test_unreadable_iq_falls_back_to_the_audio(tmp_path):
broken = tmp_path / "cap.cf32"
broken.write_bytes(b"")
drawn = wf.draw_for_recording(tmp_path / "cap.wav", audio=tone(1200.0),
rate=FS, iq_path=str(broken), iq_rate=48000.0)
assert drawn is not None and drawn.source == "audio"
# -- the text -----------------------------------------------------------------
def test_an_unknown_character_is_a_space_not_a_failure():
"""A label is a convenience; a picture refused because of one odd
character in a caption would be a poor trade."""
canvas = np.zeros((20, 200, 3), dtype=np.uint8)
wf.draw_text(canvas, 2, 2, "AéB")
assert canvas.any()
def test_a_label_that_runs_off_the_edge_is_clipped_not_wrapped():
canvas = np.zeros((20, 40, 3), dtype=np.uint8)
wf.draw_text(canvas, 30, 2, "1234567890")
assert not canvas[10:].any() # nothing wrapped onto a later row
# -- what a scan does with it ------------------------------------------------
def _scan(tmp_path, said: str | None, **over):
"""One capture of one voice transmission, with a stubbed recogniser.
``said`` is what the recogniser comes back with; None means no recogniser
is installed at all, which is the commonest case in the wild.
"""
from bandsaunter import transcribe as tr
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
from bandsaunter.scanner import Scanner
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=4.0,
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
max_cycles=1, revisit_seconds=0.2,
transcribe=said is not None,
transcribe_engine="fake", callsign_lookup=False, **over)
scanner = Scanner(cfg, device=SimulatedDevice(
transmitters=[V(146_520_000, "nfm", 0.4, 12_500, "v")]).open())
scanner.prepare()
scanner.run()
return scanner
@pytest.fixture
def recogniser(monkeypatch):
"""A stub recogniser whose answer each test chooses."""
from bandsaunter import transcribe as tr
said = {"text": ""}
monkeypatch.setitem(
tr._DISPATCH, "fake",
lambda audio, rate, model, lang: tr.Transcript(text=said["text"],
engine="fake"))
monkeypatch.setattr(tr, "ENGINES", ("fake",) + tr.ENGINES)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
# Nothing in this file is about whether a clip has signal in it.
monkeypatch.setattr(tr, "_has_signal", lambda audio, rate: True)
return said
def drawings(tmp_path) -> list[Path]:
return sorted(tmp_path.glob("*_waterfall.png"))
def test_a_capture_with_no_recogniser_is_drawn(tmp_path):
"""The commonest case in the wild: nothing installed, so nothing can be
read, so everything gets a picture."""
scanner = _scan(tmp_path, None)
assert scanner.stats.recordings >= 1
assert len(drawings(tmp_path)) == scanner.stats.recordings
def test_a_capture_the_recogniser_could_read_is_not_drawn(tmp_path,
recogniser):
recogniser["text"] = "Net control, this is W1AW, standing by."
scanner = _scan(tmp_path, recogniser["text"])
assert scanner.stats.recordings >= 1
assert drawings(tmp_path) == []
def test_a_capture_the_recogniser_could_not_read_is_drawn(tmp_path,
recogniser):
"""A recogniser handed something that is not speech comes back with a
word or two of nothing in particular, and a picture is worth more."""
recogniser["text"] = "You"
scanner = _scan(tmp_path, recogniser["text"])
assert scanner.stats.recordings >= 1
assert len(drawings(tmp_path)) == scanner.stats.recordings
def test_a_capture_that_produced_no_words_at_all_is_drawn(tmp_path,
recogniser):
"""The recogniser writes no transcript file for a silent capture, so
this is the case that had nothing at all to show for it."""
recogniser["text"] = ""
scanner = _scan(tmp_path, "")
assert scanner.stats.recordings >= 1
assert len(drawings(tmp_path)) == scanner.stats.recordings
assert not list(tmp_path.glob("*_transcription.txt"))
def test_the_picture_is_recorded_in_the_sidecar(tmp_path):
_scan(tmp_path, None)
for meta in tmp_path.glob("*.json"):
if meta.name.startswith("scan_log"):
continue
hit = json.loads(meta.read_text()).get("hit") or {}
assert hit.get("waterfall_path"), meta.name
assert Path(hit["waterfall_path"]).is_file()
def test_switching_it_off_draws_nothing(tmp_path):
scanner = _scan(tmp_path, None, waterfall=False)
assert scanner.stats.recordings >= 1
assert drawings(tmp_path) == []
def test_the_bar_for_readable_can_be_moved(tmp_path, recogniser):
"""Five characters is a default, not a law."""
recogniser["text"] = "Roger"
scanner = _scan(tmp_path, recogniser["text"], waterfall_min_chars=40)
assert scanner.stats.recordings >= 1
assert len(drawings(tmp_path)) == scanner.stats.recordings
# -- and for a directory already recorded ------------------------------------
def _recording(directory: Path, name: str, audio: np.ndarray,
rate: int = FS, hit: dict | None = None,
transcript: str = "") -> Path:
wav = directory / f"{name}.wav"
with wave.open(str(wav), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes((np.clip(audio, -1, 1) * 32000).astype("<i2").tobytes())
if hit is not None:
(directory / f"{name}.json").write_text(json.dumps({"hit": hit}))
if transcript:
(directory / f"{name}_transcription.txt").write_text(transcript + "\n")
return wav
def _waterfall_command(*args) -> int:
from bandsaunter.cli import main
return main(["waterfall", *args])
def test_the_command_draws_only_what_cannot_be_read(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
_recording(tmp_path, "0146.520000MHz--a-nfm", tone(1200.0),
hit={"category": "voice", "frequency": 146.52e6, "mode": "nfm"},
transcript="net control this is W1AW standing by")
_recording(tmp_path, "0146.940000MHz--b-ook", tone(2400.0, keyed=True),
hit={"category": "digital", "frequency": 146.94e6,
"mode": "nfm", "classification": "OOK / ASK data burst"})
assert _waterfall_command(str(tmp_path)) == 0
drawn = [p.name for p in drawings(tmp_path)]
assert drawn == ["0146.940000MHz--b-ook_waterfall.png"]
def test_short_words_do_not_count_as_having_read_it(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
_recording(tmp_path, "0146.520000MHz--a-nfm", tone(1200.0),
hit={"category": "voice", "frequency": 146.52e6}, transcript="You")
assert _waterfall_command(str(tmp_path)) == 0
assert len(drawings(tmp_path)) == 1
def test_all_draws_the_readable_ones_too(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
_recording(tmp_path, "0146.520000MHz--a-nfm", tone(1200.0),
hit={"category": "voice", "frequency": 146.52e6},
transcript="a whole sentence of perfectly good speech")
assert _waterfall_command(str(tmp_path), "--all") == 0
assert len(drawings(tmp_path)) == 1
def test_it_does_not_redraw_what_it_already_drew(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
wav = _recording(tmp_path, "0146.940000MHz--b-ook", tone(2400.0),
hit={"category": "digital", "frequency": 146.94e6})
assert _waterfall_command(str(tmp_path)) == 0
first = wf.waterfall_path(wav).stat().st_mtime_ns
assert _waterfall_command(str(tmp_path)) == 0
assert wf.waterfall_path(wav).stat().st_mtime_ns == first
assert _waterfall_command(str(tmp_path), "--redraw") == 0
assert wf.waterfall_path(wav).stat().st_mtime_ns != first
def test_a_recording_with_no_sidecar_is_drawn(tmp_path, monkeypatch):
"""Nothing is known about it, which is the strongest reason to draw it."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
_recording(tmp_path, "0146.940000MHz--b-nfm", tone(2400.0))
assert _waterfall_command(str(tmp_path)) == 0
assert len(drawings(tmp_path)) == 1
def test_the_command_notes_the_picture_in_the_sidecar(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
_recording(tmp_path, "0146.940000MHz--b-ook", tone(2400.0),
hit={"category": "digital", "frequency": 146.94e6})
assert _waterfall_command(str(tmp_path)) == 0
hit = json.loads((tmp_path / "0146.940000MHz--b-ook.json").read_text())["hit"]
assert Path(hit["waterfall_path"]).is_file()