Documentation
¶
Overview ¶
Package survey turns a detected carrier into a classified signal. Where the hunt package's sweeper finds where energy is (carriers above the noise floor), survey answers what each carrier is: a cheap, blind, per-carrier classifier that names the modulation family (analog NBFM/WFM, AM, digital FSK/C4FM/PSK, continuous data) plus an occupied-bandwidth estimate, and the conventional decoders (POCSAG/FLEX paging, analog-FM activity) that turn a classified carrier into a decode summary.
survey deliberately depends only downward — on the dsp primitives and the existing radio decoders — and never on hunt, so the hunt package can import it to route swept candidates without an import cycle. The trunking identify→decode→map path stays in siglab/hunt; survey hands a candidate off to it (via hunt) only when the class warrants the (expensive) full identify.
Index ¶
- Constants
- func CaptureAnalogAudio(iq []complex64, inRateHz uint32) []int16
- func CarrierPowerDbFS(iq []complex64) float64
- func EncryptionTriage(data []byte) (encrypted bool, label string)
- func IsDigital(c SignalClass) bool
- func IsPagingBaud(baud float64) bool
- func OccupiedBandwidth(iq []complex64, rateHz float64, maxBwHz uint32, cfg ClassifyConfig) (uint32, float64)
- func RecoverPayload(iq []complex64, rateHz float64, feat ClassFeatures) []byte
- func SnapChannelBandwidth(bwHz uint32) uint32
- func WriteAnalogClip(path string, iq []complex64, inRateHz uint32) error
- type AnalogReport
- type ClassFeatures
- type Classification
- type ClassifyConfig
- type PageRef
- type SignalClass
Constants ¶
const AudioClipRateHz = 8000
AudioClipRateHz is the PCM rate of survey analog-FM audio clips. 8 kHz is the conventional voice rate the WAV writer and recorder use.
Variables ¶
This section is empty.
Functions ¶
func CaptureAnalogAudio ¶
CaptureAnalogAudio demodulates an analog-FM baseband capture into 8 kHz PCM, reusing the standard voice chain: FM discriminator → 75 µs de-emphasis → audio AGC → rational resample to AudioClipRateHz → int16. inRateHz is the channelised input sample rate.
func CarrierPowerDbFS ¶
CarrierPowerDbFS reports the RMS power of an IQ buffer in dBFS, reusing the conventional scanner's squelch primitive. Survey uses it to detect carrier activity (a keyed FM carrier sits well above the noise floor).
func EncryptionTriage ¶ added in v0.5.9
EncryptionTriage reports whether a recovered byte payload looks encrypted, with a short label. It is a heuristic, not a decode: a strong cipher's output is statistically indistinguishable from random, so a payload with near-maximal (sample-size-normalized) Shannon entropy AND a near-uniform byte distribution (low index of coincidence) reads as encrypted. Structured traffic — plaintext, a repeating scrambler, a low-rate codec — falls below the bar and is not flagged. It is the survey's "is this flow encrypted?" for a carrier no decoder identified, mirroring the rfscope entropy analyzer; CryptoLab is where the verdict is confirmed and the construction attacked.
func IsDigital ¶
func IsDigital(c SignalClass) bool
IsDigital reports whether c is one of the digital modulation families (the classes the router forwards to the paging decoders or the trunking identify).
func IsPagingBaud ¶
IsPagingBaud reports whether baud is close to a POCSAG/FLEX signalling rate. The router uses it to decide whether a digital FSK carrier is worth a paging decode attempt before the (more expensive) trunking identify.
func OccupiedBandwidth ¶
func OccupiedBandwidth(iq []complex64, rateHz float64, maxBwHz uint32, cfg ClassifyConfig) (uint32, float64)
OccupiedBandwidth returns the contiguous bandwidth around DC that stands above the noise floor, and the carrier SNR. The capture is baseband (carrier at DC), so the occupied span is the run of above-threshold bins bridging the centre bin. Returns (0, 0) when no carrier stands out. The survey router calls this on the full-rate (un-decimated) capture so a wideband signal's bandwidth isn't truncated by the narrow channel decimation.
maxBwHz caps the measured width: the outward walk from DC stops at ±maxBwHz/2 even if the gap tolerance would otherwise bridge into an adjacent carrier. This keeps a dense band (e.g. DMR carriers on a 12.5 kHz grid, whose edges sit inside the small gap tolerance) from chaining its neighbours into one giant span. maxBwHz == 0 leaves the walk unbounded (a genuinely isolated wideband signal, where nothing adjacent can bridge in).
func RecoverPayload ¶ added in v0.5.9
func RecoverPayload(iq []complex64, rateHz float64, feat ClassFeatures) []byte
RecoverPayload blind-demodulates a digital burst's baseband IQ into a byte payload for entropy/structure triage. It FM-discriminates the burst, slices the discriminator at the detected symbol rate into 2- or 4-level symbols (integrate-and-dump), maps each symbol to bits, and packs MSB-first.
This is deliberately triage-grade, not a protocol-exact demod: its job is to produce a bitstream whose entropy/structure tells random-keystream from structured-scrambler-from-plaintext, the question the cryptolab engines answer. FM discrimination covers the FSK/C4FM family that dominates non-WiFi digital RF; a PSK carrier still yields a usable (if noisier) stream. Shared by the rfscope entropy analyzer and the hunt survey's encryption triage.
func SnapChannelBandwidth ¶ added in v0.4.2
SnapChannelBandwidth normalises a measured occupied bandwidth for reporting: when bwHz lands within ±20% of a standard narrowband channel width it returns that width (so a carrier reads as the 12.5 kHz channel it is, not "11.6 kHz"); otherwise it returns bwHz unchanged so a genuinely wideband signal (broadcast FM, a wideband data carrier) is reported as-measured. This only adjusts the displayed width — the raw measurement stays on ClassFeatures.OccupiedBwHz.
Types ¶
type AnalogReport ¶
type AnalogReport struct {
Active bool `json:"active"` // carrier power above squelch
PowerDbFS float64 `json:"power_dbfs"` // measured RMS power
CTCSSHz float64 `json:"ctcss_hz,omitempty"` // detected CTCSS tone, 0 if none
DCSCode string `json:"dcs_code,omitempty"` // detected DCS code, "" if none
AudioClipPath string `json:"audio_clip_path,omitempty"` // WAV clip, when -survey-audio is set
}
AnalogReport summarises a conventional analog carrier: whether it is keyed up (carrier present) and any sub-audible squelch tone/code identifying the system. It reuses the conventional scanner's primitives — the same IQ-power squelch and CTCSS/DCS detectors that gate the live analog scanner — so the survey's analog verdict matches what the scanner would do.
func AnalyzeAnalogFM ¶
func AnalyzeAnalogFM(iq []complex64, inputRateHz uint32) *AnalogReport
AnalyzeAnalogFM measures a baseband analog-FM carrier: carrier-present power plus a blind CTCSS-tone and DCS-code scan. iq is the channelised baseband capture and inputRateHz its sample rate. Returns the report (Active=false when no carrier clears squelch).
type ClassFeatures ¶
type ClassFeatures struct {
// OccupiedBwHz is the contiguous bandwidth around DC standing above the
// noise floor (the carrier was tuned to baseband before classification).
OccupiedBwHz uint32 `json:"occupied_bw_hz"`
// SNRDb is the peak-to-noise-floor ratio of the baseband spectrum.
SNRDb float64 `json:"snr_db"`
// EnvelopeCV is the coefficient of variation (std/mean) of |z[n]|. AM has
// a high CV; the constant-envelope angle modulations sit near zero.
EnvelopeCV float64 `json:"envelope_cv"`
// IFStd is the standard deviation of the FM-discriminator output
// (rad/sample), a proxy for peak deviation.
IFStd float64 `json:"if_std"`
// IFKurtosis is the excess kurtosis of the discriminator output. PSK is
// impulsive (high kurtosis: spikes at phase transitions, ~0 between);
// FSK/analog are flatter.
IFKurtosis float64 `json:"if_kurtosis"`
// BaudHz is the cyclostationary symbol-rate line found in the rectified
// discriminator spectrum, or 0 when none stands out (analog voice).
BaudHz float64 `json:"baud_hz"`
// BaudProminence is how far the baud line stands above the local median
// (ratio). Higher ⇒ a more confident digital call.
BaudProminence float64 `json:"baud_prominence"`
// IFModality is the number of levels detected in the discriminator
// histogram (2 ⇒ FSK, 4 ⇒ C4FM, 0/1 ⇒ analog or PSK-at-zero).
IFModality int `json:"if_modality"`
}
ClassFeatures are the raw DSP measurements behind a classification. They are carried on the result so a survey is debuggable and golden-testable (the class is a thresholded view of these numbers).
type Classification ¶
type Classification struct {
Class SignalClass `json:"class"`
Confidence float64 `json:"confidence"` // 0..1
OccupiedBwHz uint32 `json:"occupied_bw_hz"`
Features ClassFeatures `json:"features"`
}
Classification is the verdict for one carrier.
func Classify ¶
func Classify(iq []complex64, rateHz float64) Classification
Classify measures the baseband carrier in iq (already tuned to DC) with the default config, measuring occupied bandwidth from iq itself.
func ClassifyWith ¶
func ClassifyWith(iq []complex64, rateHz float64, occBwHz uint32, snrDb float64, cfg ClassifyConfig) Classification
ClassifyWith classifies a baseband carrier with an explicit config. When occBwHz > 0 it is used as the occupied bandwidth (and snrDb as the SNR) instead of measuring from iq — the survey router passes a measurement taken on a wider, un-decimated view so a wideband signal isn't mis-measured by the narrow channel iq is decimated to. occBwHz == 0 measures from iq.
type ClassifyConfig ¶
type ClassifyConfig struct {
// SNRGateDb is the minimum peak-over-noise-floor (dB) for a carrier to be
// classified at all; below it the carrier reads as unknown.
SNRGateDb float64
// OccupiedThreshDb is how far above the noise floor a bin must stand to
// count toward the occupied bandwidth.
OccupiedThreshDb float64
// AMEnvelopeCV is the envelope coefficient-of-variation above which a
// carrier reads as AM (non-constant envelope).
AMEnvelopeCV float64
// AMMaxIFStd is the FM-discriminator std (rad/sample) below which a
// high-envelope-CV carrier reads as AM. Above it the carrier carries real
// angle modulation (FM / digital FM) and is not AM even when noise or
// channel clipping has lifted its envelope CV past AMEnvelopeCV.
AMMaxIFStd float64
// DigitalProminence is the minimum rectified-discriminator baud-line
// prominence for a carrier to read as digital.
DigitalProminence float64
// NBFMMaxBwHz is the occupied-bandwidth boundary between narrowband and
// wideband analog FM.
NBFMMaxBwHz uint32
// PSKKurtosis is the discriminator excess-kurtosis above which a digital
// carrier with no level structure reads as phase modulation.
PSKKurtosis float64
}
ClassifyConfig holds the decision thresholds the classifier uses. The zero value is invalid; callers either use Classify (which applies DefaultClassifyConfig) or pass a config whose zero fields are filled in by withDefaults. Exposing these lets operators tune for a noisy SDR front-end or a non-standard channel plan without recompiling.
func DefaultClassifyConfig ¶
func DefaultClassifyConfig() ClassifyConfig
DefaultClassifyConfig returns the tuned defaults (validated against the synthesized fixtures in classify_test.go).
type PageRef ¶
type PageRef struct {
Protocol string `json:"protocol"` // "pocsag" | "flex"
BaudHz uint32 `json:"baud_hz"` // signalling rate that decoded it
Capcode uint32 `json:"capcode"` // RIC (POCSAG) / capcode (FLEX)
Encoding string `json:"encoding"` // "alpha" | "numeric" | FLEX type name
Text string `json:"text"` // decoded payload
Corrected int `json:"corrected"` // FEC bit corrections (signal-quality proxy)
}
PageRef is a protocol-neutral summary of one decoded pager message, suitable for the survey inventory and JSON. It flattens the pocsag.Page / flex.Message shapes the receivers publish to the bus.
func DecodeFLEX ¶
DecodeFLEX runs the FLEX (1600 bps) decode pipeline over a baseband IQ buffer and returns the decoded messages. It reuses the live FLEX receiver's pipeline (internal/radio/pager/flex/receiver) over a fixed buffer.
func DecodePOCSAG ¶
DecodePOCSAG runs the POCSAG decode pipeline over a baseband IQ buffer at each candidate baud and returns the pages from the rate that decoded the most. It reuses the exact FM→resample→slice→syncer chain the live receiver uses (internal/radio/pager/pocsag/receiver), just over a fixed buffer instead of a channel, so the protocol layer is unchanged.
type SignalClass ¶
type SignalClass string
SignalClass names the modulation family the classifier assigns to a carrier. It is intentionally coarse: the goal is to route a carrier to the right decoder, not to fully demodulate it. A digital class that turns out to carry a trunking control channel is refined to ClassTrunkControl/ClassTrunkVoice by the router after the authoritative siglab identify.
const ( ClassUnknown SignalClass = "unknown" // no carrier / too weak to classify ClassAM SignalClass = "am" // amplitude modulation (non-constant envelope) ClassNBFM SignalClass = "nbfm" // narrowband analog FM (≤ ~16 kHz) ClassWideFM SignalClass = "wfm" // wideband analog FM (broadcast / ~150–200 kHz) ClassFSK SignalClass = "fsk" // 2-level frequency-shift keying ClassC4FM SignalClass = "c4fm" // 4-level FSK (P25 C4FM / YSF family) ClassPSK SignalClass = "psk" // phase-shift keying (π/4-DQPSK and friends) ClassContinuousData SignalClass = "data" // continuous digital carrier, family unresolved ClassPaging SignalClass = "paging" // FSK at a paging baud (512/1200/1600/2400) ClassTrunkControl SignalClass = "trunk-control" // assigned by the router after a CC lock ClassTrunkVoice SignalClass = "trunk-voice" // assigned by the router: decoded, no CC lock ClassWideband SignalClass = "wideband" // a span wider than a channel (cellular/WiFi/OFDM); named, not decoded )