Part 4 of The Hunt, a 14-part deep dive into how GopherTrunk finds trunked systems you didn’t know were there. Part 3 turned a spectrum frame into a ranked list of carriers — frequencies with SNRs. A sweep of the 851–869 MHz band can surface forty of them: analog repeaters, pagers, noise, and, somewhere in the pile, our stray digital carrier. This part is the coarse triage that decides which ones are even worth the expensive protocol identify — and, for the carriers no decoder claims, whether they look encrypted.
TL;DR: Before a carrier earns a full protocol identify, a cheap blind classifier names its modulation family from three feature groups: the envelope coefficient of variation (AM vs constant-envelope), the FM discriminator statistics (deviation, impulsiveness, level structure), and a cyclostationary baud line in the rectified discriminator (the key-independent marker of digital symbols). Analog gets a CTCSS/DCS scan; a paging baud gets POCSAG/FLEX; a trunking-shaped digital carrier gets the identify. For an unidentified digital flow, an entropy triage flags “likely encrypted.” It’s a router, not a decoder — coarse on purpose.
Key takeaways
- Digital is decided by a cyclostationary baud line, not by envelope. A strong symbol-rate line in the rectified discriminator marks digital modulation and is checked before AM, because a pulse-shaped digital carrier has real envelope variation that would otherwise trip the AM gate.
- Level structure splits the digital families. Discriminator kurtosis catches phase modulation (PSK); folding at the symbol rate counts levels — 2 for FSK, 4 for C4FM — the way a slicer sees them.
- Encryption is a statistics verdict, not a decrypt. A recovered payload with near-maximal Shannon entropy and near-uniform byte distribution reads as encrypted; structured traffic falls below the bar.
- Everything is thresholds on measured features. The
ClassFeaturesvector rides on every result, so a classification is debuggable and golden-testable.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Feature vector | envelope, discriminator, baud, levels | internal/survey/classify.go (ClassFeatures) |
| Decision cascade | features → class + confidence | internal/survey/classify.go (decide) |
| Analog decode | squelch, CTCSS, DCS scan | internal/survey/analog.go (AnalyzeAnalogFM) |
| Paging decode | POCSAG/FLEX over a buffer | internal/survey/pager.go (DecodePOCSAG) |
| Encryption triage | entropy + index of coincidence | internal/survey/encryption.go (EncryptionTriage) |
| Algorithm name | ALGID → AES-256 / ADP / … | internal/hunt/enctype.go (encTypeName) |
| Identify gate | forward digital to siglab identify | internal/hunt/decode.go |
In this post
- Why classify before identify — the cost argument for a cheap first pass.
- The feature vector — envelope, discriminator, baud line, level structure.
- The decision cascade — the order the branches fire, and why digital comes first.
- Analog and paging — what a non-trunked carrier gets instead of an identify.
- The encryption triage — telling random from structured without a key.
Why classify before identify
A full protocol identify is expensive: it rewinds the capture, tries several candidate protocols, runs each far enough to decide, and reports a confidence. Do that on all forty carriers a sweep surfaced and most of the work is wasted on analog repeaters and pagers that were never going to be a trunked control channel. So the survey runs a cheap, blind, per-carrier classifier first. Its job is not to fully demodulate — it’s to route: name the modulation family well enough to send each carrier to the right next step. The class enum is intentionally coarse:
// internal/survey/classify.go (shape)
const (
ClassUnknown SignalClass = "unknown" // no carrier / too weak
ClassAM SignalClass = "am"
ClassNBFM SignalClass = "nbfm" // narrowband analog FM
ClassWideFM SignalClass = "wfm"
ClassFSK SignalClass = "fsk" // 2-level
ClassC4FM SignalClass = "c4fm" // 4-level (P25 C4FM / YSF)
ClassPSK SignalClass = "psk" // π/4-DQPSK and friends
ClassContinuousData SignalClass = "data"
ClassPaging SignalClass = "paging" // FSK at a paging baud
// …ClassTrunkControl/Voice assigned by the router after a CC lock
)
Note the last comment: ClassTrunkControl and ClassTrunkVoice are not assigned
by the blind classifier — they’re the router’s verdict after the authoritative
siglab identify actually locks a control channel. The classifier says “this is
C4FM”; identify says “this is a P25 control channel.” Coarse-then-fine, the same
pattern as detection-then-decode.
The feature vector
Everything the classifier decides on is a small vector of DSP measurements, computed once and carried on the result so the class is a thresholded view of numbers you can inspect:
// internal/survey/classify.go (shape) — ClassFeatures
type ClassFeatures struct {
OccupiedBwHz uint32 // contiguous bandwidth above the floor
SNRDb float64 // peak-to-floor of the baseband spectrum
EnvelopeCV float64 // std/mean of |z[n]| — AM is high, angle-mod ≈ 0
IFStd float64 // FM-discriminator std (rad/sample), a deviation proxy
IFKurtosis float64 // excess kurtosis — PSK is impulsive (spikes at transitions)
BaudHz float64 // cyclostationary symbol-rate line, or 0 for analog voice
BaudProminence float64 // how far the baud line stands over the local median
IFModality int // discriminator levels: 2 ⇒ FSK, 4 ⇒ C4FM, 0/1 ⇒ analog/PSK
}
Three physical intuitions drive these. Envelope: AM carries its message in
amplitude, so |z[n]| swings (high CV); a constant-envelope FM or digital-FM
carrier sits near zero CV. Discriminator: FM-demodulate and the output’s
standard deviation is a deviation proxy, while its kurtosis is impulsive for phase
modulation (spikes at phase transitions, quiet between). Cyclostationarity: a
digital carrier’s symbol transitions recur at the baud rate; rectify the
discriminator (|d - mean|) and that recurrence shows up as a discrete spectral
line, while analog voice produces only a smooth roll-off. baudLine finds that line
and reports its prominence over the band median.
The level count is the subtle one. You can’t histogram the raw discriminator — a
pulse-shaped carrier smears its levels between samples. So symbolModality folds
the discriminator at the detected symbol rate, integrate-and-dump over each symbol
period the way a slicer sees it, then histograms and counts well-separated peaks:
2 for binary FSK, 4 for C4FM.
The decision cascade
decide maps the vector to a class and a confidence, and the order of its
branches is load-bearing:
// internal/survey/classify.go (shape) — decide
func decide(f ClassFeatures, cfg ClassifyConfig) (SignalClass, float64) {
if f.OccupiedBwHz == 0 || f.SNRDb < cfg.SNRGateDb {
return ClassUnknown, 0
}
// Digital FIRST: a strong cyclostationary baud line is the key-independent
// marker of symbol transitions — checked before AM because a linearly-
// modulated digital carrier has pulse-shaping envelope variation that would
// otherwise trip the AM gate.
if f.BaudHz > 0 && f.BaudProminence >= cfg.DigitalProminence {
conf := 0.5 + 0.5*clamp01((f.BaudProminence-cfg.DigitalProminence)/30)
if f.IFKurtosis > cfg.PSKKurtosis {
return ClassPSK, conf
}
if f.IFModality >= 4 {
return ClassC4FM, conf
}
return ClassFSK, conf
}
// AM: non-constant envelope AND little angle modulation (low IFStd).
if f.EnvelopeCV > cfg.AMEnvelopeCV && f.IFStd < cfg.AMMaxIFStd {
return ClassAM, 0.5 + 0.5*clamp01((f.EnvelopeCV-cfg.AMEnvelopeCV)/0.3)
}
// Analog FM: constant envelope, no baud line. Split by bandwidth.
if f.OccupiedBwHz <= cfg.NBFMMaxBwHz {
return ClassNBFM, 0.6
}
return ClassWideFM, 0.6
}
Digital is tested first because a π/4-DQPSK carrier has genuine envelope variation
from pulse shaping — enough to trip the AM gate if AM ran first. Within the digital
branch, kurtosis is checked before the 4-level test because π/4-DQPSK’s phase-change
values also show four levels, so an impulsive discriminator (PSK) has to win
before the C4FM branch can. And the AM gate carries a hard-won subtlety: it requires
low IFStd as well as high envelope CV, because a DMR carrier whose fragile baud
line was lost to noise can have its envelope CV lifted past the gate — but its
discriminator still swings with the deviation, so the low-IFStd requirement keeps
it out of AM and sends it to the FM split instead (issue #648). The confidence isn’t
a constant: each branch reports how cleanly its gate was met, so a marginal call
reads low and a clear one high.
How that principle shaped the Go code
- Thresholds are a struct, not constants.
ClassifyConfigexposes every gate, andwithDefaultsfills zero fields — so an operator can tune one threshold from a CLI flag for a noisy front end without recompiling, and the golden fixtures pin the defaults. - Occupied bandwidth can come from outside.
ClassifyWithaccepts anoccBwHz/snrDbmeasured on a wider, un-decimated view, so a wideband signal isn’t mis-measured by the narrow channel the classifier IQ was decimated to. - The features survive the verdict. Because
ClassFeaturesrides on theClassification, a surprising call (“why did this read as AM?”) is answered by reading the numbers, not by re-running the DSP.
Analog and paging: what a non-trunked carrier gets
A carrier that classifies analog isn’t discarded — it’s decoded the cheap way.
AnalyzeAnalogFM checks carrier-present power against a squelch, then blind-scans
the 38-tone EIA CTCSS set and the common DCS codes, one Goertzel each, to identify
the repeater’s sub-audible squelch:
// internal/survey/analog.go (shape) — AnalyzeAnalogFM
rep := &AnalogReport{PowerDbFS: conventional.PowerDbFS(iq)}
rep.Active = rep.PowerDbFS >= analogSquelchDbFS
if !rep.Active {
return rep
}
rep.CTCSSHz = scanCTCSS(iq, float64(inputRateHz)) // first tone that locks
rep.DCSCode = scanDCS(iq, float64(inputRateHz)) // first code that locks
These reuse the conventional scanner’s own CTCSS/DCS detectors, so the survey’s
analog verdict matches what the live scanner would do. Similarly, a carrier at a
paging baud (IsPagingBaud — 512/1200/1600/2400) goes to DecodePOCSAG/DecodeFLEX,
which run the exact FM→resample→slice→syncer chain the live pager receivers use,
just over a fixed buffer. A non-trunked carrier still comes back with a real,
human-legible summary — never a silent skip.
The encryption triage
Some digital carriers no decoder identifies — an unknown burst, an encrypted flow.
For those, EncryptionTriage gives a verdict without a key, because a strong
cipher’s output is statistically indistinguishable from random:
// internal/survey/encryption.go (shape) — EncryptionTriage
norm := stats.ShannonEntropy(data) / maxH // normalized by achievable max
ic := stats.IndexOfCoincidence(data) // ≈1/256 for uniform bytes
if norm >= 0.85 && ic < 0.02 {
return true, "high-entropy (likely encrypted)"
}
return false, ""
The two statistics guard against each other. Normalized Shannon entropy is a loose
floor — a finite random sample under-estimates the full 8 bits/byte (Miller bias) —
so the byte-uniformity test (index of coincidence, which is sample-size robust) does
the real work. Random bytes sit at IC ≈ 0.0039; English text ~0.066; a repeating
scrambler or low-rate codec far higher. Only near-maximal entropy and near-uniform
bytes together read as encrypted; structured traffic falls below the bar. When a
protocol did decode and its grants carried an ALGID, encTypeName names the
algorithm (AES-256, ADP/RC4, DES-OFB) by reusing the Crypto Lab’s own tables, so the
survey and the cryptanalysis toolkit agree on the label. The triage is the survey’s
“is this encrypted?”; confirming the construction is the
Crypto Lab’s job.
Where this goes next
Our 851 MHz carrier now classifies as digital, C4FM, probably not encrypted — a strong candidate for a P25 control channel. But classifying it assumed the front end was delivering clean IQ. Part 5 settles that: sweeping the gain to minimise the decode error rate and correcting the tuning offset, so the identify in Part 7 gets the best signal the radio can give it.
FAQ
Why classify at all if identify is the authoritative call? Cost. Identify is expensive per carrier; a sweep produces dozens. The blind classifier is cheap and routes each carrier so identify runs only on the digital ones that could plausibly be a control channel — turning a forty-carrier survey from “identify everything” into “identify a handful.”
How can it call something encrypted without decrypting it? It doesn’t claim to read the traffic — it measures its statistics. Strong encryption produces output indistinguishable from random: near-maximal entropy and near-uniform byte frequencies. Structured traffic (plaintext, a scrambler, a codec) has detectable regularity and fails those tests. It’s a triage flag, confirmed elsewhere.
Why is the digital test before the AM test? Because linearly-modulated digital carriers (π/4-DQPSK) have real envelope variation from pulse shaping, which looks like AM’s non-constant envelope. Testing for the cyclostationary baud line first catches those as digital before the AM gate can mis-claim them.
What stops a DMR carrier being mislabelled AM?
The AM gate requires both high envelope CV and low discriminator std. A DMR carrier
whose baud line was lost to noise might have an inflated envelope CV, but its
discriminator still swings with the deviation (high IFStd), so it fails the AM gate
and falls through to the FM split — the fix for issue #648.
Does classification need the full capture?
No — it works on a baseband IQ buffer already tuned to the carrier, and needs only a
few thousand samples (minClassifySamples is 4096) for the spectral estimates to be
trustworthy. The same routine runs on a live grab or an offline slice.
Series navigation
Part 4 of 14 · ← Part 3: Peaks & Occupancy — Finding Carriers in the Noise · Next → Part 5: Autogain & Autotune — Settling the Front End