Recognise trunking control channels, and refuse to sit on them
A trunked system keeps one frequency transmitting a data stream around the clock so its radios know where each conversation has been put. There is no speech on it and it never stops, which makes it the strongest and most useless signal in the band: the scanner parked on 856.561 MHz for the full record limit, saved four minutes of buzzing, and found it again on the next sweep. Five signatures, matched against a constant-envelope stream that never pauses: 3600 baud two-level (Motorola SMARTNET/SmartZone), 9600 (EDACS), 1200 (MPT-1327), 4800 four-level (P25 or DMR Tier III), 2400 (NXDN). The first two are believed at once -- nothing else sends at those rates without pausing. The rest share their shape with a digital voice call on the same system, so they wait for the carrier to run unbroken past --control-seconds, longer than a conversation goes without a breath. Being in a trunked allocation raises confidence but is never required; trunking is licensed on business pairs all over the spectrum. One is named on screen, abandoned within a second or so, and its capture deleted. --keep-control records them for a decoder; --lockout-control writes them into the lock-out list. Three things had to be fixed to get there. The simulator's "pseudo-random" symbols were a counter: multiplying the symbol index by an odd constant and taking it modulo the level count returns the low bits, so two-level FSK came out 0,1,0,1. Every FSK test in the suite was measuring a tone. Its FSK is now shaped the way GFSK and C4FM shape a stream, too, square-edged keying being a signal no licensed transmitter would radiate. The symbol-rate estimator locked onto harmonics -- 3600 baud read as 18000 -- because a transition impulse train is a comb of equal lines; it now walks down to the fundamental. The squared envelope is no longer a candidate: it is not a transition signal, and its DC lobe made every random OOK signal measure ninety baud. The search starts at 200 Hz rather than 40, below which it was reading drift, which is how a bare carrier was awarded a symbol rate. And a clean two-level signal counted zero discriminator levels, because its modes land in the first and last histogram bin, where find_peaks cannot see them. Separately: locking out a frequency wrote to the settings file even under --no-config, which has no settings file by definition. It now writes only where it read from, and --simulate never writes at all -- an invented frequency would sit in a real config for ever, skipping whatever genuine signal happened to land near it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
ba6c925351
commit
4a272eb1d5
14 changed files with 984 additions and 53 deletions
86
README.md
86
README.md
|
|
@ -450,8 +450,13 @@ only for:
|
|||
| `cw` | a keyed carrier whose timing resolves as Morse |
|
||||
| `digital` | an identified keying scheme: discrete FSK levels, an M-PSK phase line, or on-off keying -- corroborated by a symbol rate |
|
||||
|
||||
Two more categories exist but are not accepted by default: `carrier`
|
||||
(unmodulated, real but empty) and `trunk` (a [trunking control
|
||||
channel](#trunked-systems-and-their-control-channels)).
|
||||
|
||||
Everything else -- static, hum, switch-mode power supply harmonics, clock
|
||||
spurs, bare carriers -- is discarded, and the files it wrote are deleted.
|
||||
spurs, bare carriers, trunking control channels -- is discarded, and the files
|
||||
it wrote are deleted.
|
||||
|
||||
The check runs *while* the capture is still going, so interference is dropped
|
||||
after a second or two instead of holding the receiver for the whole record
|
||||
|
|
@ -574,6 +579,62 @@ Each result carries a confidence and the reasoning behind it:
|
|||
|
||||
Low SNR reduces confidence rather than producing a confident wrong answer.
|
||||
|
||||
### Trunked systems and their control channels
|
||||
|
||||
Police, fire and most large business radio in the US runs on *trunked*
|
||||
systems. Rather than giving each department a frequency of its own, the system
|
||||
owns a pool of channels and hands one out per conversation. For that to work,
|
||||
one frequency is given over entirely to a data stream that runs day and night
|
||||
telling every radio in the fleet where to go next. That frequency is the
|
||||
**control channel**.
|
||||
|
||||
It is the worst thing a scanner can find: loud, perfectly steady, never
|
||||
silent, and with nothing on it to hear — just a harsh buzz. Left to itself a
|
||||
scanner parks on it for the whole record limit, saves the file, and finds it
|
||||
again on the next sweep, for as long as it runs.
|
||||
|
||||
bandsaunter recognises one and moves on, usually within a second or two:
|
||||
|
||||
```
|
||||
TRUNK 856.561096 MHz -- Motorola SMARTNET / SmartZone (Type I/II) control channel, 3600 baud -- skipping
|
||||
```
|
||||
|
||||
In the live display the recording panel turns yellow and says `TRUNK:` with
|
||||
the system name instead of `REC`, and the end-of-run summary lists every
|
||||
control channel found and where it was.
|
||||
|
||||
What identifies one is a constant-envelope data stream that never pauses, at a
|
||||
symbol rate belonging to a known trunking standard:
|
||||
|
||||
| Symbol rate | Levels | System |
|
||||
|---|---|---|
|
||||
| 3600 baud | 2 | Motorola SMARTNET / SmartZone (Type I/II) |
|
||||
| 9600 baud | 2 | EDACS / ProVoice |
|
||||
| 1200 baud | 2 | MPT-1327 |
|
||||
| 4800 baud | 4 | P25 or DMR Tier III |
|
||||
| 2400 baud | 4 | NXDN / NEXEDGE |
|
||||
|
||||
The first two are called immediately — nothing else transmits at those rates
|
||||
without pausing. The rest share their shape with an ordinary digital voice
|
||||
call on the same system, so they are only judged to be a control channel once
|
||||
the carrier has run unbroken for `--control-seconds` (20 s by default), which
|
||||
is longer than a real conversation goes without taking a breath. Raise it if
|
||||
digital voice is being skipped by mistake.
|
||||
|
||||
Sitting in a band where trunking is common raises confidence but is never
|
||||
required — trunking is licensed on business pairs all over the spectrum, so
|
||||
the shape of the signal has to be enough on its own.
|
||||
|
||||
```bash
|
||||
bandsaunter scan -b 800-trunked # control channels named and skipped
|
||||
bandsaunter scan -b 800-trunked --keep-control # record them (for a decoder)
|
||||
bandsaunter scan -b 800-trunked --lockout-control # never look at them again
|
||||
```
|
||||
|
||||
`--lockout-control` adds each one to the lock-out list as it is found; with
|
||||
lock-out saving on (the default) that list is written to your settings file
|
||||
and survives a restart.
|
||||
|
||||
### CW / Morse
|
||||
|
||||
Keyed carriers are decoded to text. The speed is measured from the signal, so
|
||||
|
|
@ -863,11 +924,18 @@ span is taken exactly as written, since a noisy stretch of spectrum has a
|
|||
definite width rather than a point with a guess around it. Ranges accept the
|
||||
same forms as everywhere else — `450M-455M`, `450-455M`, `88M to 108M`.
|
||||
|
||||
`--lockout-control` adds each trunking control channel to the list as it is
|
||||
found. Two runs never write anything back: `--no-config` has no settings file
|
||||
to write to, since the point of the flag is to leave the saved settings alone;
|
||||
and `--simulate` is looking at an invented band, whose frequencies would sit in
|
||||
a real settings file for ever, skipping whatever genuine signal happened to
|
||||
land near one. Both still lock out for the run in hand, and say so.
|
||||
|
||||
## Built-in help
|
||||
|
||||
Press `h` in the menus for topics covering setup, how the sweep works, why
|
||||
nothing (or too much) is being recorded, capturing conversations, where files
|
||||
go, HF reception and the keys available during a scan. Typing a setting name
|
||||
go, trunked systems, HF reception and the keys available during a scan. Typing a setting name
|
||||
there explains that setting instead.
|
||||
|
||||
From the command line, `bandsaunter config --describe <setting>` does the same,
|
||||
|
|
@ -925,8 +993,15 @@ signal type, which is also what the test suite runs against:
|
|||
|
||||
```bash
|
||||
bandsaunter scan -r 144M-148M --simulate
|
||||
bandsaunter scan -r 856.4M-856.7M --simulate # the control channel, skipped
|
||||
```
|
||||
|
||||
The demo band holds 2 m FM voice with a CTCSS tone, a repeater, a CW beacon,
|
||||
NOAA weather radio, airband AM, an FM broadcast station, P25-style digital
|
||||
voice, a POCSAG pager, a bare carrier, a 433 MHz ISM remote, and a SMARTNET
|
||||
control channel that never stops transmitting — because that last one is only
|
||||
interesting if it behaves the way the real thing does.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
|
@ -946,7 +1021,12 @@ then gated into syllables and phrases -- because sine tones would not exercise
|
|||
the speech detector at all. SSB transmitters are filtered to their audio
|
||||
passband first, since that filter is what makes a signal single-sideband, and
|
||||
without it the simulated signal was several times wider than anything on the
|
||||
air.
|
||||
air. Its FSK transmitters are shaped the way GFSK and C4FM shape a symbol
|
||||
stream, because square-edged keying is a signal no licensed radio would
|
||||
radiate, and its symbols are genuinely pseudo-random: an earlier version
|
||||
multiplied the symbol index by an odd constant and took it modulo the level
|
||||
count, which returns the low bits of a counter -- 0, 1, 0, 1 -- so every FSK
|
||||
test was measuring a tone rather than data.
|
||||
|
||||
Its transmitters seed themselves deterministically, so a test that fails can be
|
||||
made to fail again -- the one thing needed to fix it.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ and transcribing speech.
|
|||
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||
# to two digits so versions sort as text.
|
||||
VERSION_DATE = "2026-08-22"
|
||||
VERSION_REVISION = 1
|
||||
VERSION_REVISION = 2
|
||||
|
||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ from .dsp import (db, instantaneous_frequency,
|
|||
occupied_bandwidth, spectral_flatness, welch_psd)
|
||||
|
||||
__all__ = ["classify", "Classification", "SignalFeatures", "extract_features",
|
||||
"CTCSS_TONES", "detect_ctcss", "SSBAlignment", "ssb_alignment"]
|
||||
"CTCSS_TONES", "detect_ctcss", "SSBAlignment", "ssb_alignment",
|
||||
"ControlChannel", "trunk_control", "CONTROL_SIGNATURES"]
|
||||
|
||||
|
||||
def _pow2_floor(n: int, cap: int = 1 << 16) -> int:
|
||||
|
|
@ -114,6 +115,7 @@ class Classification:
|
|||
alternatives: list[tuple[str, float]] = field(default_factory=list)
|
||||
suggested_mode: str = "nfm"
|
||||
features: SignalFeatures | None = None
|
||||
control: "ControlChannel | None" = None
|
||||
|
||||
def summary(self) -> str:
|
||||
pct = int(round(self.confidence * 100))
|
||||
|
|
@ -142,8 +144,18 @@ def _otsu(values: np.ndarray, bins: int = 128) -> float:
|
|||
return float(centres[int(np.argmax(var_between))])
|
||||
|
||||
|
||||
# Nothing this program identifies keys slower than a few hundred baud -- the
|
||||
# slowest named system is 512 baud POCSAG -- and below a couple of hundred
|
||||
# hertz the transition signals are dominated by slow drift in gain and
|
||||
# frequency, whose spectrum is a lobe running down to DC rather than a line.
|
||||
# Searching from 40 Hz meant a bare carrier was routinely awarded a "symbol
|
||||
# rate" of fifty-something baud, taken from the bottom edge of that lobe.
|
||||
SYMBOL_RATE_FLOOR_HZ = 200.0
|
||||
|
||||
|
||||
def _cyclic_line(feature: np.ndarray, fs: float,
|
||||
lo_hz: float = 40.0, hi_hz: float | None = None):
|
||||
lo_hz: float = SYMBOL_RATE_FLOOR_HZ,
|
||||
hi_hz: float | None = None):
|
||||
"""Find the strongest periodic line in a nonnegative feature signal.
|
||||
|
||||
Symbol transitions sit on a symbol-rate grid, so the transition-magnitude
|
||||
|
|
@ -171,6 +183,30 @@ def _cyclic_line(feature: np.ndarray, fs: float,
|
|||
k = int(np.argmax(sub))
|
||||
peak = sub[k]
|
||||
med = np.median(sub) + 1e-12
|
||||
if peak <= 0:
|
||||
return 0.0, 0.0
|
||||
|
||||
# Symbol transitions are impulses on the symbol grid, and an impulse train
|
||||
# has a *comb* of lines: the symbol rate and every multiple of it, all of
|
||||
# comparable height. Taking the tallest therefore returns a harmonic as
|
||||
# often as the fundamental -- 3600 baud came back as 18000. So walk back
|
||||
# down the comb and report the lowest sub-multiple that still carries a
|
||||
# line of its own.
|
||||
df = float(freqs[1] - freqs[0])
|
||||
for m in range(8, 1, -1):
|
||||
want = subf[k] / m
|
||||
if want < lo_hz:
|
||||
continue
|
||||
j = int(round((want - subf[0]) / df))
|
||||
w = max(1, int(round(0.02 * want / df))) # +/- 2%, for a little drift
|
||||
lo_i, hi_i = max(0, j - w), min(sub.size, j + w + 1)
|
||||
if hi_i <= lo_i:
|
||||
continue
|
||||
window = sub[lo_i:hi_i]
|
||||
height = float(window.max())
|
||||
if height > 0.30 * peak and height > 6.0 * med:
|
||||
i = lo_i + int(np.argmax(window))
|
||||
return float(subf[i]), float(20.0 * math.log10(height / med))
|
||||
return float(subf[k]), float(20.0 * math.log10(peak / med))
|
||||
|
||||
|
||||
|
|
@ -182,6 +218,12 @@ def _count_modes(values: np.ndarray, weights: np.ndarray | None = None,
|
|||
lo, hi = np.percentile(values, [1.0, 99.0])
|
||||
if hi <= lo:
|
||||
return 0, 0.0, np.zeros(0)
|
||||
# Leave room at both ends. find_peaks cannot return the first or last
|
||||
# bin, and a clean two-level FSK signal puts its two modes exactly there:
|
||||
# the levels *are* the 1st and 99th percentiles. Without the margin such
|
||||
# a signal counts zero modes, and the cleaner it is the worse it gets.
|
||||
margin = 0.10 * (hi - lo)
|
||||
lo, hi = lo - margin, hi + margin
|
||||
hist, edges = np.histogram(values, bins=bins, range=(lo, hi), weights=weights)
|
||||
hist = hist.astype(np.float64)
|
||||
if hist.sum() == 0:
|
||||
|
|
@ -564,15 +606,21 @@ def extract_features(x: np.ndarray, sample_rate: float,
|
|||
f.psk_order, f.psk_strength = order, strength
|
||||
|
||||
# ---- symbol rate ---------------------------------------------------
|
||||
# For FSK the transition magnitude of the discriminator carries the line;
|
||||
# for linear modulations the squared envelope does.
|
||||
# Transition magnitude: for FSK the discriminator's, for anything that
|
||||
# keys its amplitude the envelope's. Both are impulse trains sitting on
|
||||
# the symbol grid, which is what puts a line at the symbol rate.
|
||||
#
|
||||
# The squared envelope used to be offered as a third candidate. It is not
|
||||
# a transition signal: for data-carrying NRZ its spectrum is a sinc lobe
|
||||
# peaking at DC, so the lowest bin in the search band beat the band median
|
||||
# by 40-odd dB and won every time. Every random OOK signal measured about
|
||||
# 90 baud because of it.
|
||||
cand = []
|
||||
if ifreq.size > 512:
|
||||
cand.append((_cyclic_line(np.abs(np.diff(ifreq)), sample_rate),
|
||||
np.abs(np.diff(ifreq))))
|
||||
cand.append((_cyclic_line(np.abs(np.diff(env)), sample_rate),
|
||||
np.abs(np.diff(env))))
|
||||
cand.append((_cyclic_line(env ** 2, sample_rate), env ** 2))
|
||||
(baud, strength), winner = max(cand, key=lambda c: c[0][1])
|
||||
if strength > 8.0:
|
||||
f.baud, f.baud_strength = baud, strength
|
||||
|
|
@ -925,16 +973,118 @@ def _marine_channel(freq_hz: float) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trunked-radio control channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A trunked system keeps one channel permanently transmitting a data stream
|
||||
# that tells the radios in the fleet which channel each conversation has been
|
||||
# assigned. There is no speech on it and it never stops, so a scanner that
|
||||
# treats it as a signal parks on it for as long as the record limit allows and
|
||||
# then finds it again on the next sweep. It is the single most common way for
|
||||
# an unattended scan to fill a disk with nothing.
|
||||
#
|
||||
# Each entry is (baud, levels, name, confidence, ambiguous). ``ambiguous``
|
||||
# marks a shape that digital *voice* also uses, which must therefore be held
|
||||
# to a much longer run of unbroken carrier before it is called a control
|
||||
# channel -- a P25 talkgroup and a P25 control channel look alike for the
|
||||
# first few seconds, and only the control channel is still there a minute
|
||||
# later.
|
||||
CONTROL_SIGNATURES: tuple[tuple[float, int, str, float, bool], ...] = (
|
||||
(3600.0, 2, "Motorola SMARTNET / SmartZone (Type I/II)", 0.90, False),
|
||||
(9600.0, 2, "EDACS / ProVoice", 0.80, False),
|
||||
(1200.0, 2, "MPT-1327", 0.62, True),
|
||||
(4800.0, 4, "P25 or DMR Tier III", 0.75, True),
|
||||
(2400.0, 4, "NXDN / NEXEDGE", 0.65, True),
|
||||
)
|
||||
|
||||
# Allocations where trunked systems live in the US. Being inside one is
|
||||
# corroboration, never a requirement: trunking turns up on licensed business
|
||||
# pairs all over the spectrum, and the shape of the signal is the real
|
||||
# evidence.
|
||||
_TRUNKED_BANDS = (
|
||||
(136.0, 174.0), (380.0, 400.0), (406.0, 420.0), (450.0, 470.0),
|
||||
(470.0, 512.0), (758.0, 775.0), (788.0, 805.0), (806.0, 824.0),
|
||||
(851.0, 869.0), (896.0, 902.0), (935.0, 941.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlChannel:
|
||||
"""A trunking control channel, and how sure we are of it."""
|
||||
|
||||
system: str
|
||||
confidence: float
|
||||
reason: str
|
||||
baud: float = 0.0
|
||||
ambiguous: bool = False
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.system} trunking control channel"
|
||||
|
||||
|
||||
def _continuous_data(f: SignalFeatures) -> bool:
|
||||
"""True when the signal is an unbroken, constant-envelope data stream.
|
||||
|
||||
Every control channel looks like this. Bursty data -- a pager page, an
|
||||
ACARS message, a packet frame -- does not, and neither does voice, which
|
||||
swings the envelope around as the speaker pauses.
|
||||
"""
|
||||
return (f.env_cv < 0.30 and f.ook_contrast_db < 12.0
|
||||
and f.duty_cycle > 0.75 and f.freq_modes >= 2
|
||||
and f.mode_spacing > 500.0 and f.level_dwell > 0.30)
|
||||
|
||||
|
||||
def trunk_control(f: SignalFeatures, freq_hz: float = 0.0,
|
||||
continuous_for: float = 0.0,
|
||||
min_seconds: float = 20.0) -> ControlChannel | None:
|
||||
"""Identify a trunking control channel from its shape and symbol rate.
|
||||
|
||||
``continuous_for`` is how many seconds of unbroken carrier this capture
|
||||
has seen. Signatures shared with digital voice are only believed once
|
||||
that passes ``min_seconds``; the unambiguous symbol rates -- nothing but a
|
||||
control channel sends 3600 baud two-level FSK without pause -- are
|
||||
believed straight away.
|
||||
"""
|
||||
if f.baud <= 0 or f.baud_stability < 0.60 or f.baud_strength < 12.0:
|
||||
return None
|
||||
if not _continuous_data(f):
|
||||
return None
|
||||
|
||||
levels = 4 if f.freq_modes >= 4 else 2
|
||||
for baud, want_levels, name, conf, ambiguous in CONTROL_SIGNATURES:
|
||||
if want_levels != levels or not _baud_near(f, baud, 0.06):
|
||||
continue
|
||||
if ambiguous and continuous_for < min_seconds:
|
||||
return None
|
||||
reason = (f"unbroken {baud:.0f} baud {want_levels}-level data with no "
|
||||
f"speech and no gaps -- a trunking control channel")
|
||||
if ambiguous:
|
||||
reason += f", still transmitting after {continuous_for:.0f} s"
|
||||
mhz = freq_hz / 1e6
|
||||
if any(lo <= mhz <= hi for lo, hi in _TRUNKED_BANDS):
|
||||
conf = min(0.95, conf + 0.05)
|
||||
reason += " in a trunked allocation"
|
||||
return ControlChannel(system=name, confidence=conf, reason=reason,
|
||||
baud=f.baud, ambiguous=ambiguous)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify(x: np.ndarray, sample_rate: float, freq_hz: float = 0.0,
|
||||
snr_db: float = 0.0, analyse_audio: bool = True) -> Classification:
|
||||
snr_db: float = 0.0, analyse_audio: bool = True,
|
||||
continuous_for: float = 0.0,
|
||||
control_seconds: float = 20.0) -> Classification:
|
||||
"""Identify what kind of signal ``x`` is.
|
||||
|
||||
``x`` should be complex baseband centred on the signal. ``freq_hz`` is the
|
||||
real-world centre frequency and is used only for the known-system lookup.
|
||||
``continuous_for`` is how long the carrier has been up without a break,
|
||||
which is what separates a trunking control channel from the digital voice
|
||||
call it otherwise resembles.
|
||||
"""
|
||||
f = extract_features(x, sample_rate, snr_db=snr_db)
|
||||
filtered = f.extras.pop("_filtered", x)
|
||||
|
|
@ -956,6 +1106,9 @@ def classify(x: np.ndarray, sample_rate: float, freq_hz: float = 0.0,
|
|||
label = f"SSB voice ({mode.upper()})"
|
||||
reasons.append(f"{mode.upper()} assumed from the band convention")
|
||||
|
||||
control = trunk_control(f, freq_hz, continuous_for=continuous_for,
|
||||
min_seconds=control_seconds)
|
||||
|
||||
system = _identify_system(freq_hz, f, family)
|
||||
if system:
|
||||
sys_label, sys_conf, sys_reason = system
|
||||
|
|
@ -966,6 +1119,13 @@ def classify(x: np.ndarray, sample_rate: float, freq_hz: float = 0.0,
|
|||
else:
|
||||
reasons.append(f"also consistent with {sys_label}")
|
||||
|
||||
# A control channel outranks the generic name for its modulation: "2-FSK"
|
||||
# is true but useless, and the whole point of spotting one is to say so.
|
||||
if control is not None:
|
||||
label = control.describe()
|
||||
score = max(score, control.confidence)
|
||||
reasons.insert(0, control.reason)
|
||||
|
||||
# Low SNR means low trust, whatever the rules said.
|
||||
if f.snr_db < 8:
|
||||
score *= 0.65
|
||||
|
|
@ -977,6 +1137,7 @@ def classify(x: np.ndarray, sample_rate: float, freq_hz: float = 0.0,
|
|||
return Classification(
|
||||
label=label, family=family, confidence=round(min(0.99, score), 3),
|
||||
reasons=reasons, alternatives=alts, suggested_mode=mode, features=f,
|
||||
control=control,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -325,6 +325,13 @@ def cmd_scan(args) -> int:
|
|||
"hardware.[/grey62]")
|
||||
return 1
|
||||
|
||||
if args.simulate and cfg.save_lockouts:
|
||||
# The demo band is invented. A lock-out taken from it would sit in
|
||||
# the real settings file for ever, skipping whatever genuine signal
|
||||
# happened to land near a made-up frequency. Locking out still works
|
||||
# for the run in hand; it is only the writing back that is refused.
|
||||
cfg.save_lockouts = False
|
||||
|
||||
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
|
||||
try:
|
||||
scanner.prepare()
|
||||
|
|
@ -435,6 +442,11 @@ def _print_summary(scanner: Scanner) -> None:
|
|||
if worker.dropped:
|
||||
bits.append(f"{worker.dropped} skipped, the recogniser fell behind")
|
||||
console.print(f"[grey62] {', '.join(bits)}[/grey62]")
|
||||
if st.control_channels:
|
||||
console.print(f"[yellow] {len(st.control_channels)} trunking control "
|
||||
f"channel(s) skipped:[/yellow]")
|
||||
for hz, name in sorted(st.control_channels.items()):
|
||||
console.print(f"[grey62] {fmt_hz(hz):>14} {name}[/grey62]")
|
||||
if st.rejected_by_category:
|
||||
drops = ", ".join(f"{n} {cat}"
|
||||
for cat, n in sorted(st.rejected_by_category.items(),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ class ScanConfig:
|
|||
verify_max_seconds: float = 6.0 # give up on a contentless capture by here
|
||||
max_detections_per_step: int = 4
|
||||
|
||||
# -- trunking control channels --------------------------------------
|
||||
skip_control: bool = True # spot them, abandon them, move on
|
||||
control_seconds: float = 20.0 # unbroken carrier a look-alike must hold
|
||||
lockout_control: bool = False # also add them to the lock-out list
|
||||
|
||||
# -- radio ----------------------------------------------------------
|
||||
device_index: int = 0
|
||||
sample_rate: int = 2_048_000
|
||||
|
|
@ -288,10 +293,16 @@ def remember_lockouts(cfg: "ScanConfig",
|
|||
|
||||
Returns where it was written, or None if it could not be -- locking out a
|
||||
birdie must never be the thing that ends a scan.
|
||||
|
||||
A run started with --no-config has no settings file, and writing one
|
||||
anyway would be the opposite of what was asked: the flag exists to leave
|
||||
the saved settings alone. So without a source to write back to, nothing
|
||||
is written.
|
||||
"""
|
||||
path = getattr(cfg, "_source_path", None)
|
||||
path = Path(path) if path else (Path(directory or DEFAULT_CONFIG_DIR)
|
||||
/ "config.yaml")
|
||||
source = getattr(cfg, "_source_path", None)
|
||||
if not source and directory is None:
|
||||
return None
|
||||
path = Path(source) if source else (Path(directory) / "config.yaml")
|
||||
try:
|
||||
data = {}
|
||||
if path.exists():
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ capture is assessed for *content* before it is kept:
|
|||
* **voice** -- speech structure in the demodulated audio
|
||||
* **cw** -- a keyed carrier whose timing resolves as Morse
|
||||
* **digital** -- a symbol rate, discrete FSK levels, or an M-PSK phase line
|
||||
* **trunk** -- a trunking control channel: continuous data, never any speech
|
||||
* **carrier** -- a steady unmodulated carrier (real, but carries nothing)
|
||||
* **noise** -- no structure at all: static, interference, receiver artefacts
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ import numpy as np
|
|||
__all__ = ["VoiceMetrics", "voice_metrics", "Assessment", "assess",
|
||||
"CATEGORIES", "RAYLEIGH_CV"]
|
||||
|
||||
CATEGORIES = ("voice", "cw", "digital", "carrier", "noise")
|
||||
CATEGORIES = ("voice", "cw", "digital", "trunk", "carrier", "noise")
|
||||
|
||||
# Envelope coefficient of variation for complex Gaussian noise: |x| is
|
||||
# Rayleigh distributed, so std/mean is exactly sqrt(4/pi - 1).
|
||||
|
|
@ -216,6 +217,7 @@ class Assessment:
|
|||
reason: str = ""
|
||||
voice: VoiceMetrics = field(default_factory=VoiceMetrics)
|
||||
noise_likeness: float = 0.0
|
||||
control: object = None # ControlChannel when this is a control channel
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.category} ({self.score:.2f}): {self.reason}"
|
||||
|
|
@ -293,7 +295,7 @@ def digital_structure(f) -> tuple[float, list[str]]:
|
|||
def assess(classification, audio: np.ndarray, audio_rate: float,
|
||||
morse=None, min_voice: float = 0.45, freq_hz: float = 0.0,
|
||||
accept: tuple[str, ...] = ("voice", "cw", "digital"),
|
||||
min_score: float = 0.45) -> Assessment:
|
||||
min_score: float = 0.45, skip_control: bool = True) -> Assessment:
|
||||
"""Decide what a capture contains and whether it should be kept.
|
||||
|
||||
Deliberately does not route on the classifier's label. The label is a
|
||||
|
|
@ -313,32 +315,43 @@ def assess(classification, audio: np.ndarray, audio_rate: float,
|
|||
a.voice = vm
|
||||
dig_score, dig_bits = digital_structure(f)
|
||||
|
||||
# 1. Morse that actually decoded.
|
||||
if morse is not None and morse.is_morse:
|
||||
# 1. A trunking control channel, before anything else. It is a positive
|
||||
# identification rather than a failure to find content -- the stream is
|
||||
# real data, and would otherwise be kept as "digital" and hold the
|
||||
# receiver for the full record time on a channel with nothing to hear.
|
||||
if skip_control and getattr(classification, "control", None) is not None:
|
||||
c = classification.control
|
||||
a.category = "trunk"
|
||||
a.control = c
|
||||
a.score = float(c.confidence)
|
||||
a.reason = f"{c.describe()}: {c.reason}"
|
||||
|
||||
# 2. Morse that actually decoded.
|
||||
elif morse is not None and morse.is_morse:
|
||||
a.category = "cw"
|
||||
a.score = float(morse.confidence)
|
||||
a.reason = f'Morse decoded at {morse.wpm:.0f} WPM: "{morse.text.strip()[:40]}"'
|
||||
|
||||
# 2. Speech, whatever the modulation was called.
|
||||
# 3. Speech, whatever the modulation was called.
|
||||
elif vm.score >= min_voice:
|
||||
a.category = "voice"
|
||||
a.score = vm.score
|
||||
a.reason = f"speech in the audio ({vm.describe()})"
|
||||
|
||||
# 3. Symbol structure.
|
||||
# 4. Symbol structure.
|
||||
elif dig_score > 0.3:
|
||||
a.category = "digital"
|
||||
a.score = min(0.95, 0.35 + dig_score)
|
||||
a.reason = "digital modulation: " + ", ".join(dig_bits)
|
||||
|
||||
# 4. A keyed carrier whose timing would not resolve as Morse.
|
||||
# 5. A keyed carrier whose timing would not resolve as Morse.
|
||||
elif fam == "cw" and f.ook_contrast_db > 12:
|
||||
a.category = "cw"
|
||||
a.score = 0.55
|
||||
a.reason = (f"keyed carrier, {f.ook_contrast_db:.0f} dB on/off contrast "
|
||||
"(timing did not resolve as Morse)")
|
||||
|
||||
# 5. Broadcast FM gets a lenient path because most of it is music, which
|
||||
# 6. Broadcast FM gets a lenient path because most of it is music, which
|
||||
# has no speech pitch track to find. That leniency is confined to
|
||||
# signals that really are broadcast: in the FM band, or carrying a
|
||||
# 19 kHz stereo pilot. Without the restriction, any wideband hump --
|
||||
|
|
@ -351,7 +364,7 @@ def assess(classification, audio: np.ndarray, audio_rate: float,
|
|||
a.reason = ("programme audio on a wideband FM carrier"
|
||||
+ (" with a 19 kHz stereo pilot" if f.stereo_pilot else ""))
|
||||
|
||||
# 6. A carrier that is really just a carrier.
|
||||
# 7. A carrier that is really just a carrier.
|
||||
elif fam == "carrier" or (f.am_depth < 0.004 and f.fdev_rms < 150
|
||||
and f.ook_contrast_db < 6):
|
||||
a.category = "carrier"
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ class ScanStats:
|
|||
dropped_samples: int = 0
|
||||
truncated: int = 0 # captures cut off mid-transmission
|
||||
rejected_by_category: dict = field(default_factory=dict)
|
||||
control_channels: dict = field(default_factory=dict) # Hz -> system name
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
|
|
@ -80,6 +81,7 @@ class ScannerCallbacks:
|
|||
on_record_start: callable = None # (Detection, Recording)
|
||||
on_record_tick: callable = None # (Recording, elapsed, signal_present, snr)
|
||||
on_record_end: callable = None # (HitRecord)
|
||||
on_record_note: callable = None # (str) -- a label for the live capture
|
||||
on_cycle: callable = None # (cycle_number)
|
||||
on_status: callable = None # (str)
|
||||
on_error: callable = None # (Exception)
|
||||
|
|
@ -136,11 +138,14 @@ class Scanner:
|
|||
with self._lock:
|
||||
self._lockouts.append(entry.interval(self.cfg.lockout_width))
|
||||
self.cfg.lockout.append(entry)
|
||||
if self.cfg.save_lockouts:
|
||||
path = remember_lockouts(self.cfg)
|
||||
self._status(f"locked out {entry.describe()}"
|
||||
+ (f", remembered in {path.name}" if path else
|
||||
" (could not be saved)"))
|
||||
|
||||
path = remember_lockouts(self.cfg) if self.cfg.save_lockouts else None
|
||||
# Announced either way: the lock-out took effect whether or not it was
|
||||
# written down, and saying nothing when it was not is how an operator
|
||||
# ends up thinking the key did not work.
|
||||
self._status(f"locked out {entry.describe()}"
|
||||
+ (f", remembered in {path.name}" if path
|
||||
else " for this run"))
|
||||
|
||||
def _key(self, freq_hz: float) -> int:
|
||||
return int(round(freq_hz / self._bucket))
|
||||
|
|
@ -628,8 +633,11 @@ class Scanner:
|
|||
last_signal_at = 0.0
|
||||
last_content_at = 0.0
|
||||
ever_signal = False
|
||||
was_present = False
|
||||
signal_since = 0.0 # start of the current unbroken run
|
||||
content_ever = False
|
||||
checks_done = 0
|
||||
continuous_for = 0.0
|
||||
peak_snr = det.snr_db
|
||||
stop_reason = "unknown"
|
||||
next_check = cfg.verify_seconds
|
||||
|
|
@ -668,9 +676,16 @@ class Scanner:
|
|||
snr = self._band_snr(x, bw)
|
||||
present = snr >= (squelch_off if ever_signal else squelch_on)
|
||||
if present:
|
||||
if not was_present:
|
||||
signal_since = elapsed - x.size / fs
|
||||
last_signal_at = elapsed
|
||||
ever_signal = True
|
||||
peak_snr = max(peak_snr, snr)
|
||||
was_present = present
|
||||
# How long the carrier has been up without a break. A
|
||||
# trunking control channel never lets this reset; a
|
||||
# conversation on the same system always does.
|
||||
continuous_for = elapsed - signal_since if present else 0.0
|
||||
|
||||
audio, iq = demod.step(x)
|
||||
rec.write_audio(audio, present=present, at=elapsed)
|
||||
|
|
@ -685,9 +700,13 @@ class Scanner:
|
|||
|
||||
# Judge the content while it is still arriving so static and
|
||||
# interference are dropped after a second or two instead of
|
||||
# holding the receiver for the full record time.
|
||||
if (cfg.require_signal and cfg.classify and ever_signal
|
||||
and elapsed >= next_check):
|
||||
# holding the receiver for the full record time. The check
|
||||
# also runs with the content gate off, because spotting a
|
||||
# control channel is worth doing whether or not anything is
|
||||
# being thrown away for lack of content; in that case its only
|
||||
# power is to end the capture, never to reject one.
|
||||
if ((cfg.require_signal or cfg.skip_control) and cfg.classify
|
||||
and ever_signal and elapsed >= next_check):
|
||||
# Check more often once a gap has opened, so a channel
|
||||
# held open by static is released promptly instead of
|
||||
# waiting out a full verify interval.
|
||||
|
|
@ -697,10 +716,23 @@ class Scanner:
|
|||
window = max(2.0, cfg.verify_seconds)
|
||||
_, _, verdict = self._analyse(
|
||||
rec, demod, det.frequency, peak_snr,
|
||||
since=max(0.0, elapsed - window))
|
||||
if verdict is not None:
|
||||
since=max(0.0, elapsed - window),
|
||||
continuous_for=continuous_for)
|
||||
if verdict is not None and cfg.require_signal:
|
||||
checks_done += 1
|
||||
if verdict.accept:
|
||||
if verdict is not None:
|
||||
if verdict.category == "trunk":
|
||||
# A positive identification, not a failure to find
|
||||
# content: there is nothing to hear on a control
|
||||
# channel however long we wait, so leave now
|
||||
# rather than serving out verify_max_seconds.
|
||||
rejected_early = verdict
|
||||
stop_reason = verdict.reason
|
||||
self._note_control(det.frequency, verdict)
|
||||
break
|
||||
if not cfg.require_signal:
|
||||
pass # the gate is off; nothing to judge
|
||||
elif verdict.accept:
|
||||
content_ever = True
|
||||
last_content_at = elapsed
|
||||
elif not content_ever:
|
||||
|
|
@ -806,6 +838,13 @@ class Scanner:
|
|||
hit.content_reason = rejected_early.reason
|
||||
hit.noise_likeness = round(rejected_early.noise_likeness, 3)
|
||||
hit.voice_score = round(rejected_early.voice.score, 3)
|
||||
control = rejected_early.control
|
||||
if control is not None:
|
||||
hit.classification = control.describe()
|
||||
hit.family = "control"
|
||||
hit.baud = control.baud
|
||||
hit.confidence = control.confidence
|
||||
hit.reasons = [control.reason]
|
||||
elif not ever_signal:
|
||||
hit.category = "vanished"
|
||||
hit.stop_reason = "false trigger (no signal after retuning)"
|
||||
|
|
@ -814,7 +853,15 @@ class Scanner:
|
|||
self._reject(rec, hit)
|
||||
return hit
|
||||
|
||||
verdict = self._identify(rec, hit, demod)
|
||||
verdict = self._identify(rec, hit, demod, continuous_for=continuous_for)
|
||||
if verdict is not None and verdict.category == "trunk":
|
||||
# Recognised only at the end -- a short capture, or one that ran
|
||||
# before the first content check. Same outcome, said the same way.
|
||||
hit.kept = False
|
||||
hit.stop_reason = verdict.reason
|
||||
self._note_control(det.frequency, verdict)
|
||||
self._reject(rec, hit)
|
||||
return hit
|
||||
if cfg.require_signal and verdict is not None and not verdict.accept:
|
||||
hit.kept = False
|
||||
hit.stop_reason = f"no signal content: {verdict.reason}"
|
||||
|
|
@ -862,6 +909,27 @@ class Scanner:
|
|||
pass
|
||||
return hit
|
||||
|
||||
def _note_control(self, freq_hz: float, verdict) -> None:
|
||||
"""Announce a trunking control channel, and optionally lock it out.
|
||||
|
||||
Said out loud every time rather than once: an operator watching a
|
||||
band wants to know *which* frequencies are control channels, and
|
||||
there is usually more than one.
|
||||
"""
|
||||
control = getattr(verdict, "control", None)
|
||||
name = control.system if control is not None else "trunked system"
|
||||
baud = f", {control.baud:.0f} baud" if control is not None else ""
|
||||
self.stats.control_channels[round(freq_hz)] = name
|
||||
self._status(f"TRUNK {fmt_hz(freq_hz)} -- {name} control channel"
|
||||
f"{baud} -- skipping")
|
||||
if self.cb.on_record_note:
|
||||
try:
|
||||
self.cb.on_record_note(f"TRUNK: {name}")
|
||||
except Exception:
|
||||
pass
|
||||
if self.cfg.lockout_control and not self._is_locked(freq_hz):
|
||||
self.lockout(freq_hz)
|
||||
|
||||
def _reject(self, rec: Recording, hit: HitRecord) -> None:
|
||||
"""Throw away a capture and everything it wrote."""
|
||||
rec.discard()
|
||||
|
|
@ -875,7 +943,8 @@ class Scanner:
|
|||
pass
|
||||
|
||||
def _analyse(self, rec: Recording, demod, freq_hz: float, snr_db: float,
|
||||
final: bool = False, since: float | None = None):
|
||||
final: bool = False, since: float | None = None,
|
||||
continuous_for: float = 0.0):
|
||||
"""Classify what has been captured so far and judge whether to keep it.
|
||||
|
||||
Returns ``(classification, morse, assessment)``. Called periodically
|
||||
|
|
@ -889,7 +958,9 @@ class Scanner:
|
|||
if iq.size < 2048:
|
||||
return None, None, None
|
||||
try:
|
||||
cls = classify(iq, demod.if_rate, freq_hz=freq_hz, snr_db=snr_db)
|
||||
cls = classify(iq, demod.if_rate, freq_hz=freq_hz, snr_db=snr_db,
|
||||
continuous_for=continuous_for,
|
||||
control_seconds=self.cfg.control_seconds)
|
||||
except Exception as exc:
|
||||
self._error(exc)
|
||||
return None, None, None
|
||||
|
|
@ -916,7 +987,8 @@ class Scanner:
|
|||
return assess(cls, audio, audio_rate, morse=morse, freq_hz=freq_hz,
|
||||
min_voice=cfg.min_voice_score,
|
||||
accept=tuple(cfg.accept),
|
||||
min_score=cfg.min_signal_score)
|
||||
min_score=cfg.min_signal_score,
|
||||
skip_control=cfg.skip_control)
|
||||
|
||||
def _decode_cw(self, iq: np.ndarray, demod):
|
||||
"""Run a dedicated CW detector over the captured IQ.
|
||||
|
|
@ -971,12 +1043,13 @@ class Scanner:
|
|||
meta_path=Path(hit.meta_path) if hit.meta_path else None)
|
||||
|
||||
def _identify(self, rec: Recording, hit: HitRecord, demod,
|
||||
analysis=None) -> Assessment | None:
|
||||
analysis=None, continuous_for: float = 0.0) -> Assessment | None:
|
||||
"""Attach the classification, Morse text and content verdict to a hit."""
|
||||
if not self.cfg.classify:
|
||||
return None
|
||||
cls, morse, verdict = analysis if analysis is not None else \
|
||||
self._analyse(rec, demod, hit.frequency, hit.snr_db, final=True)
|
||||
self._analyse(rec, demod, hit.frequency, hit.snr_db, final=True,
|
||||
continuous_for=continuous_for)
|
||||
if cls is None:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ _TABLE: tuple[Setting, ...] = (
|
|||
"voice - speech structure in the demodulated audio. "
|
||||
"cw - a keyed carrier whose timing resolves as Morse. "
|
||||
"digital - an identified keying scheme with a steady symbol rate. "
|
||||
"trunk - a trunked system's control channel. "
|
||||
"carrier - a steady unmodulated carrier. "
|
||||
"noise - everything else.",
|
||||
choices=CONTENT_CATEGORIES, flags=("--accept",), metavar="LIST",
|
||||
|
|
@ -200,6 +201,29 @@ _TABLE: tuple[Setting, ...] = (
|
|||
"conversation short.",
|
||||
unit="s", minimum=0.5, flags=("--verify-max",), metavar="SEC"),
|
||||
|
||||
# -- trunking -------------------------------------------------------------
|
||||
S("skip_control", "Skip trunk control channels", "Trunking", "bool",
|
||||
"spot a trunked system's control channel and move straight on",
|
||||
"A trunked radio system keeps one channel transmitting data day and "
|
||||
"night so the radios know where each conversation has been put. There "
|
||||
"is no speech on it and it never stops.",
|
||||
flags=("--skip-control",),
|
||||
off_flags=("--keep-control", "--no-skip-control")),
|
||||
S("control_seconds", "Control channel patience", "Trunking", "float",
|
||||
"unbroken carrier a digital-voice look-alike must hold",
|
||||
"P25, DMR and NXDN control channels have the same shape as a digital "
|
||||
"voice call on the same system. A carrier still running unbroken after "
|
||||
"this long is a control channel, because a conversation would have "
|
||||
"paused by now.",
|
||||
unit="s", minimum=1.0, flags=("--control-seconds",), metavar="SEC"),
|
||||
S("lockout_control", "Lock out control channels", "Trunking", "bool",
|
||||
"add every control channel found to the lock-out list",
|
||||
"Off by default: a control channel is cheap to recognise and skip, so "
|
||||
"there is no need to write it down. Turn it on to stop revisiting it "
|
||||
"at all.",
|
||||
flags=("--lockout-control",),
|
||||
off_flags=("--no-lockout-control",)),
|
||||
|
||||
# -- receiver -------------------------------------------------------------
|
||||
S("device_index", "Device index", "Receiver", "int",
|
||||
"which dongle to use when more than one is attached",
|
||||
|
|
@ -515,10 +539,40 @@ _GUIDANCE: dict[str, str] = {
|
|||
"however empty.",
|
||||
"accept":
|
||||
"Which kinds of transmission are worth keeping: speech, Morse, "
|
||||
"data, plain unmodulated carriers, and noise. Most people want "
|
||||
"voice, cw and digital. Add carrier if you are hunting beacons or "
|
||||
"interference sources, and noise only for diagnosing why nothing is "
|
||||
"being recorded.",
|
||||
"data, trunking control channels, plain unmodulated carriers, and "
|
||||
"noise. Most people want voice, cw and digital. Add carrier if you "
|
||||
"are hunting beacons or interference sources, trunk if you are "
|
||||
"collecting control channels for a decoder, and noise only for "
|
||||
"diagnosing why nothing is being recorded.",
|
||||
"skip_control":
|
||||
"Trunked radio systems -- the kind police, fire and large businesses "
|
||||
"use -- keep one frequency transmitting a data stream around the "
|
||||
"clock. It tells the radios which channel to jump to for each "
|
||||
"conversation; it carries no speech and it never goes quiet. To a "
|
||||
"scanner it looks like a very strong, very interesting signal, so "
|
||||
"without this setting the receiver parks on it, records the whole "
|
||||
"record limit of buzzing, and finds it again on the next sweep. With "
|
||||
"this on it is recognised within a second or two, named on screen, "
|
||||
"and skipped. Leave it on unless you are deliberately collecting "
|
||||
"control channel data to feed to a decoder.",
|
||||
"control_seconds":
|
||||
"Only matters for P25, DMR and NXDN. On those systems the control "
|
||||
"channel and an ordinary digital conversation look identical for the "
|
||||
"first few seconds, and the only thing that tells them apart is that "
|
||||
"the conversation eventually pauses and the control channel never "
|
||||
"does. This is how long a signal has to keep going without a break "
|
||||
"before it is judged to be a control channel. Raise it if digital "
|
||||
"voice calls are being skipped by mistake; lower it if you are tired "
|
||||
"of waiting out control channels. It has no effect on the older "
|
||||
"Motorola and EDACS systems, which are recognised immediately from "
|
||||
"their symbol rate.",
|
||||
"lockout_control":
|
||||
"Write each control channel into the lock-out list as it is found, "
|
||||
"so the scanner stops even looking at it. Skipping one already costs "
|
||||
"only a second or two, so this is worth turning on mainly if you "
|
||||
"scan the same band constantly and want the list built for you. With "
|
||||
"'Remember lock-outs' also on, the entries are saved to your "
|
||||
"settings file and survive a restart.",
|
||||
"min_signal_score":
|
||||
"How certain the content check must be before a recording is kept, "
|
||||
"from 0 to 1. Lower it if real transmissions are being discarded, "
|
||||
|
|
@ -930,6 +984,7 @@ _ARG_GROUP_TITLES = {
|
|||
"Dwell and recording": "dwell behaviour",
|
||||
"Detection": "detection",
|
||||
"What counts as a signal": "what counts as a signal",
|
||||
"Trunking": "trunked systems",
|
||||
"Receiver": "receiver",
|
||||
"Output": "output",
|
||||
"Run control": "run control",
|
||||
|
|
|
|||
|
|
@ -177,8 +177,7 @@ class VirtualTransmitter:
|
|||
out = env * np.exp(1j * self._advance(np.zeros(n), fs))
|
||||
elif m in ("fsk2", "fsk4"):
|
||||
levels = 2 if m == "fsk2" else 4
|
||||
sym = self._symbols(t, fs, levels, self.baud)
|
||||
lv = (sym - (levels - 1) / 2.0) / max(1.0, (levels - 1) / 2.0)
|
||||
lv = self._shaped_levels(t, fs, levels, self.baud)
|
||||
out = np.exp(1j * self._advance(lv * self.deviation, fs))
|
||||
elif m == "psk":
|
||||
sym = self._symbols(t, fs, 4, self.baud)
|
||||
|
|
@ -233,10 +232,49 @@ class VirtualTransmitter:
|
|||
def _symbols(self, t: np.ndarray, fs: float, levels: int,
|
||||
baud: float) -> np.ndarray:
|
||||
"""Deterministic pseudo-random symbols on an absolute-time grid."""
|
||||
idx = np.floor(t * baud).astype(np.int64)
|
||||
# A cheap hash keeps the stream identical across block boundaries.
|
||||
h = (idx * 2654435761) % 4294967296
|
||||
return (h % levels).astype(np.float64)
|
||||
idx = np.floor(t * baud).astype(np.uint64)
|
||||
# An avalanche mix, not a plain multiply. Multiplying the symbol
|
||||
# index by an odd constant and taking it modulo the number of levels
|
||||
# returns the low bits of a counter -- 0,1,0,1 for two levels -- so
|
||||
# what came out was a tone at half the symbol rate rather than data.
|
||||
# Every FSK feature measured from it was the feature of a square wave.
|
||||
z = idx * np.uint64(0x9E3779B97F4A7C15)
|
||||
z ^= z >> np.uint64(30)
|
||||
z *= np.uint64(0xBF58476D1CE4E5B9)
|
||||
z ^= z >> np.uint64(27)
|
||||
z *= np.uint64(0x94D049BB133111EB)
|
||||
z ^= z >> np.uint64(31)
|
||||
return (z % np.uint64(levels)).astype(np.float64)
|
||||
|
||||
def _shaped_levels(self, t: np.ndarray, fs: float, levels: int,
|
||||
baud: float) -> np.ndarray:
|
||||
"""Symbol levels in [-1, 1] after a transmit filter.
|
||||
|
||||
Every real FSK transmitter shapes the symbol stream before it reaches
|
||||
the modulator -- GFSK, C4FM and raised cosine all do the same job, and
|
||||
an unshaped stream would be far too wide to licence. It matters here
|
||||
because square-edged NRZ has a spectrum full of odd harmonics of the
|
||||
symbol rate, and a cyclostationary baud estimator locks onto whichever
|
||||
of those is strongest: 3600 baud came back as 18000. That is not a
|
||||
fault in the estimator. It is a signal no transmitter would radiate.
|
||||
|
||||
The filter is applied over absolute time with symbols either side of
|
||||
the block, so the stream stays continuous across block boundaries.
|
||||
"""
|
||||
sps = max(2.0, fs / max(1.0, baud))
|
||||
std = 0.15 * sps # roughly BT = 0.5
|
||||
half = int(math.ceil(3.0 * std))
|
||||
n = t.size
|
||||
te = t[0] + np.arange(-half, n + half) / fs
|
||||
sym = self._symbols(te, fs, levels, baud)
|
||||
# Divided by the half-span of the level grid, so the outer symbols
|
||||
# land on +/- the requested deviation whatever the level count. The
|
||||
# old form clamped that divisor at 1.0, which quietly halved the
|
||||
# deviation of every two-level signal the simulator produced.
|
||||
lv = (sym - (levels - 1) / 2.0) / ((levels - 1) / 2.0)
|
||||
taps = np.exp(-0.5 * (np.arange(-half, half + 1) / std) ** 2)
|
||||
taps /= taps.sum()
|
||||
return np.convolve(lv, taps, mode="same")[half:half + n]
|
||||
|
||||
def _morse_envelope(self, t: np.ndarray, fs: float) -> np.ndarray:
|
||||
key = self._morse_key(self.message, self.wpm)
|
||||
|
|
@ -293,6 +331,10 @@ def default_transmitters() -> list[VirtualTransmitter]:
|
|||
V(446_000_000, "carrier", 0.25, 1_000, "unmodulated carrier"),
|
||||
V(433_920_000, "ook", 0.30, 40_000, "ISM remote", baud=2000,
|
||||
period_seconds=9, on_seconds=1.2),
|
||||
# Always on, because that is the whole character of a control channel
|
||||
# and the reason it needs recognising rather than recording.
|
||||
V(856_562_500, "fsk2", 0.42, 12_500, "SMARTNET control channel",
|
||||
baud=3600, deviation=2_600),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -548,7 +548,18 @@ needed; most dongles use the Q branch.
|
|||
|
||||
There is no filtering or gain in front of the digitiser in this mode, so an HF
|
||||
antenna and a quiet location matter more than usual."""),
|
||||
"8": ("Keys during a scan", """
|
||||
"8": ("Trunked systems", """
|
||||
Police, fire and large business radio mostly runs on trunked systems, where a
|
||||
pool of channels is shared and one frequency is given over entirely to a data
|
||||
stream saying which channel each conversation has been put on. That is the
|
||||
control channel: loud, perfectly steady, never silent, and with nothing on it
|
||||
to hear.
|
||||
|
||||
It is recognised by its symbol rate and its refusal to pause, named on screen,
|
||||
and skipped within a second or two. Turn 'Skip trunk control channels' off
|
||||
only if you are collecting them for a decoder. If digital voice calls are
|
||||
being skipped by mistake, raise 'Control channel patience'."""),
|
||||
"9": ("Keys during a scan", """
|
||||
q stop the scan
|
||||
p pause and resume
|
||||
s skip the signal being recorded and carry on sweeping
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ class _RecState:
|
|||
present: bool = False
|
||||
quiet_for: float = 0.0
|
||||
active: bool = False
|
||||
note: str = "" # e.g. "TRUNK: Motorola SMARTNET / SmartZone"
|
||||
|
||||
|
||||
class ScanDisplay:
|
||||
|
|
@ -124,6 +125,7 @@ class ScanDisplay:
|
|||
cb.on_record_start = self.on_record_start
|
||||
cb.on_record_tick = self.on_record_tick
|
||||
cb.on_record_end = self.on_record_end
|
||||
cb.on_record_note = self.on_record_note
|
||||
cb.on_status = self.on_status
|
||||
cb.on_error = self.on_error
|
||||
|
||||
|
|
@ -154,6 +156,10 @@ class ScanDisplay:
|
|||
self._rec.mode = rec.mode
|
||||
self._dirty = True
|
||||
|
||||
def on_record_note(self, note: str):
|
||||
self._rec.note = note
|
||||
self._dirty = True
|
||||
|
||||
def on_record_end(self, hit: HitRecord):
|
||||
self._rec.active = False
|
||||
if hit.kept:
|
||||
|
|
@ -236,6 +242,15 @@ class ScanDisplay:
|
|||
# being held open across a pause rather than stuck.
|
||||
sq = f"[yellow]gap {min(r.quiet_for, hang):4.1f}/{hang:g}s[/yellow]"
|
||||
limit_s = f"/{limit:g}s" if limit else ""
|
||||
if r.note:
|
||||
# A control channel is not being recorded so much as identified
|
||||
# and abandoned; saying "REC" while that happens is a lie.
|
||||
return Panel(
|
||||
Text.from_markup(
|
||||
f"[bold black on yellow] {r.note} [/bold black on yellow] "
|
||||
f"{fmt_hz(r.frequency)} [{r.mode}] "
|
||||
f"SNR {r.snr:5.1f} dB [yellow]skipping[/yellow]"),
|
||||
border_style="yellow", padding=(0, 1))
|
||||
return Panel(
|
||||
Text.from_markup(
|
||||
f"[bold red]REC[/bold red] {fmt_hz(r.frequency)} "
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
||||
.TH BANDSAUNTER 1 "2026-08-22" "bandsaunter 2026-08-22_01" "User Commands"
|
||||
.TH BANDSAUNTER 1 "2026-08-22" "bandsaunter 2026-08-22_02" "User Commands"
|
||||
.SH NAME
|
||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||
.SH SYNOPSIS
|
||||
|
|
@ -293,10 +293,10 @@ Record these \[em] which kinds of content are worth keeping.
|
|||
.br
|
||||
Setting name \fBaccept\fR, default \fBvoice, cw, digital\fR.
|
||||
.br
|
||||
Accepts: one of: voice, cw, digital, carrier, noise.
|
||||
Accepts: one of: voice, cw, digital, trunk, carrier, noise.
|
||||
.RS
|
||||
.PP
|
||||
Which kinds of transmission are worth keeping: speech, Morse, data, plain unmodulated carriers, and noise. Most people want voice, cw and digital. Add carrier if you are hunting beacons or interference sources, and noise only for diagnosing why nothing is being recorded.
|
||||
Which kinds of transmission are worth keeping: speech, Morse, data, trunking control channels, plain unmodulated carriers, and noise. Most people want voice, cw and digital. Add carrier if you are hunting beacons or interference sources, trunk if you are collecting control channels for a decoder, and noise only for diagnosing why nothing is being recorded.
|
||||
.RE
|
||||
.TP
|
||||
.B --min-signal-score
|
||||
|
|
@ -343,6 +343,37 @@ Accepts: at least 0.5.
|
|||
How long a recording gets to prove it contains something. If nothing recognisable has appeared by then it is abandoned and deleted, and the sweep moves on rather than sitting on an open but empty channel.
|
||||
.RE
|
||||
.PP
|
||||
.SS Trunking
|
||||
.TP
|
||||
.B --skip-control / --keep-control --no-skip-control
|
||||
Skip trunk control channels \[em] spot a trunked system's control channel and move straight on.
|
||||
.br
|
||||
Setting name \fBskip_control\fR, default \fByes\fR.
|
||||
.RS
|
||||
.PP
|
||||
Trunked radio systems -- the kind police, fire and large businesses use -- keep one frequency transmitting a data stream around the clock. It tells the radios which channel to jump to for each conversation; it carries no speech and it never goes quiet. To a scanner it looks like a very strong, very interesting signal, so without this setting the receiver parks on it, records the whole record limit of buzzing, and finds it again on the next sweep. With this on it is recognised within a second or two, named on screen, and skipped. Leave it on unless you are deliberately collecting control channel data to feed to a decoder.
|
||||
.RE
|
||||
.TP
|
||||
.B --control-seconds
|
||||
Control channel patience \[em] unbroken carrier a digital-voice look-alike must hold (s).
|
||||
.br
|
||||
Setting name \fBcontrol_seconds\fR, default \fB20 s\fR.
|
||||
.br
|
||||
Accepts: at least 1.
|
||||
.RS
|
||||
.PP
|
||||
Only matters for P25, DMR and NXDN. On those systems the control channel and an ordinary digital conversation look identical for the first few seconds, and the only thing that tells them apart is that the conversation eventually pauses and the control channel never does. This is how long a signal has to keep going without a break before it is judged to be a control channel. Raise it if digital voice calls are being skipped by mistake; lower it if you are tired of waiting out control channels. It has no effect on the older Motorola and EDACS systems, which are recognised immediately from their symbol rate.
|
||||
.RE
|
||||
.TP
|
||||
.B --lockout-control / --no-lockout-control
|
||||
Lock out control channels \[em] add every control channel found to the lock-out list.
|
||||
.br
|
||||
Setting name \fBlockout_control\fR, default \fBno\fR.
|
||||
.RS
|
||||
.PP
|
||||
Write each control channel into the lock-out list as it is found, so the scanner stops even looking at it. Skipping one already costs only a second or two, so this is worth turning on mainly if you scan the same band constantly and want the list built for you. With 'Remember lock-outs' also on, the entries are saved to your settings file and survive a restart.
|
||||
.RE
|
||||
.PP
|
||||
.SS Receiver
|
||||
.TP
|
||||
.B -d --device
|
||||
|
|
@ -755,6 +786,14 @@ bandsaunter scan \-r 144M\-148M \-\-lockout "162.55M, 450M\-455M"
|
|||
A single frequency is widened by
|
||||
.BR \-\-lockout\-width ;
|
||||
a span is used exactly as written.
|
||||
.PP
|
||||
Two runs never write anything back.
|
||||
.B \-\-no\-config
|
||||
has no settings file to write to, since the point of it is to leave the saved
|
||||
settings alone; and
|
||||
.B \-\-simulate
|
||||
is looking at an invented band, whose frequencies would be nonsense in a real
|
||||
settings file. Both still lock out for the run in hand, and say so.
|
||||
.SH KEYS DURING A SCAN
|
||||
.TP
|
||||
.B q
|
||||
|
|
@ -784,6 +823,60 @@ every transmission on one frequency is appended to a single growing file for
|
|||
that frequency, with a spoken date and time before each one, so a scan can be
|
||||
played back as a recording of that channel rather than clicked through as
|
||||
hundreds of fragments.
|
||||
.SH TRUNKED SYSTEMS
|
||||
Police, fire and large business radio in the US mostly runs on
|
||||
.IR trunked
|
||||
systems. Instead of giving each department its own frequency, the system owns
|
||||
a pool of channels and hands one out for each conversation as it happens. To
|
||||
make that work, one frequency in the pool is given over entirely to a data
|
||||
stream that runs day and night, telling every radio in the fleet where to go
|
||||
next. That frequency is the
|
||||
.IR "control channel" .
|
||||
.PP
|
||||
A control channel is the worst thing a scanner can find. It is loud, it is
|
||||
perfectly steady, it never stops, and there is nothing on it to listen to \[em]
|
||||
just a harsh buzz. A scanner without special handling parks on it for the
|
||||
whole record limit, saves the file, and then finds it again on the next sweep,
|
||||
for as long as it is left running.
|
||||
.PP
|
||||
bandsaunter recognises one from the shape of the signal, names the system on
|
||||
screen, deletes what it captured and moves on, usually within a second or
|
||||
two. What it looks for is a constant\-envelope data stream that never pauses,
|
||||
at a symbol rate belonging to a known trunking standard:
|
||||
.RS
|
||||
.PP
|
||||
3600 baud two\-level \[em] Motorola SMARTNET / SmartZone (Type I and II).
|
||||
.br
|
||||
9600 baud two\-level \[em] EDACS and ProVoice.
|
||||
.br
|
||||
1200 baud two\-level \[em] MPT\-1327.
|
||||
.br
|
||||
4800 baud four\-level \[em] P25 or DMR Tier III.
|
||||
.br
|
||||
2400 baud four\-level \[em] NXDN and NEXEDGE.
|
||||
.RE
|
||||
.PP
|
||||
The first two are recognised at once: nothing else transmits at those rates
|
||||
without pausing. The others share their shape with an ordinary digital voice
|
||||
call on the same system, so they are only called a control channel once the
|
||||
carrier has run unbroken for
|
||||
.B \-\-control\-seconds
|
||||
(20 s by default) \[em] long enough that a real conversation would have taken
|
||||
a breath. Raise that figure if digital voice calls are being skipped by
|
||||
mistake.
|
||||
.PP
|
||||
Being inside a band where trunking is common raises confidence but is never
|
||||
required: trunking is licensed on business pairs all over the spectrum.
|
||||
.PP
|
||||
Use
|
||||
.B \-\-keep\-control
|
||||
to record control channels anyway, which is what you want if you are feeding
|
||||
them to a decoder. Use
|
||||
.B \-\-lockout\-control
|
||||
to have each one written into the lock\-out list as it is found, so the
|
||||
scanner stops looking at it at all; with
|
||||
.B \-\-save\-lockouts
|
||||
on, that list survives a restart.
|
||||
.SH HF RECEPTION
|
||||
These receivers cannot normally tune below about 24 MHz. Below that they can
|
||||
sample the antenna directly instead, which opens up shortwave: broadcast,
|
||||
|
|
|
|||
|
|
@ -237,6 +237,14 @@ bandsaunter scan \-r 144M\-148M \-\-lockout "162.55M, 450M\-455M"
|
|||
A single frequency is widened by
|
||||
.BR \-\-lockout\-width ;
|
||||
a span is used exactly as written.
|
||||
.PP
|
||||
Two runs never write anything back.
|
||||
.B \-\-no\-config
|
||||
has no settings file to write to, since the point of it is to leave the saved
|
||||
settings alone; and
|
||||
.B \-\-simulate
|
||||
is looking at an invented band, whose frequencies would be nonsense in a real
|
||||
settings file. Both still lock out for the run in hand, and say so.
|
||||
.SH KEYS DURING A SCAN
|
||||
.TP
|
||||
.B q
|
||||
|
|
@ -266,6 +274,60 @@ every transmission on one frequency is appended to a single growing file for
|
|||
that frequency, with a spoken date and time before each one, so a scan can be
|
||||
played back as a recording of that channel rather than clicked through as
|
||||
hundreds of fragments.
|
||||
.SH TRUNKED SYSTEMS
|
||||
Police, fire and large business radio in the US mostly runs on
|
||||
.IR trunked
|
||||
systems. Instead of giving each department its own frequency, the system owns
|
||||
a pool of channels and hands one out for each conversation as it happens. To
|
||||
make that work, one frequency in the pool is given over entirely to a data
|
||||
stream that runs day and night, telling every radio in the fleet where to go
|
||||
next. That frequency is the
|
||||
.IR "control channel" .
|
||||
.PP
|
||||
A control channel is the worst thing a scanner can find. It is loud, it is
|
||||
perfectly steady, it never stops, and there is nothing on it to listen to \[em]
|
||||
just a harsh buzz. A scanner without special handling parks on it for the
|
||||
whole record limit, saves the file, and then finds it again on the next sweep,
|
||||
for as long as it is left running.
|
||||
.PP
|
||||
bandsaunter recognises one from the shape of the signal, names the system on
|
||||
screen, deletes what it captured and moves on, usually within a second or
|
||||
two. What it looks for is a constant\-envelope data stream that never pauses,
|
||||
at a symbol rate belonging to a known trunking standard:
|
||||
.RS
|
||||
.PP
|
||||
3600 baud two\-level \[em] Motorola SMARTNET / SmartZone (Type I and II).
|
||||
.br
|
||||
9600 baud two\-level \[em] EDACS and ProVoice.
|
||||
.br
|
||||
1200 baud two\-level \[em] MPT\-1327.
|
||||
.br
|
||||
4800 baud four\-level \[em] P25 or DMR Tier III.
|
||||
.br
|
||||
2400 baud four\-level \[em] NXDN and NEXEDGE.
|
||||
.RE
|
||||
.PP
|
||||
The first two are recognised at once: nothing else transmits at those rates
|
||||
without pausing. The others share their shape with an ordinary digital voice
|
||||
call on the same system, so they are only called a control channel once the
|
||||
carrier has run unbroken for
|
||||
.B \-\-control\-seconds
|
||||
(20 s by default) \[em] long enough that a real conversation would have taken
|
||||
a breath. Raise that figure if digital voice calls are being skipped by
|
||||
mistake.
|
||||
.PP
|
||||
Being inside a band where trunking is common raises confidence but is never
|
||||
required: trunking is licensed on business pairs all over the spectrum.
|
||||
.PP
|
||||
Use
|
||||
.B \-\-keep\-control
|
||||
to record control channels anyway, which is what you want if you are feeding
|
||||
them to a decoder. Use
|
||||
.B \-\-lockout\-control
|
||||
to have each one written into the lock\-out list as it is found, so the
|
||||
scanner stops looking at it at all; with
|
||||
.B \-\-save\-lockouts
|
||||
on, that list survives a restart.
|
||||
.SH HF RECEPTION
|
||||
These receivers cannot normally tune below about 24 MHz. Below that they can
|
||||
sample the antenna directly instead, which opens up shortwave: broadcast,
|
||||
|
|
|
|||
303
tests/test_trunk.py
Normal file
303
tests/test_trunk.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""Trunking control channels: recognising them, and refusing to sit on them.
|
||||
|
||||
A trunked system keeps one frequency transmitting a data stream around the
|
||||
clock so that every radio in the fleet knows which channel each conversation
|
||||
has been assigned to. There is no speech on it and it never stops, which
|
||||
makes it both the strongest and the most useless signal in the band: a scanner
|
||||
that treats it as content parks on it for the full record limit, then finds it
|
||||
again on the very next sweep.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter.classify import (SignalFeatures, classify, trunk_control,
|
||||
CONTROL_SIGNATURES)
|
||||
from bandsaunter.config import ScanConfig
|
||||
from bandsaunter.quality import CATEGORIES, assess
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
from bandsaunter.scanner import Scanner, ScannerCallbacks
|
||||
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
|
||||
|
||||
|
||||
FS = 96_000.0
|
||||
|
||||
|
||||
def features(**over) -> SignalFeatures:
|
||||
"""A control channel as the feature extractor sees one.
|
||||
|
||||
The numbers are those measured from a real 856.561 MHz SMARTNET capture,
|
||||
so a change that stops this being recognised has broken the case the
|
||||
detector was written for.
|
||||
"""
|
||||
f = SignalFeatures(sample_rate=32_000.0, n_samples=320_000, duration=10.0)
|
||||
f.bandwidth = 7_437.5
|
||||
f.env_cv = 0.0268
|
||||
f.ook_contrast_db = 5.411
|
||||
f.duty_cycle = 0.898
|
||||
f.freq_modes = 2
|
||||
f.mode_spacing = 5_326.7
|
||||
f.level_dwell = 0.5244
|
||||
f.baud = 3_600.1
|
||||
f.baud_stability = 1.0
|
||||
f.baud_strength = 51.7
|
||||
f.snr_db = 38.58
|
||||
for k, v in over.items():
|
||||
setattr(f, k, v)
|
||||
return f
|
||||
|
||||
|
||||
def fsk(baud, levels, seconds=2.0, fs=FS, deviation=2_600.0, gaps=False):
|
||||
"""Continuous multi-level FSK -- the shape every control channel has.
|
||||
|
||||
Built from the simulator's own transmitter so the transmit shaping is the
|
||||
same as everywhere else: an unshaped square-edged stream is a signal no
|
||||
licensed radio would radiate, and its harmonics fool any symbol-rate
|
||||
estimator.
|
||||
"""
|
||||
mode = "fsk2" if levels == 2 else "fsk4"
|
||||
tx = V(100e6, mode, 1.0, 12_500.0, f"ctl{baud:.0f}x{levels}",
|
||||
baud=float(baud), deviation=deviation)
|
||||
x = tx.generate(0.0, int(seconds * fs), fs)
|
||||
if gaps: # key it on and off, as a voice call would
|
||||
env = np.ones(x.size)
|
||||
for i in range(0, x.size, int(fs * 0.5)):
|
||||
env[i:i + int(fs * 0.2)] = 0.0
|
||||
x = (x * env).astype(np.complex64)
|
||||
return x
|
||||
|
||||
|
||||
# -- the detector on its own -------------------------------------------------
|
||||
|
||||
def test_a_real_smartnet_control_channel_is_recognised():
|
||||
c = trunk_control(features(), 856_561_096.0)
|
||||
assert c is not None
|
||||
assert "SMARTNET" in c.system
|
||||
assert c.confidence >= 0.9
|
||||
assert not c.ambiguous
|
||||
|
||||
|
||||
def test_the_name_says_control_channel_in_words():
|
||||
c = trunk_control(features(), 856_561_096.0)
|
||||
assert c.describe().endswith("trunking control channel")
|
||||
|
||||
|
||||
def test_being_in_a_trunked_band_is_corroboration_not_a_requirement():
|
||||
"""Trunking is licensed all over the spectrum, so the shape has to be
|
||||
enough on its own -- the allocation only raises confidence."""
|
||||
inband = trunk_control(features(), 856_561_096.0)
|
||||
out = trunk_control(features(), 33_000_000.0)
|
||||
assert out is not None
|
||||
assert inband.confidence > out.confidence
|
||||
|
||||
|
||||
@pytest.mark.parametrize("baud,levels,name", [
|
||||
(3_600.0, 2, "SMARTNET"), (9_600.0, 2, "EDACS"),
|
||||
])
|
||||
def test_unambiguous_symbol_rates_are_believed_at_once(baud, levels, name):
|
||||
"""Nothing but a control channel sends these without pause, so waiting to
|
||||
see whether it stops would only waste the receiver's time."""
|
||||
c = trunk_control(features(baud=baud, freq_modes=levels),
|
||||
856_500_000.0, continuous_for=0.0)
|
||||
assert c is not None and name in c.system
|
||||
|
||||
|
||||
@pytest.mark.parametrize("baud,levels", [(4_800.0, 4), (2_400.0, 4), (1_200.0, 2)])
|
||||
def test_shapes_shared_with_digital_voice_wait_for_the_carrier_to_prove_itself(
|
||||
baud, levels):
|
||||
"""P25, DMR and NXDN control channels look exactly like a call on the same
|
||||
system. Only one of the two is still transmitting a minute later."""
|
||||
f = features(baud=baud, freq_modes=levels, mode_spacing=1_800.0)
|
||||
assert trunk_control(f, 856_500_000.0, continuous_for=5.0) is None
|
||||
late = trunk_control(f, 856_500_000.0, continuous_for=30.0)
|
||||
assert late is not None and late.ambiguous
|
||||
|
||||
|
||||
def test_patience_is_adjustable():
|
||||
f = features(baud=4_800.0, freq_modes=4, mode_spacing=1_800.0)
|
||||
assert trunk_control(f, 0.0, continuous_for=8.0, min_seconds=5.0) is not None
|
||||
assert trunk_control(f, 0.0, continuous_for=8.0, min_seconds=60.0) is None
|
||||
|
||||
|
||||
def test_a_bursty_data_signal_is_not_a_control_channel():
|
||||
"""A pager page or a packet frame has the right symbol rate and the wrong
|
||||
duty cycle: it stops, and a control channel never does."""
|
||||
assert trunk_control(features(duty_cycle=0.2, ook_contrast_db=28.0),
|
||||
929_000_000.0) is None
|
||||
|
||||
|
||||
def test_an_off_rate_data_stream_is_left_alone():
|
||||
assert trunk_control(features(baud=5_000.0), 856_500_000.0) is None
|
||||
|
||||
|
||||
def test_a_wandering_symbol_rate_is_not_trusted():
|
||||
"""The cyclostationary estimator always returns *some* peak; a control
|
||||
channel's does not move."""
|
||||
assert trunk_control(features(baud_stability=0.3), 856_500_000.0) is None
|
||||
|
||||
|
||||
def test_voice_never_looks_like_one():
|
||||
"""Speech swings the envelope around between syllables."""
|
||||
assert trunk_control(features(env_cv=0.55), 856_500_000.0) is None
|
||||
|
||||
|
||||
def test_every_signature_is_reachable():
|
||||
"""A typo in the table would silently disable one system for ever."""
|
||||
for baud, levels, name, conf, ambiguous in CONTROL_SIGNATURES:
|
||||
f = features(baud=baud, freq_modes=levels, mode_spacing=1_800.0)
|
||||
c = trunk_control(f, 0.0, continuous_for=999.0)
|
||||
assert c is not None and c.system == name, name
|
||||
|
||||
|
||||
# -- through the classifier --------------------------------------------------
|
||||
|
||||
def test_classify_names_the_control_channel_rather_than_the_modulation():
|
||||
""""2-FSK" is true and useless; the point of spotting one is to say so."""
|
||||
cls = classify(fsk(3_600.0, 2), FS, freq_hz=856_561_096.0, snr_db=40.0)
|
||||
assert cls.control is not None
|
||||
assert "control channel" in cls.label.lower()
|
||||
assert cls.reasons[0].startswith("unbroken 3600 baud")
|
||||
|
||||
|
||||
def test_a_keyed_digital_voice_call_is_not_called_a_control_channel():
|
||||
cls = classify(fsk(4_800.0, 4, gaps=True), FS, freq_hz=856_500_000.0,
|
||||
snr_db=40.0)
|
||||
assert cls.control is None
|
||||
|
||||
|
||||
# -- through the content gate ------------------------------------------------
|
||||
|
||||
def test_trunk_is_a_content_category_of_its_own():
|
||||
assert "trunk" in CATEGORIES
|
||||
|
||||
|
||||
def test_trunk_is_not_accepted_by_default():
|
||||
assert "trunk" not in ScanConfig().accept
|
||||
|
||||
|
||||
def test_the_verdict_calls_it_trunk_and_refuses_to_keep_it():
|
||||
cls = classify(fsk(3_600.0, 2), FS, freq_hz=856_561_096.0, snr_db=40.0)
|
||||
a = assess(cls, np.zeros(16_000), 16_000.0)
|
||||
assert a.category == "trunk"
|
||||
assert not a.accept
|
||||
assert a.control is not None
|
||||
assert "control channel" in a.reason
|
||||
|
||||
|
||||
def test_keep_control_puts_it_back_in_the_digital_pile():
|
||||
"""Someone feeding a decoder wants the control channel recorded."""
|
||||
cls = classify(fsk(3_600.0, 2), FS, freq_hz=856_561_096.0, snr_db=40.0)
|
||||
a = assess(cls, np.zeros(16_000), 16_000.0, skip_control=False)
|
||||
assert a.category == "digital"
|
||||
|
||||
|
||||
# -- end to end --------------------------------------------------------------
|
||||
|
||||
def control_channel_scan(tmp_path, **over):
|
||||
tx = [V(856_562_500, "fsk2", 0.45, 12_500, "control", baud=3_600.0,
|
||||
deviation=2_600.0)]
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("856.4M-856.7M"),
|
||||
output_dir=str(tmp_path), record_seconds=8.0,
|
||||
hang_seconds=1.0, min_record_seconds=0.3,
|
||||
threshold_db=12, dwell_seconds=0.05, max_cycles=1,
|
||||
revisit_seconds=0.2, verify_seconds=0.5)
|
||||
for k, v in over.items():
|
||||
setattr(cfg, k, v)
|
||||
hits, notes = [], []
|
||||
s = Scanner(cfg, device=dev, callbacks=ScannerCallbacks(
|
||||
on_record_end=hits.append, on_record_note=notes.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
return s, hits, notes
|
||||
|
||||
|
||||
def test_a_control_channel_is_never_saved(tmp_path):
|
||||
s, hits, _ = control_channel_scan(tmp_path)
|
||||
assert hits, "the transmitter was never found at all"
|
||||
assert not any(h.kept for h in hits)
|
||||
assert any(h.category == "trunk" for h in hits), [h.category for h in hits]
|
||||
assert list(tmp_path.glob("*.wav")) == []
|
||||
|
||||
|
||||
def test_it_is_abandoned_long_before_the_record_limit(tmp_path):
|
||||
"""The whole point: not holding the receiver on a channel with nothing to
|
||||
hear. Eight seconds were allowed; it should leave in about one."""
|
||||
s, hits, _ = control_channel_scan(tmp_path)
|
||||
trunk = [h for h in hits if h.category == "trunk"]
|
||||
assert trunk
|
||||
assert trunk[0].duration < 4.0, trunk[0].duration
|
||||
|
||||
|
||||
def test_the_display_is_told_what_it_is(tmp_path):
|
||||
s, hits, notes = control_channel_scan(tmp_path)
|
||||
assert notes, "nothing was sent to the live display"
|
||||
assert notes[0].startswith("TRUNK: ")
|
||||
assert "SMARTNET" in notes[0]
|
||||
|
||||
|
||||
def test_the_frequency_and_system_are_reported_in_the_status_line(tmp_path):
|
||||
tx = [V(856_562_500, "fsk2", 0.45, 12_500, "control", baud=3_600.0,
|
||||
deviation=2_600.0)]
|
||||
dev = SimulatedDevice(transmitters=tx).open()
|
||||
cfg = ScanConfig(ranges=parse_range_list("856.4M-856.7M"),
|
||||
output_dir=str(tmp_path), record_seconds=8.0,
|
||||
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
|
||||
max_cycles=1, verify_seconds=0.5)
|
||||
msgs = []
|
||||
s = Scanner(cfg, device=dev, callbacks=ScannerCallbacks(on_status=msgs.append))
|
||||
s.prepare()
|
||||
s.run()
|
||||
trunk = [m for m in msgs if m.startswith("TRUNK ")]
|
||||
assert trunk, msgs
|
||||
assert "856.5" in trunk[0] and "control channel" in trunk[0]
|
||||
assert s.stats.control_channels
|
||||
|
||||
|
||||
def test_keep_control_records_it_like_any_other_data(tmp_path):
|
||||
s, hits, _ = control_channel_scan(tmp_path, skip_control=False)
|
||||
kept = [h for h in hits if h.kept]
|
||||
assert kept, [(h.category, h.stop_reason) for h in hits]
|
||||
assert kept[0].category == "digital"
|
||||
|
||||
|
||||
def test_lockout_control_writes_it_down_when_asked(tmp_path):
|
||||
s, hits, _ = control_channel_scan(tmp_path, lockout_control=True,
|
||||
save_lockouts=False)
|
||||
assert s.cfg.lockout, "the control channel was not locked out"
|
||||
assert s._is_locked(856_562_500)
|
||||
|
||||
|
||||
def test_lockout_control_is_off_by_default(tmp_path):
|
||||
s, hits, _ = control_channel_scan(tmp_path)
|
||||
assert not s.cfg.lockout
|
||||
|
||||
|
||||
def test_it_is_skipped_even_with_the_content_gate_off(tmp_path):
|
||||
"""--keep-everything turns off *judging*, not spotting. A control channel
|
||||
is still recognised, and the receiver still leaves promptly."""
|
||||
s, hits, notes = control_channel_scan(tmp_path, require_signal=False)
|
||||
assert notes and notes[0].startswith("TRUNK: ")
|
||||
trunk = [h for h in hits if h.category == "trunk"]
|
||||
assert trunk and not trunk[0].kept
|
||||
assert trunk[0].duration < 4.0, trunk[0].duration
|
||||
assert list(tmp_path.glob("*.wav")) == []
|
||||
|
||||
|
||||
def test_a_simulated_lockout_never_reaches_the_real_settings(tmp_path, capsys):
|
||||
"""The demo band is invented. A lock-out taken from it must not be
|
||||
written into a settings file that a real scan will later read -- it would
|
||||
sit there for ever, skipping whatever genuine signal happened to land near
|
||||
a made-up frequency."""
|
||||
from bandsaunter.cli import main
|
||||
settings = tmp_path / "config.yaml"
|
||||
settings.write_text("record_seconds: 4.0\nsave_lockouts: true\n")
|
||||
rc = main(["scan", "--simulate", "-r", "856.4M-856.7M",
|
||||
"-o", str(tmp_path / "out"), "--plain", "--cycles", "1",
|
||||
"--record", "8", "--lockout-control",
|
||||
"--profile", str(settings)])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "locked out" in out and "for this run" in out, out
|
||||
after = settings.read_text()
|
||||
assert "lockout:" not in after, after
|
||||
assert "856" not in after, after
|
||||
Loading…
Add table
Add a link
Reference in a new issue