From a8a85483694462e7291c263292539c2fbe4d5ccc Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Thu, 3 Sep 2026 21:43:51 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg --- README.md | 14 +++++++ bandsaunter/__init__.py | 2 +- bandsaunter/cli.py | 33 +++++++++++++-- bandsaunter/scanner.py | 31 ++++++-------- bandsaunter/waterfall.py | 27 ++++++++++-- packaging/bandsaunter.1 | 12 +++++- packaging/make-man.py | 10 +++++ packaging/saunterbrowse.1 | 2 +- tests/test_waterfall.py | 88 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 190 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 50ba324..fecc053 100644 --- a/README.md +++ b/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. diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 67c3004..9c2b61d 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -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}" diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index 34245d6..5ba1c2a 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -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 diff --git a/bandsaunter/scanner.py b/bandsaunter/scanner.py index 217fc91..6f95625 100755 --- a/bandsaunter/scanner.py +++ b/bandsaunter/scanner.py @@ -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) diff --git a/bandsaunter/waterfall.py b/bandsaunter/waterfall.py index 2f4d274..26bf3b0 100644 --- a/bandsaunter/waterfall.py +++ b/bandsaunter/waterfall.py @@ -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 # --------------------------------------------------------------------------- diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 2eeb6ba..f1efe6c 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -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: diff --git a/packaging/make-man.py b/packaging/make-man.py index 010ca12..c76fb53 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -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: diff --git a/packaging/saunterbrowse.1 b/packaging/saunterbrowse.1 index 70c62c0..005b44c 100644 --- a/packaging/saunterbrowse.1 +++ b/packaging/saunterbrowse.1 @@ -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 diff --git a/tests/test_waterfall.py b/tests/test_waterfall.py index d1dd2b1..d06d6ea 100644 --- a/tests/test_waterfall.py +++ b/tests/test_waterfall.py @@ -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) == []