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
47
README.md
47
README.md
|
|
@ -876,6 +876,53 @@ picture is still a picture.
|
||||||
> demodulates. Where a decoded byte stream begins with its magic number it is
|
> demodulates. Where a decoded byte stream begins with its magic number it is
|
||||||
> named; nothing here fetches or renders one.
|
> 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
|
## Aircraft
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ and transcribing speech.
|
||||||
# Versions are the release date and a revision within that day, so
|
# 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
|
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||||
# to two digits so versions sort as text.
|
# to two digits so versions sort as text.
|
||||||
VERSION_DATE = "2026-09-01"
|
VERSION_DATE = "2026-09-03"
|
||||||
VERSION_REVISION = 2
|
VERSION_REVISION = 1
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,9 +218,33 @@ class Capture:
|
||||||
height = int(self.meta.get("image_height") or 0)
|
height = int(self.meta.get("image_height") or 0)
|
||||||
return f"{width}x{height}" if width and height else ""
|
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
|
@property
|
||||||
def image_headline(self) -> str:
|
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):
|
if not (self.image_kind or self.image_path):
|
||||||
return ""
|
return ""
|
||||||
bits = [self.image_kind or "image"]
|
bits = [self.image_kind or "image"]
|
||||||
|
|
@ -777,7 +801,7 @@ class Browser:
|
||||||
return True
|
return True
|
||||||
# And by what kind of picture it is: "/sstv" and "/apt" are how
|
# And by what kind of picture it is: "/sstv" and "/apt" are how
|
||||||
# anybody would look for the ones worth keeping.
|
# 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 True
|
||||||
return q in cap.transcript.lower()
|
return q in cap.transcript.lower()
|
||||||
|
|
||||||
|
|
@ -1016,6 +1040,22 @@ class Browser:
|
||||||
return [line.plain.rstrip()
|
return [line.plain.rstrip()
|
||||||
for line in text.wrap(self.console, width)]
|
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:
|
def _transcript_height(self) -> int:
|
||||||
"""Tall enough for the words, but never more than a third of the screen.
|
"""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 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)))
|
ceiling = max(6, min(20 if calls else 16, screen // (2 if calls else 3)))
|
||||||
cap = self.current
|
cap = self.current
|
||||||
if cap is not None and cap.image_headline:
|
if self._picture_panel_wins(cap):
|
||||||
body = len(self._image_lines(cap))
|
body = len(self._image_lines(cap))
|
||||||
else:
|
else:
|
||||||
body = (len(self._transcript_lines()) or len(self._morse_lines())
|
body = (len(self._transcript_lines()) or len(self._morse_lines())
|
||||||
|
|
@ -1083,7 +1123,7 @@ class Browser:
|
||||||
height = self._transcript_height()
|
height = self._transcript_height()
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return Panel("", border_style="bright_black", height=height)
|
return Panel("", border_style="bright_black", height=height)
|
||||||
if cap.image_headline:
|
if self._picture_panel_wins(cap):
|
||||||
return self._image_panel(cap, height)
|
return self._image_panel(cap, height)
|
||||||
morse = self._morse_lines()
|
morse = self._morse_lines()
|
||||||
if morse:
|
if morse:
|
||||||
|
|
@ -1163,7 +1203,7 @@ class Browser:
|
||||||
so the most useful thing it can do is say exactly what to open.
|
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)
|
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
|
files = cap.images
|
||||||
if not files:
|
if not files:
|
||||||
return lines + ["the picture file is not beside the recording"]
|
return lines + ["the picture file is not beside the recording"]
|
||||||
|
|
@ -1190,7 +1230,8 @@ class Browser:
|
||||||
for line in calls[1:]:
|
for line in calls[1:]:
|
||||||
body.append("\n")
|
body.append("\n")
|
||||||
body.append(line, style="not bold white")
|
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)
|
border_style="magenta", padding=(1, 3), height=height)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -1242,6 +1283,11 @@ class Browser:
|
||||||
style="yellow")
|
style="yellow")
|
||||||
if cap.image_kind:
|
if cap.image_kind:
|
||||||
line.append(f" {len(cap.images)} file(s)", style="magenta")
|
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 []
|
checks = cap.meta.get("data_checks") or []
|
||||||
if checks:
|
if checks:
|
||||||
# A checksum that came out right is the strongest thing anyone
|
# 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))
|
room = max(3, screen - 4 - (len(calls) + 1 if calls else 0))
|
||||||
lines = self._transcript_lines(pad=4)
|
lines = self._transcript_lines(pad=4)
|
||||||
title_word = "transcript"
|
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)
|
lines = self._image_lines(cap, pad=4)
|
||||||
title_word = "picture"
|
title_word = "picture"
|
||||||
if not lines and cap is not None and cap.morse:
|
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
|
# it all" when a long paging capture overflows it, and the key has
|
||||||
# to mean what the panel says it means.
|
# to mean what the panel says it means.
|
||||||
if cap is not None and (cap.transcript or cap.morse
|
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.reading = True
|
||||||
self.read_top = 0
|
self.read_top = 0
|
||||||
else:
|
else:
|
||||||
|
|
@ -1583,8 +1629,10 @@ class Browser:
|
||||||
if cap is not None:
|
if cap is not None:
|
||||||
# The picture where there is one: on a capture that turned
|
# The picture where there is one: on a capture that turned
|
||||||
# out to be an image, the PNG is what anybody wants to pipe
|
# out to be an image, the PNG is what anybody wants to pipe
|
||||||
# into something else, not the audio it arrived as.
|
# into something else, not the audio it arrived as. A
|
||||||
self.message = cap.image_path or str(cap.path)
|
# 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
|
return False
|
||||||
elif key in _FILING_KEYS:
|
elif key in _FILING_KEYS:
|
||||||
self._file_into(_FILING_KEYS[key])
|
self._file_into(_FILING_KEYS[key])
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,19 @@ examples:
|
||||||
tr.add_argument("--stdout", action="store_true",
|
tr.add_argument("--stdout", action="store_true",
|
||||||
help="print instead of writing _transcription.txt files")
|
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 ----------------------------------------------------------
|
# -- devices ----------------------------------------------------------
|
||||||
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
|
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
|
||||||
d.add_argument("--test", action="store_true",
|
d.add_argument("--test", action="store_true",
|
||||||
|
|
@ -713,6 +726,101 @@ def cmd_transcribe(args) -> int:
|
||||||
return 1 if failures else 0
|
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:
|
def cmd_devices(args) -> int:
|
||||||
# This is the command people run when something is wrong, so let the
|
# This is the command people run when something is wrong, so let the
|
||||||
# driver say what it is doing.
|
# driver say what it is doing.
|
||||||
|
|
@ -1061,7 +1169,7 @@ def main(argv=None) -> int:
|
||||||
"scan": cmd_scan, "bands": cmd_bands, "devices": cmd_devices,
|
"scan": cmd_scan, "bands": cmd_bands, "devices": cmd_devices,
|
||||||
"config": cmd_config, "transcribe": cmd_transcribe,
|
"config": cmd_config, "transcribe": cmd_transcribe,
|
||||||
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
||||||
"adsb": cmd_adsb,
|
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return handlers[args.command](args)
|
return handlers[args.command](args)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,10 @@ class ScanConfig:
|
||||||
decode_morse: bool = True
|
decode_morse: bool = True
|
||||||
decode_data: bool = True # read packets out of data signals
|
decode_data: bool = True # read packets out of data signals
|
||||||
decode_images: bool = True # SSTV, weather satellites and shortwave fax
|
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 ------------------------------------------
|
# -- one file per frequency ------------------------------------------
|
||||||
combine_by_frequency: bool = False
|
combine_by_frequency: bool = False
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ class HitRecord:
|
||||||
image_height: int = 0
|
image_height: int = 0
|
||||||
image_complete: bool = True
|
image_complete: bool = True
|
||||||
image_paths: list[str] = field(default_factory=list) # the extra channels
|
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
|
category: str = "" # voice / cw / digital / carrier / noise
|
||||||
signal_score: float = 0.0
|
signal_score: float = 0.0
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ rules from the configuration decide when to leave a signal:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -30,6 +31,7 @@ from .device import RtlSdrDevice, RtlSdrError
|
||||||
from .kml import KmlLog
|
from .kml import KmlLog
|
||||||
from .images import ImageDecode
|
from .images import ImageDecode
|
||||||
from .pictures import find_image
|
from .pictures import find_image
|
||||||
|
from .waterfall import draw_for_recording, waterfall_path
|
||||||
from .morse import decode_morse, find_morse
|
from .morse import decode_morse, find_morse
|
||||||
from .quality import Assessment, assess
|
from .quality import Assessment, assess
|
||||||
from .ranges import Lockout, TuneStep, build_plan
|
from .ranges import Lockout, TuneStep, build_plan
|
||||||
|
|
@ -936,7 +938,12 @@ class Scanner:
|
||||||
hit.meta_path = str(rec.write_metadata(hit))
|
hit.meta_path = str(rec.write_metadata(hit))
|
||||||
# Queued after the sidecar exists, so the transcriber can record the
|
# Queued after the sidecar exists, so the transcriber can record the
|
||||||
# result in it -- and record nothing when there was no speech.
|
# 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.recordings += 1
|
||||||
self.stats.seconds_recorded += duration
|
self.stats.seconds_recorded += duration
|
||||||
self.hits.append(hit)
|
self.hits.append(hit)
|
||||||
|
|
@ -1095,17 +1102,68 @@ class Scanner:
|
||||||
return (longer if len(longer.complete_text) > len(morse.complete_text)
|
return (longer if len(longer.complete_text) > len(morse.complete_text)
|
||||||
else morse)
|
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.
|
"""Queue a voice capture for speech recognition.
|
||||||
|
|
||||||
Only voice: running a recogniser over Morse or a data burst wastes
|
Only voice: running a recogniser over Morse or a data burst wastes
|
||||||
seconds per capture and produces nothing.
|
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
|
worker = self.transcriber
|
||||||
if worker is None or hit.category != "voice":
|
if worker is None or hit.category != "voice":
|
||||||
return
|
return False
|
||||||
if hit.duration < self.cfg.transcribe_min_seconds:
|
if hit.duration < self.cfg.transcribe_min_seconds:
|
||||||
return
|
return False
|
||||||
|
|
||||||
audio, rate = None, rec.audio_rate
|
audio, rate = None, rec.audio_rate
|
||||||
if rec.audio_path.exists():
|
if rec.audio_path.exists():
|
||||||
|
|
@ -1116,7 +1174,7 @@ class Scanner:
|
||||||
if audio is None or not audio.size:
|
if audio is None or not audio.size:
|
||||||
audio = rec.classification_audio(active_only=False)
|
audio = rec.classification_audio(active_only=False)
|
||||||
if audio is None or not audio.size:
|
if audio is None or not audio.size:
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Beside the recording, sharing its name. When captures are being
|
# Beside the recording, sharing its name. When captures are being
|
||||||
# combined by frequency there is one recording per frequency, so the
|
# combined by frequency there is one recording per frequency, so the
|
||||||
|
|
@ -1128,11 +1186,11 @@ class Scanner:
|
||||||
else:
|
else:
|
||||||
path = rec.dir / f"{rec.stem}_transcription.txt"
|
path = rec.dir / f"{rec.stem}_transcription.txt"
|
||||||
append = False
|
append = False
|
||||||
worker.submit(audio, rate, path,
|
return worker.submit(
|
||||||
datetime.fromtimestamp(rec.started_at), hit.frequency,
|
audio, rate, path, datetime.fromtimestamp(rec.started_at),
|
||||||
append=append,
|
hit.frequency, append=append,
|
||||||
meta_path=Path(hit.meta_path) if hit.meta_path else None,
|
meta_path=Path(hit.meta_path) if hit.meta_path else None,
|
||||||
recording=rec.audio_path.name)
|
recording=rec.audio_path.name, audio_path=rec.audio_path)
|
||||||
|
|
||||||
# Families whose signals carry bits. Voice and Morse are excluded not to
|
# 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
|
# 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
|
is best-effort: a scan must not fail because a website did not
|
||||||
answer.
|
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:
|
if self.callsigns is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|
@ -1377,6 +1443,48 @@ class Scanner:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._error(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:
|
def _announce_callsign(self, entry) -> None:
|
||||||
"""Say on the display who has just identified themselves."""
|
"""Say on the display who has just identified themselves."""
|
||||||
if self.heard.get(entry.call, 0) > 1:
|
if self.heard.get(entry.call, 0) > 1:
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,18 @@ _TABLE: tuple[Setting, ...] = (
|
||||||
"Some transmissions are pictures rather than sounds. This looks for "
|
"Some transmissions are pictures rather than sounds. This looks for "
|
||||||
"them in every recording and writes what it finds beside the audio.",
|
"them in every recording and writes what it finds beside the audio.",
|
||||||
flags=("--images",), off_flags=("--no-images",)),
|
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",
|
S("decode_morse", "Decode CW to text", "Output", "bool",
|
||||||
"decode keyed carriers as Morse",
|
"decode keyed carriers as Morse",
|
||||||
"Speed is measured from the signal, so nothing needs configuring. "
|
"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 "
|
"recognised by its own header rather than guessed at, so this costs "
|
||||||
"a moment per recording and finds nothing where there is nothing. "
|
"a moment per recording and finds nothing where there is nothing. "
|
||||||
"What it does find is written as a PNG beside the audio.",
|
"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":
|
"decode_morse":
|
||||||
"Turn keyed carriers into readable text, with the sending speed. "
|
"Turn keyed carriers into readable text, with the sending speed. "
|
||||||
"Morse is still in daily use by amateurs and by beacons, and this "
|
"Morse is still in daily use by amateurs and by beacons, and this "
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,7 @@ class Job:
|
||||||
append: bool
|
append: bool
|
||||||
meta_path: Path | None = None
|
meta_path: Path | None = None
|
||||||
recording: str = "" # filename of the audio, for reference
|
recording: str = "" # filename of the audio, for reference
|
||||||
|
audio_path: Path | None = None # and where that file is
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionWorker:
|
class TranscriptionWorker:
|
||||||
|
|
@ -396,7 +397,9 @@ class TranscriptionWorker:
|
||||||
language: str = "en", max_queue: int = 32,
|
language: str = "en", max_queue: int = 32,
|
||||||
on_done=None, on_error=None):
|
on_done=None, on_error=None):
|
||||||
# on_done(path, Transcript, Job) -- called on the worker thread once
|
# 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.engine = engine
|
||||||
self.model = model
|
self.model = model
|
||||||
self.language = language
|
self.language = language
|
||||||
|
|
@ -433,14 +436,16 @@ class TranscriptionWorker:
|
||||||
# -- work --------------------------------------------------------------
|
# -- work --------------------------------------------------------------
|
||||||
def submit(self, audio: np.ndarray, rate: float, path: Path,
|
def submit(self, audio: np.ndarray, rate: float, path: Path,
|
||||||
when: datetime, frequency: float, append: bool = False,
|
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:
|
if audio is None or audio.size == 0:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self._queue.put_nowait(
|
self._queue.put_nowait(
|
||||||
Job(np.asarray(audio, dtype=np.float32), float(rate),
|
Job(np.asarray(audio, dtype=np.float32), float(rate),
|
||||||
Path(path), when, float(frequency), append,
|
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
|
return True
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
self.dropped += 1
|
self.dropped += 1
|
||||||
|
|
@ -471,8 +476,11 @@ class TranscriptionWorker:
|
||||||
if not body:
|
if not body:
|
||||||
# Nothing was said. Writing a file to announce that leaves a
|
# Nothing was said. Writing a file to announce that leaves a
|
||||||
# directory full of placeholders, so write nothing at all; the
|
# 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.empty += 1
|
||||||
|
self._finished(job, result)
|
||||||
return
|
return
|
||||||
|
|
||||||
if job.append:
|
if job.append:
|
||||||
|
|
@ -484,11 +492,15 @@ class TranscriptionWorker:
|
||||||
fh.write(f"{body}\n")
|
fh.write(f"{body}\n")
|
||||||
self.written += 1
|
self.written += 1
|
||||||
self._record_in_metadata(job, body)
|
self._record_in_metadata(job, body)
|
||||||
if self.on_done:
|
self._finished(job, result)
|
||||||
try:
|
|
||||||
self.on_done(job.path, result, job)
|
def _finished(self, job: Job, result: Transcript) -> None:
|
||||||
except Exception:
|
if not self.on_done:
|
||||||
pass
|
return
|
||||||
|
try:
|
||||||
|
self.on_done(job.path, result, job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _record_in_metadata(job: Job, text: str) -> None:
|
def _record_in_metadata(job: Job, text: str) -> None:
|
||||||
|
|
|
||||||
441
bandsaunter/waterfall.py
Normal file
441
bandsaunter/waterfall.py
Normal 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))
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
@ -62,6 +62,12 @@ Transcribe existing recordings, or list which speech recognisers are
|
||||||
installed with
|
installed with
|
||||||
.BR \-\-engines .
|
.BR \-\-engines .
|
||||||
.TP
|
.TP
|
||||||
|
.B waterfall
|
||||||
|
Draw a waterfall for every recording in a directory that produced no
|
||||||
|
readable words. See
|
||||||
|
.B WATERFALLS
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
.B devices
|
.B devices
|
||||||
List attached receivers.
|
List attached receivers.
|
||||||
.TP
|
.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.
|
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
|
.RE
|
||||||
.TP
|
.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
|
.B --morse / --no-morse
|
||||||
Decode CW to text \[em] decode keyed carriers as Morse.
|
Decode CW to text \[em] decode keyed carriers as Morse.
|
||||||
.br
|
.br
|
||||||
|
|
@ -999,6 +1025,37 @@ Nothing is joined across a slash: a suffix says where the station is, not
|
||||||
what it is called, so
|
what it is called, so
|
||||||
.I W1AW/B
|
.I W1AW/B
|
||||||
is W1AW.
|
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
|
.SH CW AND IDENTIFICATION
|
||||||
Every capture is offered to a CW decoder once it has finished, whatever the
|
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:
|
classifier made of it. Most of the Morse on the air is not a conversation:
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
transcripts work regardless; only Enter has nothing to do.
|
||||||
.SH DETECTED CALLSIGNS
|
.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
|
Under the transcript, headed
|
||||||
.BR "DETECTED CALLSIGNS:" ,
|
.BR "DETECTED CALLSIGNS:" ,
|
||||||
is every callsign heard in it, with the name and location on the licence.
|
is every callsign heard in it, with the name and location on the licence.
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,12 @@ Transcribe existing recordings, or list which speech recognisers are
|
||||||
installed with
|
installed with
|
||||||
.BR \-\-engines .
|
.BR \-\-engines .
|
||||||
.TP
|
.TP
|
||||||
|
.B waterfall
|
||||||
|
Draw a waterfall for every recording in a directory that produced no
|
||||||
|
readable words. See
|
||||||
|
.B WATERFALLS
|
||||||
|
below.
|
||||||
|
.TP
|
||||||
.B devices
|
.B devices
|
||||||
List attached receivers.
|
List attached receivers.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -412,6 +418,37 @@ Nothing is joined across a slash: a suffix says where the station is, not
|
||||||
what it is called, so
|
what it is called, so
|
||||||
.I W1AW/B
|
.I W1AW/B
|
||||||
is W1AW.
|
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
|
.SH CW AND IDENTIFICATION
|
||||||
Every capture is offered to a CW decoder once it has finished, whatever the
|
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:
|
classifier made of it. Most of the Morse on the air is not a conversation:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
saunterbrowse \- read and listen to what a bandsaunter scan collected
|
||||||
.SH SYNOPSIS
|
.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
|
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.
|
transcripts work regardless; only Enter has nothing to do.
|
||||||
.SH DETECTED CALLSIGNS
|
.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
|
Under the transcript, headed
|
||||||
.BR "DETECTED CALLSIGNS:" ,
|
.BR "DETECTED CALLSIGNS:" ,
|
||||||
is every callsign heard in it, with the name and location on the licence.
|
is every callsign heard in it, with the name and location on the licence.
|
||||||
|
|
|
||||||
|
|
@ -1024,3 +1024,64 @@ def test_no_callsigns_means_no_map(tmp_path, monkeypatch):
|
||||||
meta={"category": "voice"})
|
meta={"category": "voice"})
|
||||||
assert main([str(tmp_path), "--kml"]) == 1
|
assert main([str(tmp_path), "--kml"]) == 1
|
||||||
assert not list(tmp_path.glob("*.kml"))
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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.image_kind == "SSTV" and hit.image_mode == "Martin M1"
|
||||||
assert hit.category == "image"
|
assert hit.category == "image"
|
||||||
assert hit.kept, "a picture was decoded and then discarded"
|
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 written
|
||||||
assert _read_png(written[0]).shape[1] == 320
|
assert _read_png(written[0]).shape[1] == 320
|
||||||
|
|
|
||||||
378
tests/test_waterfall.py
Normal file
378
tests/test_waterfall.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue