Read the Morse a station sends over its own carrier
A base station identifying itself in CW does not key its carrier. The carrier stays up and the ident is an audio tone keyed inside it, which a detector looking for a keyed carrier sees as a carrier that never stops. On the land-mobile bands that is nearly all the Morse there is, and none of it was being read: an ident of KSQ330 sat in the middle of a 27-second capture on 154.369 MHz, cleanly keyed at 22 WPM, and the capture was filed as voice with no Morse in it at all. Three things were in the way, and each was found by measuring rather than by reading. The whole-recording decode ran only for captures recorded in cw mode. An ident over FM is recorded in nfm, so it was never looked for. It now runs for every capture. The tone was sought in the first four seconds of the audio and nowhere else, so a tone that had not started yet could not be found -- on the capture above it locked onto the harmonic of something else. It is now averaged over the whole clip. And the steady tone either side of the ident was read as a character the window had sliced, which dropped the first and last letter and, through complete_text, the whole callsign: one word with no gap in it to survive the drop. A mark far longer than any dash is not a truncated element, it is the transmission the ident was sent over. Even fixed, the decoder measures its tone and its key-down threshold over the whole of whatever it is handed, so a half-minute recording with five seconds of keying in the middle measures both from the other twenty-five. So the audio is searched a few seconds at a time, plus the whole capture -- that one matters for a beacon keying throughout, where the longest window is the best one and leaving it out lost an ident the decoder had always read. Nothing was loosened. Every window is judged by is_morse exactly as a whole capture is. Across 677 real captures the search claimed Morse in four: KSQ330 and WNRS309, both FCC land-mobile callsigns and neither seen before; a 20 WPM burst on 70 cm reading as E7HNN, plausible and unverified; and noise on 445.5 MHz reading as "T T T E E E E E E E E". That last one is the new rule -- E and T are the one-element characters, so a decode of nothing but those can hardly be wrong, because there is nothing in it to get wrong. With it the count is three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
8a789e57e1
commit
7e8b9b268d
8 changed files with 341 additions and 40 deletions
|
|
@ -2,7 +2,7 @@ import numpy as np
|
|||
import pytest
|
||||
|
||||
from morse_gen import morse_audio
|
||||
from bandsaunter.morse import decode_morse, encode_morse
|
||||
from bandsaunter.morse import decode_morse, encode_morse, find_morse
|
||||
|
||||
FS = 16000
|
||||
|
||||
|
|
@ -197,3 +197,91 @@ def test_a_tone_that_never_keys_is_not_morse():
|
|||
def test_speech_is_not_morse(seed):
|
||||
from speech import synth_speech
|
||||
assert not decode_morse(synth_speech(2.5, FS, seed=seed), FS).is_morse
|
||||
|
||||
|
||||
# -- an ident sent over a carrier --------------------------------------------
|
||||
#
|
||||
# The commonest way Morse arrives on the land-mobile bands, and the way that
|
||||
# was being missed entirely: the carrier stays up and the ident is an audio
|
||||
# tone keyed inside it, so a CW detector looking for a keyed carrier sees a
|
||||
# carrier that never stops.
|
||||
|
||||
def _ident_over_a_tone(text="KSQ330", wpm=22.0, rate=16000, tone=795.0,
|
||||
before=11.0, after=11.0, seed=3):
|
||||
"""A keyed ident with a steady tone either side of it, as heard over FM."""
|
||||
rng = np.random.default_rng(seed)
|
||||
keyed = morse_audio(text, wpm, rate, snr_db=30.0, tone=tone)
|
||||
steady = np.sin(2 * np.pi * tone * np.arange(int(before * rate)) / rate)
|
||||
tail = np.sin(2 * np.pi * tone
|
||||
* np.arange(int(after * rate)) / rate) * 0.9
|
||||
audio = np.concatenate([steady * 0.9, keyed, tail]).astype(np.float64)
|
||||
return audio + rng.standard_normal(audio.size) * 0.02
|
||||
|
||||
|
||||
def test_an_ident_is_found_inside_a_capture_that_is_mostly_something_else():
|
||||
"""decode_morse reads a clip that is Morse; find_morse looks for one
|
||||
inside a clip that is not."""
|
||||
audio = _ident_over_a_tone()
|
||||
assert decode_morse(audio, 16000).complete_text != "KSQ330", \
|
||||
"if the whole clip decodes there is nothing for find_morse to fix"
|
||||
found = find_morse(audio, 16000)
|
||||
assert found is not None and found.is_morse
|
||||
assert found.complete_text == "KSQ330"
|
||||
assert 19 <= found.wpm <= 25
|
||||
|
||||
|
||||
def test_a_steady_tone_at_the_edge_is_not_a_sliced_character():
|
||||
"""A mark far longer than any dash is not a truncated element -- it is
|
||||
the transmission the ident was sent over. Read as a cut, it took the
|
||||
first and last letter of every ident, and with them the whole callsign:
|
||||
one word with no gap in it to survive the drop."""
|
||||
found = find_morse(_ident_over_a_tone("W1AW"), 16000)
|
||||
assert found is not None
|
||||
assert found.text == "W1AW"
|
||||
assert found.complete_text == "W1AW", "the ident was thrown away as cut"
|
||||
|
||||
|
||||
def test_a_capture_with_no_morse_in_it_yields_none():
|
||||
rng = np.random.default_rng(9)
|
||||
rate = 16000
|
||||
noise = rng.standard_normal(20 * rate) * 0.2
|
||||
speechy = noise + 0.4 * np.sin(
|
||||
2 * np.pi * 300 * np.arange(20 * rate) / rate
|
||||
* (1 + 0.3 * np.sin(2 * np.pi * 3 * np.arange(20 * rate) / rate)))
|
||||
for clip in (noise, speechy, np.zeros(20 * rate)):
|
||||
assert find_morse(clip, rate) is None
|
||||
|
||||
|
||||
def test_the_search_is_no_looser_than_the_decoder():
|
||||
"""Every window is judged by is_morse exactly as a whole capture would
|
||||
be; the search widens where the decoder looks, not what it accepts."""
|
||||
rng = np.random.default_rng(11)
|
||||
clip = rng.standard_normal(30 * 16000) * 0.3
|
||||
assert find_morse(clip, 16000) is None
|
||||
|
||||
|
||||
def test_a_reading_of_nothing_but_dots_and_dashes_is_refused():
|
||||
"""E and T are the one-element characters, so a decode made only of them
|
||||
can hardly be wrong -- there is nothing in it to get wrong. A capture of
|
||||
noise on 445.5 MHz came back as "T T T E E E E E E E E" with the element
|
||||
mix and the timing fit both inside their bands."""
|
||||
from bandsaunter.morse import MorseResult
|
||||
flat = MorseResult(text="T T T E E E E", wpm=20.0, confidence=0.9,
|
||||
n_elements=7, n_characters=7, timing_fit=1.0,
|
||||
snr_db=40.0, undecoded=0)
|
||||
assert not flat.is_morse
|
||||
real = MorseResult(text="W1AW", wpm=20.0, confidence=0.9,
|
||||
n_elements=11, n_characters=4, timing_fit=1.0,
|
||||
snr_db=40.0, undecoded=0)
|
||||
assert real.is_morse
|
||||
|
||||
|
||||
def test_the_whole_capture_is_one_of_the_windows():
|
||||
"""When the capture *is* Morse -- a beacon keying through all of it -- the
|
||||
longest window is the best one, because a word is only certain when a gap
|
||||
bounds it at both ends and a short window may not contain one."""
|
||||
audio = morse_audio("CQ CQ DE W1AW W1AW K", 18, FS, 20)
|
||||
whole = decode_morse(audio, FS)
|
||||
found = find_morse(audio, FS)
|
||||
assert found is not None
|
||||
assert len(found.complete_text) >= len(whole.complete_text)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue