Never call a string of Morse tones "words"

A repeater identifying itself in CW over an FM carrier came back from the
recogniser as "2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0" -- one digit per tone,
sixteen characters of nothing, which cleared the five-character bar and
cost the capture its waterfall.

The rule now lives in one place, waterfall.is_readable, shared by the
scanner and the waterfall command: voice, no Morse, and more than a
handful of characters.

  bandsaunter waterfall --check-morse

runs the CW decoder over the recordings a sidecar calls readable, for
sidecars written before the decoder could hear an ident over an FM
carrier, and draws -- and records the ident in -- the ones that have one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-03 21:43:51 -07:00
parent dee262e130
commit a8a8548369
9 changed files with 190 additions and 29 deletions

View file

@ -891,12 +891,26 @@ 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.
A capture with Morse in it never counts as readable, however much the
recogniser made of it. A station identifying itself in CW over an FM
carrier comes back as a string of digits, one per tone — sixteen characters
of nothing, sailing past any bar you set. The ident is in the Morse text;
the signal itself is only visible as a picture.
```bash
bandsaunter waterfall # draw a directory already recorded
bandsaunter waterfall --all # including the ones that read fine
bandsaunter waterfall --check-morse # listen again before believing them
bandsaunter waterfall --redraw recordings/
```
`--check-morse` is for recordings made before the CW decoder could hear an
ident over an FM carrier: their sidecars call a repeater readable, because
the recogniser turned its tones into digits. It runs the decoder over the
recordings that would otherwise be skipped, draws the ones that turn out to
have an ident in them, and writes the ident into the sidecar so the browser
shows it and the next run needs no second listen.
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.

View file

