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:
parent
dee262e130
commit
a8a8548369
9 changed files with 190 additions and 29 deletions
14
README.md
14
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,11 +1414,10 @@ 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)
|
||||
except Exception as exc:
|
||||
self._error(exc)
|
||||
try:
|
||||
self._waterfall_for_job(job, result.text)
|
||||
except Exception as exc:
|
||||
self._error(exc)
|
||||
if self.callsigns is None:
|
||||
return
|
||||
try:
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue