Hear the short replies, and read the other kind of callsign

Two things, both found by measuring rather than by reading the code.

The voice-activity filter inside the recogniser is off. It was costing
words: across a night of land-mobile captures it dropped 5-15% of what
the same model finds without it -- 491 against 507, 339 against 384,
263 against 310 -- because a single-word over between two
transmissions looks to a VAD exactly like the noise it exists to
remove, and on a scanner those short replies are the ones worth
having.

Turning it off has a cost, and the cost is that Whisper hands back
"You" for five seconds of hiss as confidently as it hands back a
sentence. So the whole capture is now asked once whether anything in
it rises above its own noise. Digital silence measures 0.0 dB of
contrast and hiss at any level 0.7, while the quietest real capture of
that night measures 8.9 and most measure 10-27; the bar sits at 3, an
order of magnitude clear of both. It can veto a capture but never trim
one, which is the whole difference between it and the filter it
replaces.

The second thing: callsigns like WQVF960 were being missed entirely.
The shape being matched was the amateur one -- prefix, district digit,
suffix -- and everything else the FCC licenses is written the other
way round, the letters first and then the digits. On the GMRS and
business channels that is most of what is said: nine callsigns across
five transcripts of one evening went by unrecognised, and now do not.

The shape is written as the three allocations that exist rather than
as "letters then digits", which claims KN95, WD40 and KC135. Its
letters are checked against the word list even when they arrive as a
single token, which the amateur shape does not need -- no English word
has a digit in the middle of it, but "west 120" and "word 100" fit
this one exactly.

Lookups now fall back to hamdb.org when callook has nothing. Not a
spare copy: callook holds United States amateur licences only, so
DL1ABC and VE3ABC are INVALID there and resolve perfectly well from
the other. And a GMRS callsign is not looked up at all -- every
database reachable without an account is an amateur register, so
reporting WQVF960 as "unlisted" would blame the callsign for the
absence of a source.

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-02 00:30:02 -07:00
parent 0f378c4d6c
commit 8a789e57e1
14 changed files with 679 additions and 74 deletions

View file

@ -40,6 +40,20 @@ def _speech(seconds=3.0, rate=16000):
return synth_speech(seconds, rate, 120, 0)
def _sounds(seconds=1.0, rate=16000):
"""Something -- anything -- rather than digital silence.
A clip with nothing in it is now refused before an engine ever sees it,
so a test about plumbing has to hand over audio that has something in it,
or it is testing the refusal instead.
"""
n = int(seconds * rate)
t = np.arange(n) / rate
tone = np.sin(2 * np.pi * 440 * t).astype(np.float32) * 0.3
tone[: n // 2] = 0.0 # a quiet part to stand above
return tone
# ---------------------------------------------------------------------------
# Engines
# ---------------------------------------------------------------------------
@ -63,19 +77,19 @@ def test_a_failing_engine_is_reported_not_raised(monkeypatch):
raise RuntimeError("model file is corrupt")
monkeypatch.setitem(tr._DISPATCH, "fake", boom)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
result = tr.transcribe(np.zeros(16000, np.float32), 16000, engine="fake")
result = tr.transcribe(_sounds(), 16000, engine="fake")
assert result is not None and not result
assert "corrupt" in result.note
def test_audio_is_resampled_to_what_the_engines_expect(fake_engine):
for rate in (8000, 16000, 32000, 48000):
tr.transcribe(np.zeros(int(rate * 2), np.float32), rate, engine="fake")
tr.transcribe(_sounds(2.0, rate), rate, engine="fake")
assert [s["samples"] for s in fake_engine] == [32000] * 4
def test_the_model_and_language_reach_the_engine(fake_engine):
tr.transcribe(np.zeros(16000, np.float32), 16000, engine="fake",
tr.transcribe(_sounds(), 16000, engine="fake",
model="small.en", language="fr")
assert fake_engine[-1]["model"] == "small.en"
assert fake_engine[-1]["language"] == "fr"
@ -124,7 +138,7 @@ def test_nothing_recognised_writes_no_file_at_all(tmp_path, monkeypatch):
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "quiet_transcription.txt"
worker.submit(np.zeros(16000, np.float32), 16000, out, datetime.now(), 1e6)
worker.submit(_sounds(), 16000, out, datetime.now(), 1e6)
_drain(worker)
assert not out.exists()
assert not list(tmp_path.iterdir())
@ -139,7 +153,7 @@ def test_whitespace_only_speech_writes_no_file(tmp_path, monkeypatch):
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "blank_transcription.txt"
worker.submit(np.zeros(16000, np.float32), 16000, out, datetime.now(), 1e6)
worker.submit(_sounds(), 16000, out, datetime.now(), 1e6)
_drain(worker)
assert not out.exists() and worker.empty == 1
@ -155,7 +169,7 @@ def test_an_empty_result_adds_no_line_when_combining(tmp_path, monkeypatch):
worker.start()
out = tmp_path / "0146.520000MHz_transcription.txt"
for minute in (0, 5, 9):
worker.submit(np.zeros(16000, np.float32), 16000, out,
worker.submit(_sounds(), 16000, out,
datetime(2026, 8, 21, 12, minute, 0), 1e6, append=True)
_drain(worker)
lines = out.read_text().strip().split("\n")
@ -491,3 +505,28 @@ def test_a_later_run_never_truncates_an_earlier_transcript(tmp_path,
after = combined.read_text()
assert after.startswith(before), "the earlier transcript was overwritten"
assert len(after) > len(before), "the later over was not added"
def test_a_clip_with_nothing_in_it_is_refused_before_the_engine(fake_engine):
"""A recogniser with no voice-activity filter hands back "You" for five
seconds of hiss as confidently as it hands back a sentence, so a capture
with nothing above its own noise is not offered to one."""
rng = np.random.default_rng(0)
for clip in (np.zeros(16000 * 5, np.float32),
rng.standard_normal(16000 * 5).astype(np.float32) * 0.01):
result = tr.transcribe(clip, 16000, engine="fake")
assert result is not None and not result.text
assert "nothing above the noise" in result.note
assert fake_engine == [], "the engine was asked about silence"
def test_the_refusal_is_a_whole_clip_veto_not_a_voice_activity_filter(fake_engine):
"""One short word in the middle of a long quiet capture is exactly what a
VAD throws away and exactly what a scanner is for, so it survives."""
clip = np.zeros(16000 * 10, np.float32)
t = np.arange(16000) / 16000
clip[16000 * 4:16000 * 5] = (np.sin(2 * np.pi * 300 * t)
* np.hanning(16000) * 0.4).astype(np.float32)
result = tr.transcribe(clip, 16000, engine="fake")
assert result is not None and result.text
assert len(fake_engine) == 1