@ -9,7 +9,7 @@ and transcribing speech.
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-03"
VERSION_REVISION = 1
VERSION_REVISION = 2
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -139,6 +139,9 @@ examples:
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")
wf.add_argument("--check-morse", action="store_true",
help="listen for a CW ident in the recordings a sidecar "
"calls readable, and draw the ones that have one")
# -- devices ----------------------------------------------------------
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
@ -729,8 +732,9 @@ def cmd_transcribe(args) -> int:
def cmd_waterfall(args) -> int:
"""Draw the captures nobody can read, for a directory already recorded."""
import json as _json
from .morse import find_morse
from .recorder import read_wav
from .waterfall import draw_for_recording, waterfall_path
from .waterfall import draw_for_recording, is_readable, waterfall_path
cfg, _ = load_default()
floor = args.min_chars if args.min_chars is not None \
@ -776,9 +780,8 @@ def cmd_waterfall(args) -> int:
# 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:
readable = is_readable(hit, transcript, floor)
if readable and not args.all and not args.check_morse:
skipped += 1
continue
@ -788,6 +791,21 @@ def cmd_waterfall(args) -> int:
console.print(f"[red]{wav.name}: {exc}[/red]")
failed += 1
continue
# A sidecar written before the decoder could hear an ident over an
# FM carrier calls a repeater readable, because the recogniser turned
# its tones into a long string of digits. Listening again is the
# only way to know, and it is only worth it for the ones that would
# otherwise be skipped.
ident = None
if readable and not args.all:
ident = find_morse(audio, rate)
if ident is None or not ident.is_morse:
skipped += 1
continue
console.print(f" [grey62]{wav.name}: CW ident "
f'"{ident.complete_text}"[/grey62]')
iq = str(hit.get("iq_path") or "")
try:
picture = draw_for_recording(
@ -812,6 +830,13 @@ def cmd_waterfall(args) -> int:
body = _json.loads(meta.read_text())
if isinstance(body.get("hit"), dict):
body["hit"]["waterfall_path"] = picture.path
if ident is not None:
# Found the hard way; worth keeping, so the browser
# shows the ident and the next run knows without
# listening again.
body["hit"]["morse_text"] = ident.text
body["hit"]["morse_complete"] = ident.complete_text
body["hit"]["morse_wpm"] = round(ident.wpm, 1)
meta.write_text(_json.dumps(body, indent=2, default=str))
except (OSError, ValueError):
pass

View file

@ -31,7 +31,7 @@ from .device import RtlSdrDevice, RtlSdrError
from .kml import KmlLog
from .images import ImageDecode
from .pictures import find_image
from .waterfall import draw_for_recording, waterfall_path
from .waterfall import draw_for_recording, is_readable, waterfall_path
from .morse import decode_morse, find_morse
from .quality import Assessment, assess
from .ranges import Lockout, TuneStep, build_plan
@ -1102,18 +1102,6 @@ class Scanner:
return (longer if len(longer.complete_text) > len(morse.complete_text)
else morse)
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.
@ -1426,9 +1414,8 @@ class Scanner:
# 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)
self._waterfall_for_job(job, result.text)
except Exception as exc:
self._error(exc)
if self.callsigns is None:
@ -1443,7 +1430,7 @@ class Scanner:
except Exception as exc:
self._error(exc)
def _waterfall_for_job(self, job) -> None:
def _waterfall_for_job(self, job, transcript: str = "") -> None:
"""Draw a capture whose transcript came back empty or near enough.
Works from the samples the job is already carrying rather than
@ -1464,6 +1451,12 @@ class Scanner:
classification = str(meta.get("classification") or "")
if meta.get("waterfall_path"):
return # already drawn
# Judged from the sidecar, which is where the Morse ended up. With
# no sidecar the category is not in doubt either: nothing but voice
# is ever handed to a recogniser.
if is_readable({"category": "voice", **meta}, transcript,
self.cfg.waterfall_min_chars):
return
drawn = draw_for_recording(
job.audio_path, audio=job.audio, rate=job.rate,
frequency=job.frequency, mode=mode, classification=classification)

View file

@ -27,13 +27,12 @@ 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"]
"draw_for_recording", "waterfall_path", "is_readable",
"caption_for", "read_iq", "COLOURS"]
# ---------------------------------------------------------------------------
@ -200,6 +199,28 @@ def waterfall_path(audio_path) -> Path:
return path.with_name(path.stem + "_waterfall.png")
def is_readable(hit, transcript: str, floor: int) -> bool:
"""Did this capture produce words worth having, so no picture is needed?
Voice, with a transcript longer than a handful of characters, and no
Morse in it. A recogniser handed a data burst reliably comes back with
one short word of nothing in particular, which is why the bar is a
length rather than merely "did it say anything at all".
Morse never counts, however long the transcript is. A station
identifying itself in CW over an FM carrier is transcribed as a string
of digits -- one per tone -- which sails past any bar and says nothing;
the ident is in the Morse text, and the signal itself is only visible
as a picture.
"""
if str(hit.get("category") or "") != "voice":
return False
if str(hit.get("morse_text") or "").strip() or \
str(hit.get("morse_complete") or "").strip():
return False
return len(transcript.strip()) > floor
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_01" "User Commands"
.TH BANDSAUNTER 1 "2026-09-03" "bandsaunter 2026-09-03_02" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -1041,6 +1041,12 @@ 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
A capture with Morse in it is never counted as readable, however long the
transcript. A station identifying itself in CW over an FM carrier is
transcribed as a string of digits, one per tone, which clears any bar and
says nothing; the ident is in the Morse text and the signal is only visible
as a picture.
.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
@ -1056,6 +1062,10 @@ read unless
is given, and skipping what it has already drawn unless
.B \-\-redraw
is.
.B \-\-check\-morse
runs the CW decoder over the recordings it was about to skip, for sidecars
written before the decoder could hear an ident over an FM carrier, and draws
\[em] and records the ident in \[em] the ones that have one.
.SH CW AND IDENTIFICATION
Every capture is offered to a CW decoder once it has finished, whatever the
classifier made of it. Most of the Morse on the air is not a conversation:

View file

@ -434,6 +434,12 @@ 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
A capture with Morse in it is never counted as readable, however long the
transcript. A station identifying itself in CW over an FM carrier is
transcribed as a string of digits, one per tone, which clears any bar and
says nothing; the ident is in the Morse text and the signal is only visible
as a picture.
.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
@ -449,6 +455,10 @@ read unless
is given, and skipping what it has already drawn unless
.B \-\-redraw
is.
.B \-\-check\-morse
runs the CW decoder over the recordings it was about to skip, for sidecars
written before the decoder could hear an ident over an FM carrier, and draws
\[em] and records the ident in \[em] the ones that have one.
.SH CW AND IDENTIFICATION
Every capture is offered to a CW decoder once it has finished, whatever the
classifier made of it. Most of the Morse on the air is not a conversation:

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-09-03" "bandsaunter 2026-09-03_01" "User Commands"
.TH SAUNTERBROWSE 1 "2026-09-03" "bandsaunter 2026-09-03_02" "User Commands"
.SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS

View file

@ -376,3 +376,91 @@ def test_the_command_notes_the_picture_in_the_sidecar(tmp_path, monkeypatch):
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()
# -- Morse is never words ----------------------------------------------------
def test_a_transcript_of_morse_tones_is_not_words():
"""Whisper renders a CW ident as one digit per tone: sixteen characters
of nothing, which clears the bar and means nothing."""
hit = {"category": "voice", "morse_complete": "KSQ330"}
assert not wf.is_readable(hit, "2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0", 5)
def test_words_over_the_top_of_an_ident_are_still_words():
assert wf.is_readable({"category": "voice"},
"net control this is W1AW standing by", 5)
def test_the_partial_morse_text_counts_as_much_as_the_complete_one():
"""A keyed carrier whose timing only half resolved is still a keyed
carrier, and the transcript of it is still digits."""
hit = {"category": "voice", "morse_text": "K SQ 3 30"}
assert not wf.is_readable(hit, "3-3-0-5-9-7-0-8", 5)
def test_a_capture_that_identified_itself_in_morse_is_drawn(tmp_path,
recogniser,
monkeypatch):
"""The whole point: an FM carrier with a CW ident on the end of it gets
a picture, however long a string of digits the recogniser made of it."""
from bandsaunter.scanner import Scanner
class _Ident:
is_morse = True
text = "KSQ330"
complete_text = "KSQ330"
wpm = 20.0
confidence = 0.9
monkeypatch.setattr(Scanner, "_morse_from_recording",
lambda self, rec, morse: _Ident())
recogniser["text"] = "2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0"
scanner = _scan(tmp_path, recogniser["text"])
assert scanner.stats.recordings >= 1
assert len(drawings(tmp_path)) == scanner.stats.recordings
for meta in tmp_path.glob("*.json"):
if meta.name.startswith("scan_log"):
continue
assert json.loads(meta.read_text())["hit"]["morse_complete"] == "KSQ330"
def test_the_command_draws_an_ident_it_finds_in_the_sidecar(tmp_path,
monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
_recording(tmp_path, "0154.369068MHz--a-nfm", tone(1200.0),
hit={"category": "voice", "frequency": 154.369068e6,
"mode": "nfm", "morse_complete": "KSQ330"},
transcript="2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0")
assert _waterfall_command(str(tmp_path)) == 0
assert len(drawings(tmp_path)) == 1
def test_check_morse_listens_to_what_the_sidecar_calls_readable(tmp_path,
monkeypatch):
"""A sidecar written before the CW decoder could hear an ident over an FM
carrier calls a repeater readable. Listening again finds it."""
from morse_gen import morse_audio
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
ident = morse_audio("DE KSQ330 KSQ330", wpm=20, fs=FS, snr_db=25)
_recording(tmp_path, "0154.369068MHz--a-nfm", ident,
hit={"category": "voice", "frequency": 154.369068e6,
"mode": "nfm", "morse_text": "", "morse_complete": ""},
transcript="2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0")
assert _waterfall_command(str(tmp_path)) == 0
assert drawings(tmp_path) == [] # the sidecar was believed
assert _waterfall_command(str(tmp_path), "--check-morse") == 0
assert len(drawings(tmp_path)) == 1
hit = json.loads((tmp_path / "0154.369068MHz--a-nfm.json").read_text())["hit"]
assert "KSQ330" in hit["morse_complete"]
def test_check_morse_leaves_a_conversation_alone(tmp_path, monkeypatch):
"""It has to be able to say no, or it is just --all with a long wait."""
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="net control this is W1AW standing by")
assert _waterfall_command(str(tmp_path), "--check-morse") == 0
assert drawings(tmp_path) == []