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

@ -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