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:
parent
0f378c4d6c
commit
8a789e57e1
14 changed files with 679 additions and 74 deletions
58
README.md
58
README.md
|
|
@ -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 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. 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 10–27. 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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue