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

@ -1198,6 +1198,23 @@ downloaded once.
since on a short noisy clip automatic detection often guesses wrong and
returns nonsense in another language.
**No voice-activity filter runs inside the recogniser.** It used to, and it
cost words: measured across a night of land-mobile captures it dropped 515%
of what the same model finds without it — 491 words 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 is there to remove. On a scanner
those short replies are the ones worth having.
What replaces it is a single question asked of the whole capture: does
anything in it rise above its own noise? Nothing does in digital silence
(0.0 dB of contrast) or in hiss at any level (0.7 dB), while the quietest
real capture of that night gives 8.9 dB and most give 1027. Below 3 dB the
clip is refused before a recogniser sees it — which matters, because with no
filter at all Whisper hands back *"You"* for five seconds of hiss as
confidently as it hands back a sentence. The check can only veto a capture
entirely, never trim one, so the short over in the middle of a quiet channel
survives.
Existing recordings can be transcribed after the fact:
```bash
@ -1418,11 +1435,26 @@ are one callsign, and all of them read back correctly:
| `wiskey one alfa whisky` | spelled the way it sounded |
| `whiskey one alpha, uh, whiskey` | said with a hesitation in the middle |
| `W1AW-4`, `W1AW/B`, `DL/W1AW` | a suffix, which is not part of the callsign |
| `WRUC 242`, `7-3-W-F-K-L-2-0-4` | a GMRS or business callsign, said the same ways |
A word is only taken apart when it is phonetic *all the way through*, which is
what keeps this away from English: "kilometre" begins with a phonetic word and
"victorious" contains one, and neither can be consumed to the end.
**Two shapes, not one.** An amateur callsign is a prefix, a district digit and
a suffix — `W1AW`, `KU0W`, `2E0ABC`. Everything else the FCC licenses is
called the other way round, letters first and then the digits: `WQVF960` is a
GMRS licence, `WXG204` an old Part 90 one. On 462 and 464 MHz those are most
of what is said, and reading only the amateur shape found none of them — nine
callsigns across five transcripts of one evening's GMRS traffic went by
unrecognised.
The shape is written as the three allocations that exist rather than as
"letters then digits", which would claim `KN95`, `WD40` and `KC135`. The
length matters for a second reason: `"7-3-W-F-K-L-2-0-4. 0-4-W-R-C-U"` is a
real transcript of someone spelling a callsign out, and a looser pattern read
the `0` that began the next one as part of this one.
Callsigns arrive from three directions and all three end up in the same list
and on the same map: **spoken and transcribed, sent in Morse, or carried in
the header of an APRS packet.** Neither of the last two involves a speech
@ -1446,17 +1478,29 @@ saunterbrowse --callsigns # everyone who identified themselves, and where
saunterbrowse --no-lookup # find them, but contact nothing
```
Lookups use the FCC's own licence data via [callook.info](https://callook.info),
which needs no account or key. The callsign is the only thing sent; results are
cached in `~/.cache/bandsaunter/callsigns.json`, so the same net is looked up
once however many nights you record it, and a lookup never delays the display —
the entry reads `looking up…` and fills itself in.
Lookups use the FCC's own licence data via [callook.info](https://callook.info)
and fall back to [hamdb.org](https://hamdb.org), both of which need an account
or a key from nobody. The callsign is the only thing sent; results are cached
in `~/.cache/bandsaunter/callsigns.json`, so the same net is looked up once
however many nights you record it, and a lookup never delays the display — the
entry reads `looking up…` and fills itself in.
The second source is not a spare copy of the first. callook holds United
States amateur licences and nothing else, so `DL1ABC` and `VE3ABC` come back
`INVALID` from it and resolve perfectly well from the other; and when one
service is down or rate-limiting, the other usually is not. It is asked only
when the first has nothing, and which one answered is recorded.
**A GMRS or business callsign is not looked up at all**, and says so rather
than saying "unlisted". Every database reachable without an account is an
amateur register, and `WQVF960` was never in one — reporting it as missing
would blame the callsign for the absence of a source. It is still recognised,
still listed, and still described as what it is.
`--no-lookup` contacts nothing. Callsigns are still found and still described
from their own structure: the prefix is allocated by the ITU and the digit is
the US licensing district, so `VE3ABC` is Canada and `N7XYZ` is US district 7
with no database at all. Outside the US that structural description is all
there is — callook.info holds US licences only.
with no database at all.
US amateur licence records are public by law and include the licensee's
address; that is what is shown.

View file

@ -8,7 +8,7 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so
# 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-08-30"
VERSION_DATE = "2026-09-01"
VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -7,11 +7,18 @@ who spells it out in the phonetic alphabet gets "kilo uniform zero whiskey"
written down verbatim. So finding one is not a matter of a single regular
expression over the text as written.
What comes out is a shape -- one or two letters, a digit, one to three
letters -- which is checked against the licence database. A callsign that
resolves to a licence is real; one that does not is reported as unverified
rather than silently dropped, because a mangled callsign is still a signal
that someone identified themselves.
What comes out is one of two shapes. An amateur callsign is a prefix, a
district digit and a suffix -- ``W1AW``, ``KU0W``, ``2E0ABC``. Everything
else the FCC licenses is written the other way round, the letters first and
then the digits: ``WQVF960`` is a GMRS licence and ``WXG204`` an older Part 90
one, and on the GMRS and business channels those are most of what is said.
Either is then checked against the licence database. A callsign that resolves
to a licence is real; one that does not is reported as unverified rather than
silently dropped, because a mangled callsign is still a signal that someone
identified themselves. A GMRS or business callsign is not looked up at all:
every database reachable without an account is an amateur register, and
saying "unlisted" would blame the callsign for the absence of a source.
"""
from __future__ import annotations
@ -27,18 +34,26 @@ from dataclasses import dataclass
from pathlib import Path
__all__ = ["Callsign", "CallsignBook", "find_callsigns", "describe_prefix",
"grid_to_latlon", "PHONETIC", "LOOKUP_URL"]
"grid_to_latlon", "PHONETIC", "LOOKUP_URL", "BACKUP_URL",
"is_service_call", "SHAPE", "SERVICE_SHAPE"]
# The FCC's own licence data, served as JSON without an account or a key.
# US callsigns only; everything else resolves to what the prefix alone says.
LOOKUP_URL = "https://callook.info/{call}/json"
# A second database, asked only when the first has nothing. Two reasons for
# it rather than one: callook is United States only and returns INVALID for a
# perfectly good German or Canadian callsign, and a single source that is
# down, blocked or rate-limiting takes every lookup with it. Checked against
# both: DL1ABC and VE3ABC are INVALID at callook and resolve here.
BACKUP_URL = "https://api.hamdb.org/{call}/json/bandsaunter"
# Raised whenever a lookup starts recording something it did not before.
# Entries written by an older version are ignored and asked about again,
# rather than being kept forever with a field permanently empty -- which is
# how a warm cache would otherwise pin every station to its grid square for
# good after coordinates were added.
CACHE_VERSION = 3
CACHE_VERSION = 4
# The NATO alphabet, with the spellings a speech recogniser actually writes
# down beside the ones the ITU prints. A recogniser has never heard of the
@ -89,6 +104,42 @@ FILLERS = frozenset({"UH", "UM", "UHM", "ER", "ERM", "AH", "HMM", "MM",
# 2E0 callsign would otherwise be missed.
SHAPE = re.compile(r"^(?:[A-Z]{1,2}|[0-9][A-Z])[0-9][A-Z]{1,3}$")
# The other kind of callsign, and on these bands the commoner one. Everything
# the FCC licenses that is not amateur -- GMRS, business, public safety,
# marine -- is called the other way round: the letters first and then the
# digits. WQVF960 is a GMRS licence and WXG204 an old Part 90 one, and both
# were being missed entirely because the amateur shape reads left to right
# expecting a district digit in the middle.
#
# Written as the three allocations rather than as one loose pattern, because
# "letters then digits" on its own claims KC135, KN95 and WD40 -- and because
# the length is what stops a spelled-out callsign running into the next
# sentence. "7-3-W-F-K-L-2-0-4. 0-4-W-R-C-U" is a real transcript, and a
# pattern that allowed four letters and four digits read it as WFKL2040.
SERVICE_SHAPE = re.compile(
r"^[KWN](?:"
r"[A-Z]{3}[0-9]{3}" # WQVF960 -- four letters, three digits
r"|[A-Z]{2}[0-9]{3,4}" # WXG204, WRF5506 -- three letters
r"|[A-Z][0-9]{4,5}" # KA12345 -- two letters, four or five digits
r")$")
# What such a callsign is, in the words an operator would use. The FCC issues
# one series across GMRS, business and public safety and the callsign alone
# does not say which, so this says the set rather than guessing the member.
SERVICE_LICENCE = "GMRS or business licence"
def is_service_call(call: str) -> bool:
"""True for a GMRS, business or public-safety callsign, not a ham one.
Worth asking about separately: the amateur databases are the only ones
reachable without an account, none of them holds these, and reporting one
as "unlisted" would say the wrong thing -- it was never listed there to
begin with.
"""
return bool(SERVICE_SHAPE.match(str(call).upper()))
# Things that fit the shape and are never callsigns. Every entry here has
# been checked against SHAPE: a list of words that could not match anyway
# would only suggest the filter was doing more than it is.
@ -112,6 +163,10 @@ may men met mom nor not now odd off oil old one out own pay per pop put ran
red rid row run sat saw say sea see set she sir sit six son sun tax tea ten
the tie tip toe ton too top try two use van war was way wet who why win yes
yet you
kart keel keen kelp keys kick kids kiln kilt king kirk kiss kite knee knit
knob knot kong nail nap navy neat net new nite nod node nope norm nova numb
nun nut nuts wage wake warp wart wasp wax web wed weed weld whip wig wing
wins wise wit wok wolf womb wool worm wow wrap
able also away back bad been beer bell best bill blue boat body book both
call came care case city club cold come cost dark data date days dead deal
does done door down draw drop each easy else even ever face fact fall feel
@ -216,6 +271,11 @@ def describe_prefix(call: str) -> tuple[str, str]:
itself wherever it is heard.
"""
call = call.upper()
# A GMRS or business callsign has no district digit for the prefix walk
# below to anchor on -- the letters run straight into the numbers -- and
# K, W and N are United States allocations wherever they turn up.
if is_service_call(call):
return "United States", ""
country = ""
for prefixes, name in _PREFIXES:
for p in sorted(prefixes, key=len, reverse=True):
@ -325,9 +385,24 @@ def _tokens(text: str) -> list[tuple[str, bool]]:
return out
# A callsign is at most six characters, so a run of more than six tokens can
# never be one however it was broken up.
MAX_SPAN = 6
# A callsign spelled out arrives one character per token, so the span has to
# reach the length of the longest one there is. Amateur callsigns stop at
# six; a GMRS or business licence runs to seven -- "whiskey quebec victor
# foxtrot nine six zero" is seven tokens and one callsign.
MAX_SPAN = 7
def _service_letters_ok(joined: str) -> bool:
"""False when a GMRS-shaped run is really a word and a number.
The amateur shape can trust a single token: no English word has a digit
in the middle of it, so W1BOY is safe to take as written. This one
cannot. Its letters are a contiguous run of two to four -- exactly the
length of a short word -- so "word 100", "west 120" and "net 2024" fit it
perfectly, and a licence prefix never spells anything.
"""
letters = re.match(r"^[A-Z]+", joined)
return bool(letters) and letters.group(0) not in COMMON_WORDS
def find_callsigns(text: str, max_found: int = 12,
@ -389,7 +464,12 @@ def find_callsigns(text: str, max_found: int = 12,
for word, phonetic in run):
continue # "or 3. Can you" is not OR3CAN
joined = "".join(word for word, _ in run)
if not SHAPE.match(joined) or joined in NOT_CALLSIGNS:
if joined in NOT_CALLSIGNS:
continue
if SERVICE_SHAPE.match(joined):
if not _service_letters_ok(joined):
continue
elif not SHAPE.match(joined):
continue
if joined not in seen:
seen.add(joined)
@ -427,7 +507,9 @@ class Callsign:
latitude: float = 0.0
longitude: float = 0.0
from_grid: bool = False # position is the grid square, not the address
status: str = "pending" # pending / found / unlisted / offline
# pending / found / unlisted / offline / service
status: str = "pending"
source: str = "" # which database answered
fetched_at: float = 0.0
version: int = 1 # of the record, not of the licence
@ -473,6 +555,12 @@ class Callsign:
# is real information, rather than nothing at all.
bits = [b for b in (self.country, self.district) if b]
tail = ", ".join(bits) if bits else "no matching licence"
if self.status == "service":
# Nothing was looked up and nothing failed. The databases that
# can be reached without an account hold amateur licences only,
# and this is not one; saying "unlisted" would blame the callsign
# for the absence of a source.
return f"{SERVICE_LICENCE} — not in the amateur register"
if self.status == "offline":
return f"not looked up ({tail})" if bits else "not looked up"
return f"unlisted ({tail})" if bits else "no matching licence"
@ -524,6 +612,20 @@ _KEEP_CAPS = frozenset({"II", "III", "IV", "V", "VI", "VII", "VIII", "JR",
"E", "W", "APO", "FPO"})
# The letter each United States licence class is recorded as, where one
# database spells it out and the other does not.
_US_CLASSES = {"N": "Novice", "T": "Technician", "G": "General",
"A": "Advanced", "E": "Amateur Extra"}
def _same_place(a: str, b: str) -> bool:
"""Two address lines that say the same thing, spacing and case aside."""
def flat(x: str) -> str:
return re.sub(r"[^a-z0-9]", "", x.lower())
a, b = flat(a), flat(b)
return bool(a) and a == b
def person_case(name: str) -> str:
"""Licence records are all capitals; make them readable without lying.
@ -620,10 +722,12 @@ class CallsignBook:
def __init__(self, online: bool = True, cache: Path | None = None,
timeout: float = 5.0, url: str = LOOKUP_URL,
max_age: float = 30 * 86_400):
max_age: float = 30 * 86_400,
backup_url: str = BACKUP_URL):
self.online = online
self.timeout = timeout
self.url = url
self.backup_url = backup_url
self.max_age = max_age
self.cache_path = Path(cache) if cache is not None else _cache_path()
self._lock = threading.Lock()
@ -644,7 +748,7 @@ class CallsignBook:
entry = Callsign(**body)
except TypeError:
continue # written by a version with other fields
if entry.status in ("found", "unlisted") and \
if entry.status in ("found", "unlisted", "service") and \
entry.version >= CACHE_VERSION and \
now - entry.fetched_at < self.max_age:
self._entries[call] = entry
@ -655,7 +759,7 @@ class CallsignBook:
if not self._dirty:
return
body = {c: e.__dict__ for c, e in self._entries.items()
if e.status in ("found", "unlisted")}
if e.status in ("found", "unlisted", "service")}
self._dirty = False
try:
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
@ -676,8 +780,18 @@ class CallsignBook:
country, district = describe_prefix(call)
entry = Callsign(call=call, country=country, district=district,
status="pending" if self.online else "offline")
if is_service_call(call):
# Answered here rather than over the network. Both reachable
# databases are amateur registers and return INVALID for
# every one of these -- checked on WQVF960, WSKL204, WRF5506
# and KA12345 -- so a request could only ever spend a second
# to learn nothing, once per callsign per month.
entry.status = "service"
entry.version = CACHE_VERSION
entry.fetched_at = time.time()
self._dirty = True
self._entries[call] = entry
if self.online:
if self.online and entry.status == "pending":
thread = threading.Thread(target=self._fetch, args=(entry,),
daemon=True)
self._threads.append(thread)
@ -701,22 +815,43 @@ class CallsignBook:
self._threads = [t for t in self._threads if t.is_alive()]
def _fetch(self, entry: Callsign) -> None:
"""Ask the first database, then the second, then give up.
The second is not a duplicate of the first. callook holds United
States amateur licences and nothing else, so a German or Canadian
callsign comes back INVALID from it and resolves perfectly well from
hamdb; and when one source is down or rate-limiting, the other is
usually not. Either way it is asked once and the answer is cached.
"""
reached = False
try:
body = self._request(entry.call)
body = self._request(self.url, entry.call)
reached = True
self._apply(entry, body)
except Exception:
# Offline, blocked, rate-limited, or the service moved. The
pass
if entry.status != "found" and self.backup_url:
try:
body = self._request(self.backup_url, entry.call)
reached = True
self._apply_backup(entry, body)
except Exception:
pass
if not reached:
# Offline, blocked, rate-limited, or both services moved. The
# prefix still says something, and a browser that cannot look a
# callsign up must still show the transcript.
with self._lock:
entry.status = "offline"
return
self._apply(entry, body)
with self._lock:
self._dirty = True
def _request(self, call: str) -> dict:
def _request(self, url: str, call: str) -> dict:
req = urllib.request.Request(
self.url.format(call=urllib.parse.quote(call)),
url.format(call=urllib.parse.quote(call)),
headers={"User-Agent": "bandsaunter"})
with urllib.request.urlopen(req, timeout=self.timeout) as response:
return json.loads(response.read(64_000).decode("utf8", "replace"))
@ -758,6 +893,70 @@ class CallsignBook:
entry.trustee = str(trustee.get("callsign") or "")
if not entry.country:
entry.country = "United States"
entry.source = "callook.info"
entry.status = "found"
@staticmethod
def _apply_backup(entry: Callsign, body: dict) -> None:
"""Read the second database's answer, which is shaped differently.
One flat record rather than callook's nested one, and it writes the
string "NOT_FOUND" into every field rather than saying so once -- so
that string is what has to be tested, in the callsign field, before
any of the rest is believed.
"""
entry.fetched_at = time.time()
entry.version = CACHE_VERSION
record = {}
if isinstance(body, dict):
record = (body.get("hamdb") or {}).get("callsign") or {}
got = str(record.get("call") or "")
if not got or got == "NOT_FOUND":
entry.status = "unlisted"
return
def field(name: str) -> str:
value = str(record.get(name) or "").strip()
return "" if value == "NOT_FOUND" else value
# Given names, middle initial and surname in three fields, and the
# one called "name" holds the surname alone -- so reading it by
# itself files Charles M Stachovic under "Stachovic". Where a
# register has no such split, the whole name is in that field and the
# other two are empty, which joins to the same string.
entry.name = person_case(" ".join(
part for part in (field("fname"), field("mi"), field("name"))
if part))
entry.street = person_case(field("addr1"))
town, state = field("addr2"), field("state")
# Outside North America the second line is often the street over
# again rather than the town, and printing it would put the address
# in twice.
if _same_place(town, entry.street):
town = ""
entry.location = ", ".join(p for p in (person_case(town), state) if p)
entry.postcode = field("zip")
entry.grid = field("grid")
entry.latitude = _as_float(field("lat"))
entry.longitude = _as_float(field("lon"))
if entry.latitude == 0.0 and entry.longitude == 0.0:
fallback = grid_to_latlon(entry.grid)
if fallback is not None:
entry.latitude, entry.longitude = fallback
entry.from_grid = True
entry.expires = field("expires")
country = field("country")
if country:
entry.country = country
# A single letter, where callook spells the class out. Expanded only
# for United States licences: "A" is Advanced there and a national
# class A elsewhere, and guessing across borders would be wrong more
# often than it was right.
oper = field("class")
if entry.country == "United States":
oper = _US_CLASSES.get(oper.upper(), oper)
entry.oper_class = oper
entry.source = "hamdb.org"
entry.status = "found"

View file

@ -8,6 +8,7 @@ it runs on its own thread, so a scan never waits for it.
from __future__ import annotations
import math
import os
import queue
import shutil
@ -153,9 +154,16 @@ def _faster_whisper(audio, rate, model, language):
if engine is None:
engine = WhisperModel(key[1], device="cpu", compute_type="int8")
_MODELS[key] = engine
# No voice-activity filter. It was on, and it cost words: measured
# across the land-mobile captures in one night's recordings it dropped
# 5-15% of what the same model finds without it -- 491 words 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 is
# there to remove. Radio audio is what this is pointed at, and on radio
# audio the short replies are the ones worth having.
segments, info = engine.transcribe(
audio, language=language or None,
vad_filter=True, beam_size=5)
vad_filter=False, beam_size=5)
out, pieces = [], []
for seg in segments:
pieces.append((float(seg.start), float(seg.end), seg.text.strip()))
@ -275,6 +283,41 @@ def _as_16k(audio: np.ndarray, rate: float) -> np.ndarray:
return resample_poly(audio, 16000 // g, int(rate) // g).astype(np.float32)
# How far the loud part of a clip has to stand above its own quiet part
# before there is anything in it worth recognising. Measured across a
# night's recordings: digital silence gives 0.0 dB and hiss at any level
# 0.7, while the quietest whole capture gives 8.9 and most give 10-27. The
# bar sits an order of magnitude clear of both.
MIN_CONTRAST_DB = 3.0
def _has_signal(audio: np.ndarray, rate: float) -> bool:
"""Is there anything in this clip at all, anywhere in it?
Asked once of the whole capture, and able only to veto it entirely. That
is the difference between this and the voice-activity filter that used to
run inside the recogniser: a VAD judges every frame and throws away the
single-word overs between transmissions, which on a scanner are the
replies worth having. This throws away only a capture in which nothing
rises above the noise at any point -- and something has to, because with
no filter at all a recogniser hands back "You" or "With" for five seconds
of hiss, as confidently as it hands back a sentence.
"""
frame = max(1, int(rate * 0.03))
if audio.size < frame * 8:
return True # too short to measure; let it through
n = audio.size // frame
block = np.asarray(audio[:n * frame], dtype=np.float64).reshape(n, frame)
energy = np.sqrt((block * block).mean(axis=1)) + 1e-12
# The tenth percentile rather than the quarter: speech that runs without
# a pause anywhere -- a broadcast station, a net control talking through
# a whole capture -- still drops between phonemes, but not for a quarter
# of its length.
loud = float(np.percentile(energy, 90))
floor = float(np.percentile(energy, 10))
return 20.0 * math.log10(loud / max(floor, 1e-12)) >= MIN_CONTRAST_DB
def _write_wav(path: Path, audio: np.ndarray, rate: int) -> None:
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
@ -300,6 +343,12 @@ def transcribe(audio: np.ndarray, rate: float, engine: str = "auto",
return Transcript(note=f"{chosen} is not installed")
original_seconds = np.asarray(audio).size / float(rate)
audio = _as_16k(audio, rate)
if not _has_signal(audio, 16000):
# Said here rather than left to the engine. Nothing it writes about
# a clip like this can be believed, and not running it is also the
# fastest way to transcribe silence.
return Transcript(engine=chosen, duration=original_seconds,
note="nothing above the noise")
try:
# Engines announce model downloads and load progress on stderr, which
# would be drawn straight over the live display.

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-08-30" "bandsaunter 2026-08-30_01" "User Commands"
.TH BANDSAUNTER 1 "2026-09-02" "bandsaunter 2026-09-01_01" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -969,7 +969,13 @@ unattended receiver keeps adding to that file night after night rather than
starting it over.
.PP
A capture with nothing recognisable in it produces no file at all, rather
than a directory of placeholders.
than a directory of placeholders. No voice-activity filter runs inside the
recogniser \[em] one throws away the single-word overs between transmissions,
which on a scanner are the replies worth having. Instead the whole capture is
asked once whether anything in it rises above its own noise, and refused
before a recogniser sees it if nothing does. That check can veto a capture
but never trim one, so a short reply in the middle of a quiet channel
survives it.
.PP
.BR saunterbrowse (1)
reads these back, and lists any callsigns it finds in them with the licence
@ -984,6 +990,11 @@ up, hyphenates them, or drops a hesitation into the middle of the run. So
back as the callsigns they are, and "alfa", "juliett" and "whisky" count
alongside the official spellings.
.PP
Two shapes are recognised. An amateur callsign is a prefix, a district digit
and a suffix; everything else the FCC licenses is written the other way
round, the letters first and then the digits, so WQVF960 and WXG204 are read
as the GMRS and business licences they are.
.PP
Nothing is joined across a slash: a suffix says where the station is, not
what it is called, so
.I W1AW/B

View file

@ -216,27 +216,42 @@ A suffix is not part of the callsign. Nothing is joined across a slash, so
.I W1AW/B
is W1AW and not W1AWB, which belongs to nobody.
.PP
Two shapes are recognised, not one. An amateur callsign is a prefix, a
district digit and a suffix \[em] W1AW, KU0W, 2E0ABC. Everything else the FCC
licenses is called the other way round, the letters first and then the digits:
WQVF960 is a GMRS licence and WXG204 an older Part 90 one. On the GMRS and
business channels those are most of what is said.
.PP
False positives are the thing to avoid: a browser that invents callsigns is
worse than one that finds none. So a run of words is only accepted when none
of its parts is an ordinary English word \[em] "or 3. Can you open 4" fits the
shape once the punctuation is gone, and is not a callsign \[em] while a single
token said in one breath is trusted, because W1BOY is a perfectly good
callsign.
callsign. The GMRS shape is not given that trust: its letters are a run of
two to four, which is the length of a short word, so "west 120" fits it
exactly and a licence prefix never spells anything.
.PP
The lookup uses the FCC's own licence data, published at callook.info, which
needs no account and no key. The callsign is the only thing sent, results are
cached under
The lookup uses the FCC's own licence data, published at callook.info, and
falls back to hamdb.org; neither needs an account or a key. The second is not
a spare copy of the first \[em] callook holds United States amateur licences
only, so a German or Canadian callsign is INVALID there and known to the
other, and a source that is down takes only itself with it. The callsign is
the only thing sent, results are cached under
.I ~/.cache/bandsaunter/
so the same net is looked up once however many nights it is recorded, and a
lookup never delays the display: the entry says "looking up" and fills itself
in.
.PP
A GMRS or business callsign is not looked up at all, and says so rather than
saying "unlisted". Every database reachable without an account is an amateur
register and WQVF960 was never in one, so reporting it as missing would blame
the callsign for the absence of a source.
.PP
.B \-\-no\-lookup
contacts nothing at all. Callsigns are still found, and still described from
their own structure \[em] the prefix is allocated by the ITU and the digit is
the US licensing district, so a callsign says which country and which region
it belongs to without any database. Outside the United States that is all
there is; callook.info holds US licences only.
it belongs to without any database.
.PP
.B \-\-callsigns
prints every callsign in the directory, who it belongs to, and each frequency

View file

@ -382,7 +382,13 @@ unattended receiver keeps adding to that file night after night rather than
starting it over.
.PP
A capture with nothing recognisable in it produces no file at all, rather
than a directory of placeholders.
than a directory of placeholders. No voice-activity filter runs inside the
recogniser \[em] one throws away the single-word overs between transmissions,
which on a scanner are the replies worth having. Instead the whole capture is
asked once whether anything in it rises above its own noise, and refused
before a recogniser sees it if nothing does. That check can veto a capture
but never trim one, so a short reply in the middle of a quiet channel
survives it.
.PP
.BR saunterbrowse (1)
reads these back, and lists any callsigns it finds in them with the licence
@ -397,6 +403,11 @@ up, hyphenates them, or drops a hesitation into the middle of the run. So
back as the callsigns they are, and "alfa", "juliett" and "whisky" count
alongside the official spellings.
.PP
Two shapes are recognised. An amateur callsign is a prefix, a district digit
and a suffix; everything else the FCC licenses is written the other way
round, the letters first and then the digits, so WQVF960 and WXG204 are read
as the GMRS and business licences they are.
.PP
Nothing is joined across a slash: a suffix says where the station is, not
what it is called, so
.I W1AW/B

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-08-30" "bandsaunter 2026-08-30_01" "User Commands"
.TH SAUNTERBROWSE 1 "2026-09-02" "bandsaunter 2026-09-01_01" "User Commands"
.SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS
@ -190,27 +190,42 @@ A suffix is not part of the callsign. Nothing is joined across a slash, so
.I W1AW/B
is W1AW and not W1AWB, which belongs to nobody.
.PP
Two shapes are recognised, not one. An amateur callsign is a prefix, a
district digit and a suffix \[em] W1AW, KU0W, 2E0ABC. Everything else the FCC
licenses is called the other way round, the letters first and then the digits:
WQVF960 is a GMRS licence and WXG204 an older Part 90 one. On the GMRS and
business channels those are most of what is said.
.PP
False positives are the thing to avoid: a browser that invents callsigns is
worse than one that finds none. So a run of words is only accepted when none
of its parts is an ordinary English word \[em] "or 3. Can you open 4" fits the
shape once the punctuation is gone, and is not a callsign \[em] while a single
token said in one breath is trusted, because W1BOY is a perfectly good
callsign.
callsign. The GMRS shape is not given that trust: its letters are a run of
two to four, which is the length of a short word, so "west 120" fits it
exactly and a licence prefix never spells anything.
.PP
The lookup uses the FCC's own licence data, published at callook.info, which
needs no account and no key. The callsign is the only thing sent, results are
cached under
The lookup uses the FCC's own licence data, published at callook.info, and
falls back to hamdb.org; neither needs an account or a key. The second is not
a spare copy of the first \[em] callook holds United States amateur licences
only, so a German or Canadian callsign is INVALID there and known to the
other, and a source that is down takes only itself with it. The callsign is
the only thing sent, results are cached under
.I ~/.cache/bandsaunter/
so the same net is looked up once however many nights it is recorded, and a
lookup never delays the display: the entry says "looking up" and fills itself
in.
.PP
A GMRS or business callsign is not looked up at all, and says so rather than
saying "unlisted". Every database reachable without an account is an amateur
register and WQVF960 was never in one, so reporting it as missing would blame
the callsign for the absence of a source.
.PP
.B \-\-no\-lookup
contacts nothing at all. Callsigns are still found, and still described from
their own structure \[em] the prefix is allocated by the ITU and the digit is
the US licensing district, so a callsign says which country and which region
it belongs to without any database. Outside the United States that is all
there is; callook.info holds US licences only.
it belongs to without any database.
.PP
.B \-\-callsigns
prints every callsign in the directory, who it belongs to, and each frequency

View file

@ -39,7 +39,7 @@ def no_licence_lookups(monkeypatch):
answers stubs this itself; anything else fails loudly rather than going
to the network and being slow, flaky and rude about it.
"""
def refuse(self, call):
def refuse(self, url, call):
raise AssertionError(f"a test tried to look up {call} for real")
monkeypatch.setattr(bandsaunter.callsign.CallsignBook, "_request", refuse)

View file

@ -78,7 +78,7 @@ def no_network(monkeypatch):
it failed printed a real operator's address. A stub that is forgotten
should fail loudly rather than work.
"""
def refuse(self, call):
def refuse(self, url, call):
raise AssertionError(f"a test tried to look up {call} for real")
monkeypatch.setattr(CallsignBook, "_request", refuse)
@ -774,7 +774,7 @@ class StubBook(CallsignBook):
self.requested = []
super().__init__(cache=(tmp or Path("/nonexistent")) / "c.json", **kw)
def _request(self, call):
def _request(self, url, call):
self.requested.append(call)
if call not in self.answers:
raise OSError("offline")
@ -828,9 +828,9 @@ def test_a_callsign_broken_by_the_recogniser_is_still_found(net, tmp_path):
def test_the_lookup_is_shown_as_pending_before_it_lands(net, tmp_path):
class Slow(StubBook):
def _request(self, call):
def _request(self, url, call):
time.sleep(1.0)
return super()._request(call)
return super()._request(url, call)
b = browser(net)
b.book = Slow({"KU0W": KU0W}, tmp=tmp_path)
@ -920,9 +920,9 @@ def test_no_lookup_contacts_nothing(net, capsys, monkeypatch):
made = []
class Watching(StubBook):
def _request(self, call):
def _request(self, url, call):
made.append(call)
return super()._request(call)
return super()._request(url, call)
monkeypatch.setattr("bandsaunter.browse.CallsignBook",
lambda **kw: Watching({"KU0W": KU0W}, tmp=net, **kw))
@ -979,7 +979,7 @@ def test_the_listing_carries_the_band_beside_the_frequency(library, capsys):
# ---------------------------------------------------------------------------
def _stub_lookup(monkeypatch):
def respond(self, call):
def respond(self, url, call):
return {"status": "VALID", "name": "ARRL HQ OPERATORS CLUB",
"address": {"line2": "NEWINGTON, CT 06111"},
"location": {"latitude": "41.714775",

View file

@ -14,10 +14,11 @@ import time
import pytest
from bandsaunter.callsign import (Callsign, CallsignBook, FILLERS, HEADING,
NOT_CALLSIGNS, PHONETIC, SHAPE,
_phonetic_parts, describe_prefix,
find_callsigns, person_case, report,
from bandsaunter.callsign import (BACKUP_URL, Callsign, CallsignBook, FILLERS,
HEADING, NOT_CALLSIGNS, PHONETIC,
SERVICE_SHAPE, SHAPE, _phonetic_parts,
describe_prefix, find_callsigns,
is_service_call, person_case, report,
split_postcode)
@ -28,7 +29,7 @@ def no_network(monkeypatch):
Every lookup test uses a stub; this makes forgetting one fail loudly
rather than quietly pass with somebody's real address in the output.
"""
def refuse(self, call):
def refuse(self, url, call):
raise AssertionError(f"a test tried to look up {call} for real")
monkeypatch.setattr(CallsignBook, "_request", refuse)
@ -288,14 +289,27 @@ VALID = {
class StubBook(CallsignBook):
"""A book whose lookups are answered from a dict, not the network."""
"""A book whose lookups are answered from a dict, not the network.
def __init__(self, answers, **kw):
Only the first source is stubbed; the second is switched off unless a
test asks for it, so "how many requests did that take" stays a question
about one database.
"""
def __init__(self, answers, backup=None, **kw):
self.answers = answers
self.backup = backup or {}
self.requested = []
self.backup_requested = []
kw.setdefault("backup_url", BACKUP_URL if backup else "")
super().__init__(**kw)
def _request(self, call):
def _request(self, url, call):
if url == self.backup_url:
self.backup_requested.append(call)
if call not in self.backup:
raise OSError("no route to host")
return self.backup[call]
self.requested.append(call)
if call not in self.answers:
raise OSError("no route to host")
@ -327,9 +341,9 @@ def test_rendering_never_waits_for_the_network(tmp_path):
assertion and the test would pass whether or not the call blocked.
"""
class Slow(StubBook):
def _request(self, call):
def _request(self, url, call):
time.sleep(1.0)
return super()._request(call)
return super()._request(url, call)
b = Slow({"KU0W": VALID}, cache=tmp_path / "cache.json")
started = time.time()
@ -571,3 +585,211 @@ def test_a_record_cached_before_the_street_is_asked_about_again(tmp_path):
entry = b.get("W1AW")
b.wait(5.0)
assert entry.street == "225 Main St"
# -- GMRS, business and public-safety callsigns ------------------------------
#
# The commonest kind on 462 and 464 MHz, and the ones the amateur shape could
# never match: the letters come first and the digits after, with no district
# digit in the middle to anchor on.
@pytest.mark.parametrize("call", [
"WQVF960", # GMRS, four letters and three digits
"WSKL204",
"WSLB491",
"WXG204", # an older Part 90 licence, three letters
"WRF5506", # three letters and four digits
"KA12345", # two letters and five
])
def test_a_service_callsign_is_recognised(call):
assert SERVICE_SHAPE.match(call), f"{call} did not match"
assert is_service_call(call)
assert find_callsigns(f"copy that, {call} out") == [call]
@pytest.mark.parametrize("said,call", [
("Usually they don't need to be. WQVF960. Copy that.", "WQVF960"),
("I'm here at WSKL204. The name is Chris.", "WSKL204"),
("so thank you, WSLB491 out. All right, man.", "WSLB491"),
])
def test_a_service_callsign_is_found_in_what_was_said(said, call):
"""The exact sentences a night on the GMRS channels produced."""
assert call in find_callsigns(said)
def test_a_service_callsign_spelled_out_is_put_back_together():
"""Seven tokens, one callsign -- and longer than any amateur one."""
assert find_callsigns(
"whiskey quebec victor foxtrot nine six zero, monitoring"
) == ["WQVF960"]
@pytest.mark.parametrize("text", [
"I bought a KN95 mask",
"spray some WD40 on it",
"a KC135 went over",
"the K9 unit is here",
])
def test_letters_then_digits_is_not_enough_to_be_a_callsign(text):
"""The shape is two branches, not "letters then digits", for this reason:
a loose pattern claims every model number ever spoken."""
assert find_callsigns(text) == []
def test_the_digit_in_a_service_callsign_is_not_a_district():
"""Only amateur callsigns carry a district there. WQVF960's 9 means
nothing, and "district 9 (IL IN WI)" would be a confident answer to a
question nobody asked."""
country, district = describe_prefix("WQVF960")
assert country == "United States"
assert district == ""
assert describe_prefix("W9ABC")[1].startswith("district 9")
def test_a_service_callsign_is_not_asked_of_the_amateur_databases(tmp_path):
"""Both reachable databases are amateur registers and answer INVALID for
every one of these, so the request could only ever cost a second."""
b = book(tmp_path, {})
entry = b.get("WQVF960")
b.wait(2)
assert b.requested == []
assert entry.status == "service"
assert "GMRS or business" in entry.summary()
assert "unlisted" not in entry.summary()
def test_a_service_callsign_is_remembered_between_runs(tmp_path):
b = book(tmp_path, {})
b.get("WQVF960")
b.save()
again = book(tmp_path, {})
assert again.get("WQVF960").status == "service"
# -- the second database -----------------------------------------------------
HAMDB = {"hamdb": {"callsign": {
"call": "VE3ABC", "class": "", "expires": "", "grid": "FN25ci",
"lat": "45.3606600", "lon": "-75.7667900",
"fname": "Lawrence Carson", "mi": "", "name": "Kearns", "suffix": "",
"addr1": "2205 BEL-AIR DRIVE", "addr2": "OTTAWA", "state": "ON",
"zip": "K2C0X2", "country": "Canada"}}}
def test_the_second_database_is_asked_when_the_first_has_nothing(tmp_path):
"""callook holds United States amateur licences and nothing else, so a
Canadian callsign is INVALID there and perfectly well known elsewhere."""
b = book(tmp_path, {"VE3ABC": {"status": "INVALID"}},
backup={"VE3ABC": HAMDB})
entry = b.get("VE3ABC")
b.wait(5)
assert entry.known
assert entry.source == "hamdb.org"
assert entry.name == "Lawrence Carson Kearns"
assert entry.location == "Ottawa, ON"
assert entry.street == "2205 Bel-Air Drive"
assert entry.country == "Canada"
assert entry.position == (45.36066, -75.76679)
def test_the_second_database_answers_when_the_first_is_unreachable(tmp_path):
"""One source down should not take every lookup with it."""
b = book(tmp_path, {}, backup={"VE3ABC": HAMDB})
entry = b.get("VE3ABC")
b.wait(5)
assert entry.known and entry.source == "hamdb.org"
def test_the_second_database_is_not_asked_when_the_first_answered(tmp_path):
b = book(tmp_path, {"KU0W": VALID}, backup={"KU0W": HAMDB})
b.get("KU0W")
b.wait(5)
assert b.requested == ["KU0W"]
assert b.backup_requested == []
def test_both_databases_failing_is_offline_not_unlisted(tmp_path):
""""Unlisted" is a claim about the register. Nothing was reached here,
so nothing can be said about it."""
b = book(tmp_path, {}, backup={})
b.get("W1AW")
b.wait(5)
assert b.get("W1AW").status == "offline"
def test_the_second_database_says_not_found_in_every_field(tmp_path):
"""It writes the string NOT_FOUND rather than saying so once, which is
why the callsign field is tested before any of the rest is believed."""
missing = {"hamdb": {"callsign": {
k: "NOT_FOUND" for k in
("call", "class", "expires", "grid", "lat", "lon", "fname", "mi",
"name", "addr1", "addr2", "state", "zip", "country")}}}
b = book(tmp_path, {"ZZ9ZZZ": {"status": "INVALID"}},
backup={"ZZ9ZZZ": missing})
b.get("ZZ9ZZZ")
b.wait(5)
entry = b.get("ZZ9ZZZ")
assert entry.status == "unlisted"
assert entry.name == "" and entry.street == ""
def test_a_second_address_line_that_repeats_the_street_is_dropped(tmp_path):
"""Outside North America that field holds the street again, and printing
it would put the address in twice."""
body = {"hamdb": {"callsign": dict(
HAMDB["hamdb"]["callsign"],
call="DL1ABC", fname="", name="Klaus-Dietmar Brackmann",
addr1="Olxheim 3a", addr2="Olxheim 3 a", state="",
zip="37574 Einbeck-Olxheim", country="Germany", **{"class": "A"})}}
b = book(tmp_path, {"DL1ABC": {"status": "INVALID"}},
backup={"DL1ABC": body})
b.get("DL1ABC")
b.wait(5)
entry = b.get("DL1ABC")
assert entry.street == "Olxheim 3a"
assert entry.location == ""
assert entry.address == "Olxheim 3a, 37574 Einbeck-Olxheim"
# "A" is Advanced in the United States and a national class elsewhere,
# so it is left as it was written.
assert entry.oper_class == "A"
def test_a_united_states_class_letter_is_spelled_out(tmp_path):
body = {"hamdb": {"callsign": dict(
HAMDB["hamdb"]["callsign"], call="KK7SVX", country="United States",
**{"class": "T"})}}
b = book(tmp_path, {"KK7SVX": {"status": "INVALID"}},
backup={"KK7SVX": body})
b.get("KK7SVX")
b.wait(5)
assert b.get("KK7SVX").oper_class == "Technician"
def test_a_spelled_out_callsign_does_not_run_into_the_next_sentence():
"""From a real transcript. The lengths in the shape are what stop it:
with four letters and four digits allowed, the joiner read the 0 that
began the next callsign as part of this one."""
said = ("learn calls by Jeff. 7-3-W-F-K-L-2-0-4. 0-4-W-R-C-U, "
"Charlie Uniform.")
assert "WFKL204" in find_callsigns(said)
assert "WFKL2040" not in find_callsigns(said)
def test_a_service_callsign_said_with_a_pause_is_put_back_together():
""""WRUC 242 with WSAB 543, see you later" -- the recogniser put those
gaps in, and they mean nothing."""
found = find_callsigns("WRUC 242 with WSAB 543, see you later.")
assert found == ["WRUC242", "WSAB543"]
@pytest.mark.parametrize("text", [
"unit west 120 responding",
"the new 2024 model",
"the net 100 is up",
" ".join(f"word{n}" for n in range(120)),
])
def test_a_word_and_a_number_is_not_a_service_callsign(text):
"""The amateur shape can trust a single token -- no English word has a
digit in the middle of it. This one cannot: its letters are a run of two
to four, which is the length of a short word."""
assert find_callsigns(text) == []

View file

@ -39,7 +39,7 @@ class StubBook(CallsignBook):
self.asked: list[str] = []
super().__init__(cache=Path(tmp) / "calls.json", **kw)
def _request(self, call):
def _request(self, url, call):
self.asked.append(call)
return {"status": "VALID", "current": {"callsign": call},
"name": "Newington Radio Club",

View file

@ -19,7 +19,7 @@ KML_NS = "{http://www.opengis.net/kml/2.2}"
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
def refuse(self, call):
def refuse(self, url, call):
raise AssertionError(f"a test tried to look up {call} for real")
monkeypatch.setattr(CallsignBook, "_request", refuse)
@ -335,7 +335,7 @@ def fake_lookup(monkeypatch):
"""The licence database, answering from a fixture instead of the network."""
asked = []
def respond(self, call):
def respond(self, url, call):
asked.append(call)
return dict(CALLOOK, current={"callsign": call, "operClass": "CLUB"})
@ -426,7 +426,7 @@ def test_the_map_is_off_when_no_file_is_named(tmp_path, heard_speech,
def test_lookups_can_be_turned_off_entirely(tmp_path, heard_speech):
"""Nothing may reach the network with the lookup switched off."""
def refuse(self, call):
def refuse(self, url, call):
raise AssertionError("looked a callsign up with lookups off")
import bandsaunter.callsign as cs

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