siglab

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 49 Imported by: 0

Documentation

Overview

Package siglab is the offline signal replay / testing / analysis core shared by the gophertrunk replay, analyze, gen, test and siglab-TUI subcommands. It drives any protocol GopherTrunk can decode through the same production receiver + control-channel pipelines the daemon runs (via the ccdecoder factory map), collects a protocol-agnostic structured Result, and exposes signal-quality analysis, synthesis, and a metadata-driven acceptance harness on top of it.

The engine deliberately mirrors the daemon's IQ → DDC → pipeline chain so a replay lock implies an on-air lock and a replay failure makes the offline capture a reproducible fixture (the same contract the original replay subcommand established for P25 in issue #402).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DiscoverMetadata

func DiscoverMetadata(capturePath string) string

DiscoverMetadata finds the sidecar for a capture by stem: it tries <stem>.metadata.json, <stem>.metadata.yaml, then <capture>.json/.yaml. Returns "" when none exists.

func EncodeCapture added in v0.3.3

func EncodeCapture(iq []complex64, format SampleFormat) []byte

EncodeCapture returns IQ encoded in the given on-disk format (u8 or f32). It is the pure (no-I/O) core shared by WriteCapture and the in-memory synth→decode bridge (SynthesizeAndAnalyze), so a synthesized signal can be fed straight back into the engine via a bytes.Reader without touching disk.

func Fixtures

func Fixtures() []trunking.Protocol

Fixtures returns the protocols with a registered synthesis fixture, in stable order, for the `gen` usage message and protocol picker.

func HasFixture

func HasFixture(p trunking.Protocol) bool

HasFixture reports whether a synthesis fixture is registered for p.

func ParseProtocolCLI

func ParseProtocolCLI(s string) (trunking.Protocol, error)

ParseProtocolCLI maps a command-line protocol name to a trunking.Protocol. It first honours the legacy replay aliases ("p25p1" → P25 Phase 1, "dmr-tier3" → DMR Tier III) so existing replay invocations keep working, then falls back to the canonical trunking.ParseProtocol (which covers all config spellings: p25, p25-phase2, dmr, dmr-tier2, nxdn, dpmr, edacs, motorola, ltr, mpt1327, tetra, ysf, dstar).

func SortedDecodeErrors

func SortedDecodeErrors(m map[string]int) []string

SortedDecodeErrors returns the decode-error stages in a stable order, for deterministic text/CSV rendering by callers.

func SynthesizeAndAnalyze added in v0.3.3

func SynthesizeAndAnalyze(synth SynthOptions, decode Config, onEvent func(EventRecord)) (*Result, *Metadata, error)

SynthesizeAndAnalyze synthesizes an idealized (optionally impaired) signal for synth.Protocol and immediately analyzes it through the production pipeline, all in memory — no capture file is written to disk. It is the one-call path behind the web interface's "compare against a generated idealized signal" feature: the returned Result (and its IQTaps, when decode.CaptureIQ is set) can be diffed against an uploaded capture's Result.

The decode template carries the analysis knobs the caller wants (CollectIQDiag, CaptureIQ, CaptureIQMaxPoints, Conjugate, IQCorrect, DemodMode and the P25 deep knobs); the protocol, sample rate, format and per-protocol system knobs are taken from the synthesis fixture so the decode always matches the signal that was generated. onEvent (when non-nil) receives each EventRecord live, exactly like RunReaderStream.

func WriteCapture

func WriteCapture(path string, iq []complex64, format SampleFormat) error

WriteCapture writes IQ to path in the given format (u8 or f32).

func WriteMetadata

func WriteMetadata(path string, m *Metadata) error

WriteMetadata writes m to path as JSON (the sidecar `test` auto-discovers).

func WriteResult

func WriteResult(w io.Writer, r *Result, f Format) error

WriteResult serializes r to w in the requested format. FormatTextSummary is not handled here (the subcommands own their text rendering); it returns an error so a miswired caller is caught.

Types

type Acceptance

type Acceptance struct {
	// Lock, when non-nil, requires the run to (true) or to not (false) lock
	// the control channel.
	Lock *bool `json:"lock,omitempty" yaml:"lock,omitempty"`
	// LockLatencyMaxSec caps the time-to-first-lock (0 ⇒ unchecked).
	LockLatencyMaxSec float64 `json:"lock_latency_max_sec,omitempty" yaml:"lock_latency_max_sec,omitempty"`
	// LockFields are expected lock-payload fields (NAC, ColorCode,
	// SystemID, …). Values may be given as numbers or hex strings
	// ("0x293"); comparison is hex-tolerant. Matched as a subset of the
	// observed lock fields (and the common frequency_hz).
	LockFields map[string]any `json:"lock_fields,omitempty" yaml:"lock_fields,omitempty"`
	// MinGrants requires at least this many grants (0 ⇒ unchecked).
	MinGrants int `json:"min_grants,omitempty" yaml:"min_grants,omitempty"`
	// BaudTolerancePct caps |effective − expected| / expected (0 ⇒ unchecked).
	BaudTolerancePct float64 `json:"baud_tolerance_pct,omitempty" yaml:"baud_tolerance_pct,omitempty"`
	// MaxDecodeErrorRate caps decode errors per 1000 symbols (0 ⇒ unchecked).
	MaxDecodeErrorRate float64 `json:"max_decode_error_rate,omitempty" yaml:"max_decode_error_rate,omitempty"`
	// MaxEVMPct caps the demod error-vector magnitude percentage (0 ⇒
	// unchecked). Grades demod quality directly — a capture whose eye is too
	// closed fails even if it happens to lock.
	MaxEVMPct float64 `json:"max_evm_pct,omitempty" yaml:"max_evm_pct,omitempty"`
	// MinSNRdB requires the estimated demod SNR to be at least this many dB
	// (0 ⇒ unchecked).
	MinSNRdB float64 `json:"min_snr_db,omitempty" yaml:"min_snr_db,omitempty"`
}

Acceptance is the expected-outcome contract a capture is graded against by the `test` harness. A nil field means "don't check this dimension". It generalizes the per-test assertions the integration_cc_*_test.go files and samples/README.md acceptance criteria describe into one schema.

type BandPlanSlot added in v0.3.5

type BandPlanSlot = trunking.TopoBandPlanSlot

BandPlanSlot is one band-plan entry. See trunking.TopoBandPlanSlot.

type CCStatsBreakdown

type CCStatsBreakdown struct {
	NIDTrusted        int64 `json:"nid_trusted" yaml:"nid_trusted"`
	NIDMarginal       int64 `json:"nid_marginal" yaml:"nid_marginal"`
	NIDFailed         int64 `json:"nid_failed" yaml:"nid_failed"`
	TSBKDecoded       int64 `json:"tsbk_decoded" yaml:"tsbk_decoded"`
	TSBKTrellisFailed int64 `json:"tsbk_trellis_failed" yaml:"tsbk_trellis_failed"`
	TSBKCRCFailed     int64 `json:"tsbk_crc_failed" yaml:"tsbk_crc_failed"`
	// NetStatusSeen, RFSSStatusSeen, and AdjacentSeen count the identity
	// broadcasts decoded (0x3B / 0x3A / 0x3C). NetStatusSeen == 0 on a locked
	// CC explains a blank WACN/System ID: the periodic NSB never landed.
	NetStatusSeen  int64 `json:"net_status_seen" yaml:"net_status_seen"`
	RFSSStatusSeen int64 `json:"rfss_status_seen" yaml:"rfss_status_seen"`
	AdjacentSeen   int64 `json:"adjacent_seen" yaml:"adjacent_seen"`
	// LocRegSeen counts accepted Location Registration Responses (0x2B), the
	// high-rate RFSS/Site source. RFSS/Site resolved via LocRegSeen while
	// NetStatusSeen == 0 is the normal shape of a site that names itself in
	// registrations but withholds the WACN-bearing NSB.
	LocRegSeen int64 `json:"loc_reg_seen" yaml:"loc_reg_seen"`
}

CCStatsBreakdown mirrors p25phase1.CCStats: the per-frame decode outcomes.

type CandidateScore

type CandidateScore struct {
	Protocol       string  `json:"protocol"`
	Locked         bool    `json:"locked"`
	LockLatencySec float64 `json:"lock_latency_sec"`
	SyncHits       int     `json:"sync_hits"`
	SyncVariant    string  `json:"sync_variant"`
	ModalSpacing   int     `json:"modal_spacing"`
	FECPassRate    float64 `json:"fec_pass_rate"`
	Score          float64 `json:"score"`
	// contains filtered or unexported fields
}

CandidateScore is one protocol's identification evidence and composite score.

type CarrierResult added in v0.4.3

type CarrierResult struct {
	OffsetHz     float64       `json:"offset_hz" yaml:"offset_hz"`
	FreqHz       uint32        `json:"freq_hz" yaml:"freq_hz"`
	SNRDb        float32       `json:"snr_db" yaml:"snr_db"`
	Protocol     string        `json:"protocol" yaml:"protocol"`
	Confidence   float64       `json:"confidence" yaml:"confidence"`
	Inconclusive bool          `json:"inconclusive" yaml:"inconclusive"`
	Locked       bool          `json:"locked" yaml:"locked"`
	Role         CarrierRole   `json:"role" yaml:"role"`
	ColorCode    uint8         `json:"color_code,omitempty" yaml:"color_code,omitempty"`
	SystemID     uint32        `json:"system_id,omitempty" yaml:"system_id,omitempty"`
	Grants       []GrantRecord `json:"grants,omitempty" yaml:"grants,omitempty"`
	Score        float64       `json:"score" yaml:"score"`

	// Blind / ReferenceMatches name a carrier that did NOT decode, via the
	// offline signal-ID reference DB (Protocol is blanked for such carriers and
	// Role is RoleUnknown). Populated from the per-carrier identify fallback.
	Blind            *blind.BlindEstimate `json:"blind,omitempty" yaml:"blind,omitempty"`
	ReferenceMatches []sigref.Match       `json:"reference_matches,omitempty" yaml:"reference_matches,omitempty"`
	// contains filtered or unexported fields
}

CarrierResult is one detected carrier surveyed end-to-end.

func (*CarrierResult) Identify added in v0.4.3

func (c *CarrierResult) Identify() *IdentifyResult

Identify returns the carrier's ranked identification.

func (*CarrierResult) Result added in v0.4.3

func (c *CarrierResult) Result() *Result

Result returns the winning protocol's full run Result (topology + grants), or nil. Used by the offline hunt bridge to fold each carrier into a system.

type CarrierRole added in v0.4.3

type CarrierRole string

CarrierRole labels a surveyed carrier within a trunked system.

const (
	RoleUnknown CarrierRole = ""
	RoleControl CarrierRole = "control"
	RoleVoice   CarrierRole = "voice"
)

type ChannelRef added in v0.3.5

type ChannelRef = trunking.TopoChannelRef

ChannelRef is a band-plan channel coordinate. See trunking.TopoChannelRef.

type Config

type Config struct {
	// Protocol selects the production pipeline to drive. Every protocol the
	// ccdecoder factory map registers is supported.
	Protocol trunking.Protocol
	// System carries per-protocol knobs (demod mode, colour code, band
	// plan, FEC modes, …) exactly as the daemon reads them off a configured
	// trunking.System. SystemName/FrequencyHz are surfaced separately
	// because every factory consumes them.
	System      trunking.System
	SystemName  string
	FrequencyHz uint32

	// SampleRateHz is the capture's IQ sample rate (input rate, before any
	// down-conversion). Required.
	SampleRateHz float64
	// Format is the on-disk sample encoding.
	Format SampleFormat

	// TuneHz frequency-shifts the capture so a channel at +TuneHz lands at
	// 0 Hz before demod. AutoTune estimates it from a prefix of the file
	// (and overrides TuneHz when set).
	TuneHz   float64
	AutoTune bool

	// Conjugate negates Q before channelization (spectrum-inverted /
	// I-Q-swapped front-end, issue #264). IQCorrect applies blind
	// I/Q-imbalance correction to the raw IQ before decimation (issue #402).
	Conjugate bool
	IQCorrect bool

	// CollectIQDiag enables the protocol-agnostic signal-quality analyzer
	// (symbol histogram, IQ imbalance, soft-sample distribution where
	// available). Off by default since it buffers per-symbol observations.
	CollectIQDiag bool

	// DemodMode is the P25 Phase 1 symbol-recovery path: "c4fm" (default) or
	// "cqpsk". Empty ⇒ c4fm.
	DemodMode string
	// NIDSearchSpan overrides the NID-alignment search radius in dibits
	// (issue #275 bisect knob). 0 ⇒ the production default.
	NIDSearchSpan int
	// EnableDDA opts the C4FM path into the experimental decision-directed
	// AFC (issue #402; off in production).
	EnableDDA bool
	// EnableAdaptiveSlicer opts the C4FM path into the adaptive slicer
	// (issue #402; off in production).
	EnableAdaptiveSlicer bool
	// EnableSoftSync opts the P25 control channel into the wider FSW-fallback
	// tolerance: sync words carrying 5–8 symbol errors are decoded if the
	// frame's TSBK CRC corroborates the NID, extending lock reach into
	// marginal-SNR captures. Gated by the existing NID/CRC validation, so it
	// cannot manufacture a false lock (issue #771; off in production).
	EnableSoftSync bool
	// CollectReceiverState captures a per-(stream-)second snapshot of the P25
	// receiver's AFC/AGC/clock/slicer state into the Result (the per-second
	// state log replay emitted to stderr, now structured + exportable).
	CollectReceiverState bool

	// ChunkSamples is the read-loop chunk size in IQ samples; 0 ⇒ default.
	ChunkSamples int

	// RecordDDCPath, when non-empty, writes the post-DDC narrowband IQ (the
	// exact channelized complex stream the receiver decodes, at the DDC output
	// rate — 144 kHz for TETRA, ~48 kHz for the C4FM family) to a two-channel
	// 16-bit baseband WAV at that path. This is the offline half of the "record
	// the digital down-converter output" tooling: point it at a fat 2.5/10 MS/s
	// cfile and it emits a small narrowband WAV that `replay -format wav`
	// decodes bit-for-bit the same way — a shareable, reproducible fixture that
	// is orders of magnitude smaller than the wideband capture. Empty ⇒ no
	// recording.
	RecordDDCPath string

	// CaptureIQ enables buffering a stride-decimated copy of the
	// post-DDC (channelized) IQ and the pre-slicer soft samples into
	// Result.IQTaps, so a web/visualization consumer can render the
	// constellation, spectrogram, PSD and eye diagram client-side. Off
	// by default since it is O(captured points) memory. The capture is
	// always decimated and hard-capped (see CaptureIQMaxPoints), so it
	// never ships full-rate IQ.
	CaptureIQ bool
	// CaptureIQMaxPoints caps the number of decimated IQ points (and,
	// separately, soft samples) retained when CaptureIQ is set. 0 ⇒
	// defaultCaptureIQMaxPoints. The read loop strides the channelized
	// stream so the retained density targets visualization fidelity
	// rather than faithful reconstruction.
	CaptureIQMaxPoints int

	// MaxSamples caps the number of input IQ samples processed (0 ⇒ whole
	// file). The signal identifier sets this to scan only a prefix of a
	// capture, bounding per-candidate cost regardless of capture length.
	MaxSamples int64

	// CollectPDUs retains a per-signaling-block dissection (Result.PDUs) of the
	// data PDUs the decoder parses — the data-over-RF inspector feed. Off by
	// default; P25 Phase 1 (TSBK) only for now. Capped at pduMaxRecords.
	CollectPDUs bool

	// Acceptance, when non-nil, is evaluated against the Result to produce a
	// pass/fail Verdict (the `test` harness sets it).
	Acceptance *Acceptance

	// Log receives the production pipeline's structured diagnostics. nil ⇒
	// a discard logger (the engine never wants a nil *slog.Logger reaching a
	// factory that does not nil-guard it).
	Log *slog.Logger
}

Config drives a single offline run of the engine over one capture (or a synthesized stream). The zero value is not runnable — Protocol, a sample source, and SampleRateHz must be set.

type DemodMetrics added in v0.3.7

type DemodMetrics struct {
	// Modulation names the estimator that produced these numbers: "c4fm" (the
	// 4-level soft eye) or "cqpsk" (the complex π/4-DQPSK constellation).
	Modulation string `json:"modulation" yaml:"modulation"`
	// EVMPct is the RMS error-vector magnitude as a percentage of the ideal
	// symbol level. A clean lock sits at a few percent; it climbs toward the
	// inter-rail half-spacing (~33% for C4FM) as the eye closes.
	EVMPct float64 `json:"evm_pct" yaml:"evm_pct"`
	// SNREstimateDB is the symbol SNR implied by the residual, in dB. For
	// C4FM this is the post-discriminator soft-axis SNR (lower than the input
	// Es/N0 by the FM detection loss); for CQPSK it is the constellation SNR.
	// Capped at demodSNRCapDB for a noise-free synthetic input.
	SNREstimateDB float64 `json:"snr_estimate_db" yaml:"snr_estimate_db"`
	// SymbolsAnalyzed is the soft-sample count the estimate was formed over
	// (after the warmup skip).
	SymbolsAnalyzed int64 `json:"symbols_analyzed" yaml:"symbols_analyzed"`

	// PeakEVMPct is the worst single-symbol EVM (≥ EVMPct).
	PeakEVMPct float64 `json:"peak_evm_pct" yaml:"peak_evm_pct"`
	// MagErrPct / PhaseErrDeg split the error vector into its amplitude
	// (RMS magnitude error, %) and angular (RMS phase error, degrees) parts.
	MagErrPct   float64 `json:"mag_err_pct" yaml:"mag_err_pct"`
	PhaseErrDeg float64 `json:"phase_err_deg" yaml:"phase_err_deg"`
	// CarrierFreqErrorHz is the residual carrier-frequency error the receiver's
	// AFC/CQPSK loop settled at, in Hz (surfaced from the receiver, not
	// re-estimated). Zero when the deep receiver did not expose it.
	CarrierFreqErrorHz float64 `json:"carrier_freq_error_hz" yaml:"carrier_freq_error_hz"`
	// IQGainImbalanceDB / QuadratureErrorDeg are the I/Q gain imbalance and
	// quadrature-skew measured on the *recovered* constellation — distinct from
	// SignalQuality's pre-DDC front-end IQ figures.
	IQGainImbalanceDB  float64 `json:"iq_gain_imbalance_db" yaml:"iq_gain_imbalance_db"`
	QuadratureErrorDeg float64 `json:"quadrature_error_deg" yaml:"quadrature_error_deg"`
	// OriginOffsetPct is the residual DC (carrier-leakage) offset as a percent
	// of the reference radius.
	OriginOffsetPct float64 `json:"origin_offset_pct" yaml:"origin_offset_pct"`
	// EVMTrace is the per-symbol EVM% time series, stride-decimated to the IQ
	// cap. ErrorVectorSpectrum is the FFT-shifted magnitude spectrum (dB) of
	// the residual error vectors — a spur in it points at a periodic
	// impairment rather than white noise. Both omitempty so the CSV/summary
	// path is unaffected.
	EVMTrace            []float32 `json:"evm_trace,omitempty" yaml:"evm_trace,omitempty"`
	ErrorVectorSpectrum []float32 `json:"error_vector_spectrum,omitempty" yaml:"error_vector_spectrum,omitempty"`
}

DemodMetrics is the demodulator-quality summary computed from the recovered soft symbols: error-vector magnitude (constellation dispersion) and the SNR implied by the residual about the ideal symbol positions. It is the siglab-surfaced face of internal/radio/p25/phase1/metrics.

type EventRecord

type EventRecord struct {
	Seq       int            `json:"seq" yaml:"seq"`
	OffsetSec float64        `json:"offset_sec" yaml:"offset_sec"`
	Kind      string         `json:"kind" yaml:"kind"`
	Fields    map[string]any `json:"fields" yaml:"fields"`
}

EventRecord is one bus event captured generically — Kind plus the reflection-flattened payload — so the JSONL stream and the events CSV represent every protocol's events without a typed switch per protocol.

type FECStat

type FECStat struct {
	Stage         string `json:"stage" yaml:"stage"`
	Frames        int    `json:"frames" yaml:"frames"`
	Clean         int    `json:"clean" yaml:"clean"`
	Corrected     int    `json:"corrected" yaml:"corrected"`
	Uncorrectable int    `json:"uncorrectable" yaml:"uncorrectable"`
	CRCPass       int    `json:"crc_pass,omitempty" yaml:"crc_pass,omitempty"`
	CRCFail       int    `json:"crc_fail,omitempty" yaml:"crc_fail,omitempty"`
}

FECStat tallies one FEC stage's outcomes across the frames found at clean sync hits — the direct analog of P25's NID trusted/marginal/uncorrectable.

type Format

type Format int

Format selects an output encoding for a Result.

const (
	// FormatTextSummary is the human-readable summary (handled by the
	// caller's renderer, not WriteResult). Defined so callers share one
	// Format enum.
	FormatTextSummary Format = iota
	// FormatJSON is a single indented JSON object.
	FormatJSON
	// FormatJSONL is one JSON object per line: every captured event, then a
	// final summary object tagged "summary".
	FormatJSONL
	// FormatYAML is a single YAML document.
	FormatYAML
	// FormatCSVSummary is a two-line CSV (header + one summary row).
	FormatCSVSummary
	// FormatCSVEvents is a CSV with one row per captured event.
	FormatCSVEvents
	// FormatCSVPDUs is a CSV with one row per dissected signaling PDU.
	FormatCSVPDUs
)

func ParseFormat

func ParseFormat(s string) (Format, error)

ParseFormat maps a -format flag value to a Format. csv defaults to the summary shape; use "csv-events" for the per-event shape.

type GrantRecord

type GrantRecord struct {
	OffsetSec   float64 `json:"offset_sec" yaml:"offset_sec"`
	GroupID     uint32  `json:"group_id" yaml:"group_id"`
	SourceID    uint32  `json:"source_id" yaml:"source_id"`
	ChannelID   uint8   `json:"channel_id" yaml:"channel_id"`
	ChannelNum  uint16  `json:"channel_num" yaml:"channel_num"`
	Timeslot    uint8   `json:"timeslot" yaml:"timeslot"`
	FrequencyHz uint32  `json:"frequency_hz" yaml:"frequency_hz"`
	Encrypted   bool    `json:"encrypted" yaml:"encrypted"`
	// AlgorithmID / KeyID mirror trunking.Grant: the encryption algorithm id and
	// key selector when Encrypted (0 / 0 otherwise). They let the survey name the
	// encryption type (e.g. AES-256, ADP/RC4) rather than just "encrypted".
	AlgorithmID uint8  `json:"algorithm_id,omitempty" yaml:"algorithm_id,omitempty"`
	KeyID       uint16 `json:"key_id,omitempty" yaml:"key_id,omitempty"`
	Emergency   bool   `json:"emergency" yaml:"emergency"`
	// Individual mirrors trunking.Grant.Individual: the GroupID is a
	// unit-to-unit / telephone / data destination, not a talkgroup. Hunt
	// skips these when accumulating the talkgroup list.
	Individual bool `json:"individual,omitempty" yaml:"individual,omitempty"`
}

GrantRecord mirrors trunking.Grant, flattened with the stream-time offset at which it was observed.

type IQPoint added in v0.3.3

type IQPoint struct {
	I float32 `json:"i"`
	Q float32 `json:"q"`
}

IQPoint is one complex sample on the wire. Float32 keeps the captured stream compact; web consumers render it directly without conversion. The JSON tags match internal/api's IQPoint (the live Constellation panel) so the offline siglab taps and the live diag stream share a wire shape.

type IQTaps added in v0.3.3

type IQTaps struct {
	// DecimatedIQ is the channelized IQ sampled every Stride samples.
	DecimatedIQ []IQPoint `json:"decimated_iq" yaml:"decimated_iq"`
	// SoftSamples is the pre-slicer soft-sample stream (deep P25 path);
	// empty for protocols/paths that do not emit soft samples.
	SoftSamples []float32 `json:"soft_samples" yaml:"soft_samples"`
	// DecimatedRateHz is the effective sample rate of DecimatedIQ
	// (PipelineRate / Stride).
	DecimatedRateHz float64 `json:"decimated_rate_hz" yaml:"decimated_rate_hz"`
	// Stride is the input-to-output downsample ratio applied to the
	// channelized stream.
	Stride int `json:"stride" yaml:"stride"`

	// SymbolDibits is the recovered-symbol decision stream (values
	// 0..SymbolCardinality-1), decimated to the same cap as the IQ. It
	// backs the offline Symbol-scope viz (the OP25-style time series).
	// Empty for protocols/paths that buffer no symbols.
	SymbolDibits []uint8 `json:"symbol_dibits,omitempty" yaml:"symbol_dibits,omitempty"`
	// SymbolSoft is the pre-slicer soft waveform aligned index-for-index
	// with SymbolDibits (deep P25 C4FM path). Empty when the demod path
	// emits no soft samples (e.g. CQPSK); the viz then shows the dibit
	// rows only.
	SymbolSoft []float32 `json:"symbol_soft,omitempty" yaml:"symbol_soft,omitempty"`
	// SymbolCardinality is 4 for the dibit (4-level) protocols, 2 for the
	// bit (2-level) protocols.
	SymbolCardinality int `json:"symbol_cardinality,omitempty" yaml:"symbol_cardinality,omitempty"`

	// DiffPhase is the per-symbol differential phase (radians, in (-π, π]) of
	// the complex constellation — angle(z[i]·conj(z[i-1])) — decimated to the
	// same cap as the IQ. It is the rotation signal that backs the offline
	// rotation-tracker viz: a π/4-DQPSK control channel clusters these around
	// ±π/4 and ±3π/4. Populated only on the CQPSK / π4-DQPSK path (the deep
	// constellation buffer); empty for C4FM and the 2-level paths.
	DiffPhase []float32 `json:"diff_phase,omitempty" yaml:"diff_phase,omitempty"`
}

IQTaps is the optional decimated signal capture attached to a Result when Config.CaptureIQ is set. It carries a stride-decimated copy of the post-DDC (channelized) IQ — what the constellation, spectrogram and PSD views render — plus the pre-slicer soft samples (deep P25 C4FM path) the eye diagram folds. Both slices are independently hard-capped at Config.CaptureIQMaxPoints, so the taps never grow without bound and never ship full-rate IQ.

It is deliberately heavy (potentially MBs), so the API/export layer strips it from the Result before serializing the summary or running the CSV/JSON exporters; it is retrieved separately (GET .../iq) when a view needs it.

type IdentifyConfig

type IdentifyConfig struct {
	SampleRateHz float64
	Format       SampleFormat
	AutoTune     bool
	Conjugate    bool
	IQCorrect    bool
	// MaxSamples caps the input samples each candidate processes (0 ⇒ whole
	// capture). Set it to scan a fast prefix when identifying.
	MaxSamples int64
	// Candidates restricts the protocols tried. nil ⇒ every registered
	// protocol whose channel rate fits the capture sample rate.
	Candidates []trunking.Protocol
	Log        *slog.Logger
}

IdentifyConfig configures a signal-identification scan over a capture.

type IdentifyResult

type IdentifyResult struct {
	Source       string  `json:"source"`
	SampleRateHz float64 `json:"sample_rate_hz"`
	Winner       string  `json:"winner"`
	Confidence   float64 `json:"confidence"`
	Inconclusive bool    `json:"inconclusive"`
	// TuneHz is the carrier offset the winning candidate locked at — the one
	// the decode should reuse (under -auto-tune this may be an off-centre,
	// non-dominant carrier, not the band's loudest). 0 when no shift was used.
	TuneHz     float64          `json:"tune_hz"`
	Candidates []CandidateScore `json:"candidates"`

	// Blind / ReferenceMatches are the offline-signal-ID fallback, populated
	// only when the decode-based identification is Inconclusive: the blind
	// symbol-rate/modulation estimate and the reference-database candidates it
	// ranks (so an undecodable carrier is still named a best guess).
	Blind            *blind.BlindEstimate `json:"blind,omitempty"`
	ReferenceMatches []sigref.Match       `json:"reference_matches,omitempty"`
}

IdentifyResult ranks the candidates and names the most likely protocol.

func Identify

func Identify(path string, cfg IdentifyConfig) (*IdentifyResult, error)

Identify scans path against candidate protocols and returns a ranked result. Each candidate is run through the engine — over a bounded prefix when MaxSamples is set — and scored on lock + sync-landscape evidence + FEC pass rate. The winner's run result is retained for WinnerResult.

It is a thin wrapper over IdentifyReader: the file is opened once and each candidate seeks back to the start, so the capture is read from disk a single time rather than re-opened per protocol.

func IdentifyIQ added in v0.3.5

func IdentifyIQ(iq []complex64, source string, cfg IdentifyConfig) (*IdentifyResult, error)

IdentifyIQ identifies an in-memory IQ buffer without any disk round-trip. It encodes the samples once in cfg.Format and delegates to IdentifyReader. This is the path the live hunt sweeper uses: capture a short slice off the SDR at a candidate frequency, then identify it in memory.

func IdentifyReader added in v0.3.5

func IdentifyReader(r io.ReadSeeker, source string, cfg IdentifyConfig) (*IdentifyResult, error)

IdentifyReader scans the capture read from r against candidate protocols and returns a ranked result. r is read once per candidate and rewound between candidates via Seek, so the underlying bytes are materialized a single time (an *os.File or *bytes.Reader both satisfy io.ReadSeeker, and the seek keeps AutoTune working). It is the shared core behind Identify and IdentifyIQ.

func (*IdentifyResult) WinnerProtocol

func (r *IdentifyResult) WinnerProtocol() (trunking.Protocol, error)

WinnerProtocol parses the winning protocol name back to a trunking.Protocol.

func (*IdentifyResult) WinnerResult

func (r *IdentifyResult) WinnerResult() *Result

WinnerResult returns the run Result for the winning candidate (the prefix scan), or nil. Callers wanting a full-capture analysis should re-run siglab.Run with MaxSamples=0 for the winning protocol.

type LockInfo

type LockInfo struct {
	FrequencyHz uint32         `json:"frequency_hz" yaml:"frequency_hz"`
	Fields      map[string]any `json:"fields" yaml:"fields"`
}

LockInfo is the flattened, protocol-agnostic view of a KindCCLocked payload: the frequency every LockState carries, plus a Fields map of the remaining protocol-specific fields (NAC/DUID, ColorCode/SystemID, MCC/MNC, RAN, …) so one struct represents all 13 protocols' distinct LockState types.

type Metadata

type Metadata struct {
	// Protocol is the CLI/config protocol name (p25, p25-phase2, dmr,
	// dmr-tier2, nxdn, dpmr, edacs, motorola, ltr, mpt1327, tetra, ysf,
	// dstar; the replay aliases p25p1/dmr-tier3 are also accepted).
	Protocol string `json:"protocol" yaml:"protocol"`
	// Source / ToolCrossCheck are free-text provenance (where the capture
	// came from, which reference receiver it was validated against).
	Source         string `json:"source,omitempty" yaml:"source,omitempty"`
	ToolCrossCheck string `json:"tool_cross_check,omitempty" yaml:"tool_cross_check,omitempty"`
	// SampleRateHz is required — a raw cfile/u8 carries no rate of its own.
	SampleRateHz float64 `json:"sample_rate_hz" yaml:"sample_rate_hz"`
	// CenterFreqHz is informational (the capture's nominal centre).
	CenterFreqHz uint32 `json:"center_freq_hz,omitempty" yaml:"center_freq_hz,omitempty"`
	// Format is the on-disk encoding (u8|f32); empty defaults to u8.
	Format string `json:"format,omitempty" yaml:"format,omitempty"`
	// TuneHz / AutoTune / Conjugate / IQCorrect mirror the engine knobs for
	// captures that need them.
	TuneHz    float64 `json:"tune_hz,omitempty" yaml:"tune_hz,omitempty"`
	AutoTune  bool    `json:"auto_tune,omitempty" yaml:"auto_tune,omitempty"`
	Conjugate bool    `json:"conjugate,omitempty" yaml:"conjugate,omitempty"`
	IQCorrect bool    `json:"iq_correct,omitempty" yaml:"iq_correct,omitempty"`
	// System carries per-protocol decoder knobs by their YAML config-key
	// names (e.g. "tetra_colour_code": "1", "p25_phase1_demod_mode":
	// "cqpsk"), applied onto the trunking.System the engine drives.
	System map[string]string `json:"system,omitempty" yaml:"system,omitempty"`
	// Expected is the acceptance contract graded against the decode.
	Expected Acceptance `json:"expected" yaml:"expected"`
}

Metadata is the sidecar that accompanies a capture (real-air or synthesized) and describes how to decode it plus what a correct decode must produce. It generalizes the ad-hoc per-test loaders and the acceptance-criteria documented in samples/README.md into one schema the `test` harness consumes. It loads from JSON or YAML.

func LoadMetadata

func LoadMetadata(path string) (*Metadata, error)

LoadMetadata reads a Metadata document from path (JSON or YAML, chosen by extension; .json → JSON, everything else → YAML).

func Synthesize

func Synthesize(opts SynthOptions) ([]complex64, *Metadata, error)

Synthesize builds a known-good (optionally impaired) capture for a protocol and returns the IQ plus the Metadata describing how to decode and grade it. Returns an error when no synthesis fixture is registered for the protocol (see Fixtures for the supported set).

func (*Metadata) Config

func (m *Metadata) Config(collectIQDiag bool) (Config, error)

Config builds an engine Config from the metadata for the given capture. collectIQDiag is threaded through because the harness wants the analyzer's decode-error rate available to the verdict.

type NIDDecode

type NIDDecode struct {
	Pos  int    `json:"pos" yaml:"pos"`
	Errs int    `json:"errs" yaml:"errs"`
	NAC  uint16 `json:"nac" yaml:"nac"`
	DUID uint8  `json:"duid" yaml:"duid"`
	OK   bool   `json:"ok" yaml:"ok"`
}

NIDDecode is one NID BCH decode attempt at an FSW hit.

type NeighborRef added in v0.3.5

type NeighborRef = trunking.TopoNeighborRef

NeighborRef is an adjacent site. See trunking.TopoNeighborRef.

type P25P1Detail

type P25P1Detail struct {
	DibitsBuffered  int             `json:"dibits_buffered" yaml:"dibits_buffered"`
	DibitHistogram  [4]int64        `json:"dibit_histogram" yaml:"dibit_histogram"`
	Rotations       [4]RotationStat `json:"rotations" yaml:"rotations"`
	WinningRotation int             `json:"winning_rotation" yaml:"winning_rotation"`
	WinningHits     int             `json:"winning_hits" yaml:"winning_hits"`
	// NIDDecodes are the NAC/DUID results from BCH-decoding the NID at the
	// cleanest (distance-0) FSW hits under the winning rotation. A mix of
	// successes and failures points at signal quality; zero successes
	// despite perfect FSW alignment points at framing.
	NIDDecodes []NIDDecode `json:"nid_decodes" yaml:"nid_decodes"`

	// CCStats is the per-frame NID/TSBK outcome breakdown the control channel
	// accumulates (the replay EOF summary's "nid trusted/marginal/…" lines).
	// Non-nil only on the deep path.
	CCStats *CCStatsBreakdown `json:"cc_stats,omitempty" yaml:"cc_stats,omitempty"`
	// SoftEye is the pre-slicer soft-sample analysis (DC, per-rail
	// distribution, true-symbol outer-rail eye + mechanism verdict). Non-nil
	// only when soft samples were captured (the deep C4FM path).
	SoftEye *SoftEye `json:"soft_eye,omitempty" yaml:"soft_eye,omitempty"`
	// ReceiverStates is the per-(stream-)second receiver-state series the
	// deep path captures when Config.CollectReceiverState is set.
	ReceiverStates []ReceiverState `json:"receiver_states,omitempty" yaml:"receiver_states,omitempty"`
}

P25P1Detail is the P25-Phase-1-specific demod deep-dive, surfaced in Result.P25P1 when the protocol is P25 Phase 1 and CollectIQDiag is set. It reuses the dibit-domain analysis the historical iqdiag report pioneered (issue #275): a per-rotation Frame-Sync-Word correlation landscape that tells whether the demod produces canonical dibits at all and which rotation aligns the stream, plus NID decode attempts at the cleanest FSW hits. It operates purely on the recovered dibit stream (no soft samples), so it is available through the generic SymbolTap for every P25 P1 capture.

type PDURecord added in v0.5.8

type PDURecord struct {
	Seq        int            `json:"seq" yaml:"seq"`
	OffsetSec  float64        `json:"offset_sec" yaml:"offset_sec"`
	Protocol   string         `json:"protocol" yaml:"protocol"`
	Opcode     uint8          `json:"opcode" yaml:"opcode"`
	OpcodeName string         `json:"opcode_name" yaml:"opcode_name"`
	MFID       uint8          `json:"mfid,omitempty" yaml:"mfid,omitempty"`
	NAC        uint16         `json:"nac,omitempty" yaml:"nac,omitempty"`
	SourceID   uint32         `json:"source_id,omitempty" yaml:"source_id,omitempty"`
	DestID     uint32         `json:"dest_id,omitempty" yaml:"dest_id,omitempty"`
	Talkgroup  uint32         `json:"talkgroup,omitempty" yaml:"talkgroup,omitempty"`
	Fields     map[string]any `json:"fields,omitempty" yaml:"fields,omitempty"`
	RawHex     string         `json:"raw_hex" yaml:"raw_hex"`
	CRCOK      bool           `json:"crc_ok" yaml:"crc_ok"`
	FECMetric  int            `json:"fec_metric" yaml:"fec_metric"`
	DibitStart int            `json:"dibit_start" yaml:"dibit_start"`
	DibitLen   int            `json:"dibit_len" yaml:"dibit_len"`
}

PDURecord is one decoded (or attempted) data/signaling PDU, surfaced for SigLab's data-over-RF inspector. It is a flattened, export-friendly view of a decoder SignalingBlock with the common entity IDs promoted out of Fields for filtering.

type ProtocolDetail

type ProtocolDetail struct {
	Protocol           string         `json:"protocol" yaml:"protocol"`
	SymbolsBuffered    int            `json:"symbols_buffered" yaml:"symbols_buffered"`
	SymbolCardinality  int            `json:"symbol_cardinality" yaml:"symbol_cardinality"`
	SymbolHistogram    []int64        `json:"symbol_histogram" yaml:"symbol_histogram"`
	SymbolHistogramPct []float64      `json:"symbol_histogram_pct" yaml:"symbol_histogram_pct"`
	Sync               *SyncLandscape `json:"sync,omitempty" yaml:"sync,omitempty"`
	FEC                []FECStat      `json:"fec,omitempty" yaml:"fec,omitempty"`
	Notes              string         `json:"notes,omitempty" yaml:"notes,omitempty"`
}

ProtocolDetail is the protocol-specific deep dive for every protocol other than P25 Phase 1 (which keeps its richer P25P1Detail with soft eye + receiver state). It is computed from the buffered recovered-symbol stream: a symbol histogram, a sync-correlation landscape against the protocol's own sync word(s), and — where tractable — a per-stage FEC-decode tally. This is the symbol-domain analog of P25's FSW landscape + NID-BCH probe, the most valuable half of a deep dive, available without any receiver changes.

type RailStat

type RailStat struct {
	Label string  `json:"label" yaml:"label"`
	N     int     `json:"n" yaml:"n"`
	Mean  float64 `json:"mean" yaml:"mean"`
	Std   float64 `json:"std" yaml:"std"`
	P10   float64 `json:"p10" yaml:"p10"`
	P50   float64 `json:"p50" yaml:"p50"`
	P90   float64 `json:"p90" yaml:"p90"`
}

RailStat is the distribution of soft samples on one decision rail.

type ReceiverState

type ReceiverState struct {
	TimeSec float64 `json:"time_sec" yaml:"time_sec"`
	CQPSK   bool    `json:"cqpsk" yaml:"cqpsk"`

	// C4FM path.
	AFCHzEst         float64    `json:"afc_hz_est" yaml:"afc_hz_est"`
	AGCLevel         float64    `json:"agc_level" yaml:"agc_level"`
	AGCTarget        float64    `json:"agc_target" yaml:"agc_target"`
	MMMu             float64    `json:"mm_mu" yaml:"mm_mu"`
	MMSPS            float64    `json:"mm_sps" yaml:"mm_sps"`
	DDAActive        bool       `json:"dda_active" yaml:"dda_active"`
	SlicerLevels     [4]float64 `json:"slicer_levels" yaml:"slicer_levels"`
	SlicerThresholds [3]float64 `json:"slicer_thresholds" yaml:"slicer_thresholds"`

	// CQPSK path.
	CarrierHzEst float64 `json:"carrier_hz_est,omitempty" yaml:"carrier_hz_est,omitempty"`
	GardnerMu    float64 `json:"gardner_mu,omitempty" yaml:"gardner_mu,omitempty"`
	GardnerSPS   float64 `json:"gardner_sps,omitempty" yaml:"gardner_sps,omitempty"`
	CQPSKAGCGain float64 `json:"cqpsk_agc_gain,omitempty" yaml:"cqpsk_agc_gain,omitempty"`
	CMAError     float64 `json:"cma_error,omitempty" yaml:"cma_error,omitempty"`
}

ReceiverState is one snapshot of the P25 receiver's internal loops at a point in stream time. C4FM and CQPSK populate disjoint field sets (the other path's fields read zero); CQPSK is true on the CQPSK path.

type Result

type Result struct {
	// Provenance.
	Source         string  `json:"source" yaml:"source"`
	Protocol       string  `json:"protocol" yaml:"protocol"`
	SampleRateHz   float64 `json:"sample_rate_hz" yaml:"sample_rate_hz"`
	PipelineRateHz float64 `json:"pipeline_rate_hz" yaml:"pipeline_rate_hz"`
	TuneHz         float64 `json:"tune_hz" yaml:"tune_hz"`
	DurationSec    float64 `json:"duration_sec" yaml:"duration_sec"`

	// Throughput.
	TotalSamples     int64   `json:"total_samples" yaml:"total_samples"`
	Symbols          int64   `json:"symbols" yaml:"symbols"`
	EffectiveBaud    float64 `json:"effective_baud" yaml:"effective_baud"`
	ExpectedBaud     float64 `json:"expected_baud" yaml:"expected_baud"`
	BaudDeviationPct float64 `json:"baud_deviation_pct" yaml:"baud_deviation_pct"`

	// Lock.
	Locked         bool      `json:"locked" yaml:"locked"`
	LockLatencySec float64   `json:"lock_latency_sec" yaml:"lock_latency_sec"`
	Lock           *LockInfo `json:"lock,omitempty" yaml:"lock,omitempty"`

	// Grants + events.
	Grants       []GrantRecord  `json:"grants" yaml:"grants"`
	Events       []EventRecord  `json:"events" yaml:"events"`
	EventCounts  map[string]int `json:"event_counts" yaml:"event_counts"`
	DecodeErrors map[string]int `json:"decode_errors" yaml:"decode_errors"`

	// Analysis (nil unless CollectIQDiag).
	Signal *SignalQuality `json:"signal,omitempty" yaml:"signal,omitempty"`
	// Detail is the protocol-specific deep dive (a *P25P1Detail, *DMRDetail,
	// *NXDNDetail, …) populated when CollectIQDiag is set and a per-protocol
	// detail builder exists. Consumers type-switch on it (keyed by Protocol).
	Detail any `json:"detail,omitempty" yaml:"detail,omitempty"`

	// Topology is the protocol-neutral system topology accumulated from the
	// control channel during the run — identity (WACN/SYSID/RFSS/Site or the
	// per-protocol equivalent), neighbor sites, and band plan. It is the only
	// path by which a caller learns WACN/SYSID/RFSS/Site, which never appear in
	// event payloads (they live in the decoder's network model). nil when the
	// protocol's pipeline does not implement TopologyProvider.
	Topology *TopologySnapshot `json:"topology,omitempty" yaml:"topology,omitempty"`

	// Verdict (nil unless Config.Acceptance supplied).
	Verdict *Verdict `json:"verdict,omitempty" yaml:"verdict,omitempty"`

	// NoLockReason is a human-readable classification of why the control
	// channel did not lock, populated only when !Locked. It turns a bare "did
	// not lock" into a named cause — SNR-limited capture / frame sync not
	// aligned / not the control channel / no signal — from the demod and
	// frame-decode metrics already gathered, so a capture-quality failure is
	// not misread as a tuning or AGC bug (issues #764 / #771).
	NoLockReason string `json:"no_lock_reason,omitempty" yaml:"no_lock_reason,omitempty"`

	// IQTaps is the optional decimated signal capture (channelized IQ +
	// pre-slicer soft samples) populated when Config.CaptureIQ is set. It
	// backs the web visualization views (constellation, spectrogram, PSD,
	// eye diagram). It can be large, so the API/export layer strips it
	// before serializing the summary or running the exporters and serves
	// it from a dedicated endpoint instead.
	IQTaps *IQTaps `json:"iq_taps,omitempty" yaml:"iq_taps,omitempty"`

	// PDUs is the optional per-signaling-block (TSBK) dissection populated when
	// Config.CollectPDUs is set — the data-over-RF inspector feed. Capped at
	// pduMaxRecords; PDUsTruncated marks an overflow. P25 Phase 1 only for now.
	PDUs          []PDURecord `json:"pdus,omitempty" yaml:"pdus,omitempty"`
	PDUsTruncated bool        `json:"pdus_truncated,omitempty" yaml:"pdus_truncated,omitempty"`
}

Result is the protocol-agnostic structured outcome of one engine run. It is the single model every exporter (JSON / JSONL / YAML / CSV) and the acceptance harness render — populated identically for all 13 protocols.

func Run

func Run(path string, cfg Config) (*Result, error)

Run decodes the capture at path through the production pipeline for cfg.Protocol and returns the structured Result. It is the batch entry point behind the replay/analyze/test subcommands.

func RunAutoTuneMulti added in v0.4.3

func RunAutoTuneMulti(path string, cfg Config, maxCandidates int) (*Result, error)

RunAutoTuneMulti opens the capture at path and decodes cfg.Protocol against each detected carrier candidate, returning the strongest lock (see RunReaderAutoTuneMulti). It is the file-path entry the replay/analyze subcommands use for multi-candidate -auto-tune.

func RunReader added in v0.3.3

func RunReader(r io.Reader, source string, cfg Config) (*Result, error)

RunReader decodes the capture read from r through the production pipeline for cfg.Protocol and returns the structured Result. source is recorded as the Result's provenance (its base name). It is the in-memory sibling of Run: a synthesized signal, an uploaded HTTP body, or any io.Reader can be analyzed without a disk round-trip. AutoTune requires r to also satisfy io.ReadSeeker (an *os.File or *bytes.Reader does).

func RunReaderAutoTuneMulti added in v0.4.3

func RunReaderAutoTuneMulti(r io.ReadSeeker, source string, cfg Config, maxCandidates int) (*Result, error)

RunReaderAutoTuneMulti decodes cfg.Protocol against each detected carrier candidate (each tuned to a fixed offset, resolved here rather than re-estimated) and returns the Result with the strongest lock. It is the multi-candidate counterpart to a single -auto-tune run: when the control channel is off-centre AND not the loudest carrier in a wideband capture, the single dominant-carrier estimate misses it; this tries the ranked candidates strongest-first and stops at the first that locks (or returns the most promising failure). r must be an io.ReadSeeker; cfg.AutoTune is implied and cfg.TuneHz is ignored. maxCandidates ≤ 0 uses the package default.

func RunReaderMonitor added in v0.4.7

func RunReaderMonitor(r io.Reader, source string, cfg Config, tick time.Duration, onTick func(*TopologySnapshot, float64) bool) (*Result, error)

RunReaderMonitor decodes a (typically live, effectively unbounded) stream, invoking onTick every tick of stream time with the current accumulated topology so the caller can converge-and-stop a long control-channel dwell. It is the streaming sibling of RunReader: memory stays bounded (chunked reads, nothing is buffered whole), and the run ends when onTick returns true, the reader hits EOF, or a read errors. The carrier must already be tuned via cfg.TuneHz — auto-tune (which needs to seek) is not supported.

func RunReaderStream added in v0.3.3

func RunReaderStream(r io.Reader, source string, cfg Config, onEvent func(EventRecord)) (*Result, error)

RunReaderStream is RunReader with a live per-event sink (see RunStream). It is the single decode path RunStream and the in-memory callers share.

func RunStream

func RunStream(path string, cfg Config, onEvent func(EventRecord)) (*Result, error)

RunStream is Run with a live per-event sink: onEvent (when non-nil) is called for every captured EventRecord as it is observed, in stream order. It backs the JSONL exporter and the TUI's live event feed. The full Result is still returned at EOF.

type RotationStat

type RotationStat struct {
	BestDist int `json:"best_dist" yaml:"best_dist"`
	BestPos  int `json:"best_pos" yaml:"best_pos"`
	Hits     int `json:"hits" yaml:"hits"` // positions with distance ≤ tolerance
}

RotationStat summarizes the FSW Hamming-distance landscape for one of the four cyclic dibit rotations.

type SampleDecoder

type SampleDecoder func(buf []byte, out []complex64)

SampleDecoder converts a byte chunk to complex64 IQ in-place into out.

type SampleFormat

type SampleFormat int

SampleFormat is the on-disk IQ encoding of a capture file.

const (
	// FormatU8 is rtl_sdr's 8-bit unsigned interleaved IQ.
	FormatU8 SampleFormat = iota
	// FormatF32 is GNU Radio's interleaved little-endian float32 cfile.
	FormatF32
	// FormatWAV is a two-channel 16-bit signed PCM RIFF/WAVE baseband
	// recording (I in channel 1, Q in channel 2) — the layout GopherTrunk's
	// own IQWriter and SDRtrunk/SDR++ write. The sample rate is read from the
	// WAV header, overriding -sample-rate, and the 44-byte header is skipped
	// before decoding.
	FormatWAV
)

func ParseSampleFormat

func ParseSampleFormat(s string) (SampleFormat, error)

ParseSampleFormat maps a -format flag value to a SampleFormat. It accepts the same spellings the replay subcommand always has (u8, f32, plus the float32/cfile aliases) so existing muscle memory keeps working, plus wav (aka sw16) for two-channel 16-bit baseband recordings.

func (SampleFormat) Decoder

func (f SampleFormat) Decoder() (SampleDecoder, int)

Decoder returns the sample decoder + bytes-per-IQ-pair for the format. This is the shared replacement for replay.go's pickSampleDecoder / decodeU8Replay / decodeF32Replay so the replay subcommand and the siglab engine read captures through one implementation.

func (SampleFormat) String

func (f SampleFormat) String() string

String renders the format as the flag value operators type.

type SignalQuality

type SignalQuality struct {
	// SymbolCardinality is 4 for the dibit (4-level) protocols and 2 for
	// the bit (2-level) protocols.
	SymbolCardinality int `json:"symbol_cardinality" yaml:"symbol_cardinality"`
	// SymbolHistogram counts recovered symbols per value (len ==
	// SymbolCardinality). A clean 4-level C4FM control channel sits near
	// 25% per bin; a near-empty bin means the slicer collapsed, a single
	// dominant bin means the signal is below the slicer thresholds.
	SymbolHistogram []int64 `json:"symbol_histogram" yaml:"symbol_histogram"`
	// SymbolHistogramPct is SymbolHistogram normalised to percentages.
	SymbolHistogramPct []float64 `json:"symbol_histogram_pct" yaml:"symbol_histogram_pct"`

	// Raw (pre-DDC) front-end I/Q imbalance. A clean front-end is balanced
	// (≈0 dB gain, ≈0° phase) with image rejection ≳ 40 dB. Populated only
	// when raw IQ was observed.
	IQGainImbalanceDB   float64 `json:"iq_gain_imbalance_db" yaml:"iq_gain_imbalance_db"`
	IQPhaseImbalanceDeg float64 `json:"iq_phase_imbalance_deg" yaml:"iq_phase_imbalance_deg"`
	IQImageRejectionDB  float64 `json:"iq_image_rejection_db" yaml:"iq_image_rejection_db"`
	IQObserved          bool    `json:"iq_observed" yaml:"iq_observed"`

	// RMSPowerDBFS is the raw-capture RMS power in dBFS (20·log10 of the RMS
	// amplitude; 0 dBFS = a full-scale signal). It is the capture-level power
	// figure the `spectrum` CLI reports; populated only when raw IQ was observed.
	RMSPowerDBFS float64 `json:"rms_power_dbfs" yaml:"rms_power_dbfs"`

	// DecodeErrorRate is decode-error events per recovered 1000 symbols — a
	// protocol-neutral proxy for FEC stress.
	DecodeErrorRate float64 `json:"decode_error_rate_per_ksym" yaml:"decode_error_rate_per_ksym"`

	// Demod is the demodulator-quality measurement (EVM + estimated SNR)
	// derived from the per-symbol soft samples on the P25 deep path. Nil for
	// protocols / runs that do not surface the soft stream. It is what turns
	// "the audio sounds bad" into "the eye is N% open / the demod is M dB from
	// the noise floor."
	Demod *DemodMetrics `json:"demod,omitempty" yaml:"demod,omitempty"`
}

SignalQuality is the protocol-agnostic demod-quality summary the analyzer produces when Config.CollectIQDiag is set. It generalizes the parts of the historical P25-only iqdiag report that apply to every protocol: the recovered-symbol distribution (which exposes a collapsed or mis-calibrated slicer) and the raw front-end I/Q imbalance (the leading cause of an asymmetric eye). The deeper P25-specific FSW/NID landscape lives in P25P1Detail.

type SoftEye

type SoftEye struct {
	SoftSamples  int     `json:"soft_samples" yaml:"soft_samples"`
	SignedMeanDC float64 `json:"signed_mean_dc" yaml:"signed_mean_dc"`
	StdDev       float64 `json:"std_dev" yaml:"std_dev"`
	MeanAbs      float64 `json:"mean_abs" yaml:"mean_abs"`
	MaxAbs       float64 `json:"max_abs" yaml:"max_abs"`
	// MagnitudeHistogram bins |soft| into 10 deciles of MaxAbs — bimodal ⇒ a
	// clean 4-level eye, unimodal-saturated ⇒ everything piled near ±max.
	MagnitudeHistogram [10]int64 `json:"magnitude_histogram" yaml:"magnitude_histogram"`
	// PerDecidedSymbol is the soft distribution grouped by the slicer's
	// decision (rails -3,-1,+1,+3). Clean ⇒ symmetric, |outer| ≈ 3·|inner|.
	PerDecidedSymbol []RailStat `json:"per_decided_symbol" yaml:"per_decided_symbol"`
	// TrueOuterRail is the slicer-independent outer-rail (+3,-3) distribution
	// from the known FSW symbols.
	TrueOuterRail []RailStat `json:"true_outer_rail" yaml:"true_outer_rail"`
	// OuterSpread splits each true outer rail into steady vs post-transition
	// standard deviation — the ISI / timing discriminator.
	OuterSpread []SpreadStat `json:"outer_spread" yaml:"outer_spread"`
	// Verdict points at the dominant outer-spread mechanism.
	Verdict string `json:"verdict" yaml:"verdict"`
}

SoftEye is the structured pre-slicer soft-sample analysis for the P25 deep C4FM path — the part of the historical iqdiag report that worked on the matched-filter output. It exposes the eye-skew the dibit histogram folds away (issue #402): the DC offset, the per-decided-symbol rail distribution, and the slicer-independent true-outer-rail eye with a mechanism verdict.

type SpreadStat

type SpreadStat struct {
	Label     string  `json:"label" yaml:"label"`
	SteadyStd float64 `json:"steady_std" yaml:"steady_std"`
	SteadyN   int     `json:"steady_n" yaml:"steady_n"`
	TransStd  float64 `json:"trans_std" yaml:"trans_std"`
	TransN    int     `json:"trans_n" yaml:"trans_n"`
	Ratio     float64 `json:"ratio" yaml:"ratio"`
}

SpreadStat compares steady-state vs post-transition spread on one outer rail.

type SurveySpectrum added in v0.4.3

type SurveySpectrum struct {
	CenterHz     uint32    `json:"center_hz" yaml:"center_hz"`
	SampleRateHz uint32    `json:"sample_rate_hz" yaml:"sample_rate_hz"`
	Bins         []float32 `json:"bins" yaml:"bins"`
}

SurveySpectrum is the Welch-averaged power spectrum of the whole band, in the same FFT-shifted dBFS layout as spectrum.Frame: Bins[0] is the lowest frequency (CenterHz - SampleRateHz/2) and successive bins step by SampleRateHz/len(Bins). Populated only when WidebandConfig.CollectSpectrum is set; it lets a UI plot the band with the detected carriers marked.

type SyncLandscape

type SyncLandscape struct {
	SyncLen        int        `json:"sync_len" yaml:"sync_len"`
	Tolerance      int        `json:"tolerance" yaml:"tolerance"`
	Stats          []SyncStat `json:"stats" yaml:"stats"`
	WinnerVariant  string     `json:"winner_variant" yaml:"winner_variant"`
	WinnerRotation int        `json:"winner_rotation" yaml:"winner_rotation"`
	WinnerHits     int        `json:"winner_hits" yaml:"winner_hits"`
	// ModalSpacing is the most common symbol delta between consecutive clean
	// hits under the winner — a real control channel emits sync at a fixed
	// frame interval, so a single dominant spacing means genuine frames while
	// scattered spacings mean false positives.
	ModalSpacing int `json:"modal_spacing" yaml:"modal_spacing"`
	// contains filtered or unexported fields
}

SyncLandscape summarizes sync-word correlation across a recovered-symbol stream — the generalization of P25's FSW-correlation landscape. It answers "does the demod produce canonical symbols, which sync/polarity aligns the stream, and at what cadence" for any protocol.

type SyncStat

type SyncStat struct {
	Variant  string `json:"variant" yaml:"variant"`
	Rotation int    `json:"rotation" yaml:"rotation"`
	BestDist int    `json:"best_dist" yaml:"best_dist"`
	BestPos  int    `json:"best_pos" yaml:"best_pos"`
	Hits     int    `json:"hits" yaml:"hits"`
}

SyncStat is the best correlation of one (variant, rotation) against the recovered-symbol stream. rotation is the cyclic symbol offset applied to the stream before comparison: for 4-level streams rotation 2 is the discriminator-polarity flip and 1/3 the I/Q-swap rotations; for 2-level streams rotation 1 is bit inversion.

type SyncVariant

type SyncVariant struct {
	Name    string  `json:"name" yaml:"name"`
	Pattern []uint8 `json:"-" yaml:"-"`
}

SyncVariant is one named candidate sync pattern (e.g. one of DMR's 9 ETSI sync words, or a protocol's single FSW), as a slice of symbols (dibits in 0..3, or bits in 0..1).

type SynthOptions

type SynthOptions struct {
	Protocol trunking.Protocol
	Format   SampleFormat
	// Impairments applied to the ideal IQ (SNR, carrier offset, DC spike,
	// I/Q imbalance, multipath). The zero value is a clean capture.
	Impairments demod.Impairments
	// Modulation optionally overrides the fixture's default modulator. Empty
	// uses the per-protocol default. For P25 Phase 1, "lsm" (or "cqpsk")
	// selects the linear π/4-DQPSK (LSM) waveform instead of C4FM, so a
	// capture can exercise the linear CQPSK demod path (issue #492).
	Modulation string
}

SynthOptions configures a synthesis run: which protocol to build, the front-end impairments to overlay on the ideal modulator output, and the on-disk capture format.

type SystemRollup added in v0.4.3

type SystemRollup struct {
	Protocol     string `json:"protocol" yaml:"protocol"`
	Tier3        bool   `json:"tier3" yaml:"tier3"`
	SystemID     uint32 `json:"system_id,omitempty" yaml:"system_id,omitempty"`
	ColorCode    uint8  `json:"color_code,omitempty" yaml:"color_code,omitempty"`
	ControlCount int    `json:"control_count" yaml:"control_count"`
	VoiceCount   int    `json:"voice_count" yaml:"voice_count"`
	LoHz         uint32 `json:"lo_hz" yaml:"lo_hz"`
	HiHz         uint32 `json:"hi_hz" yaml:"hi_hz"`
	CarrierIdx   []int  `json:"carrier_idx" yaml:"carrier_idx"`
	Verdict      string `json:"verdict" yaml:"verdict"`
}

SystemRollup clusters carriers into one trunked-system verdict.

type TopologyProvider added in v0.3.5

type TopologyProvider = trunking.TopologyProvider

TopologyProvider is the optional pipeline hook. See trunking.TopologyProvider.

type TopologySnapshot added in v0.3.5

type TopologySnapshot = trunking.TopologySnapshot

TopologySnapshot is the accumulated system topology attached to a Result.Topology. See trunking.TopologySnapshot.

type Verdict

type Verdict struct {
	Pass     bool     `json:"pass" yaml:"pass"`
	Failures []string `json:"failures" yaml:"failures"`
	Checks   []string `json:"checks" yaml:"checks"`
}

Verdict is the pass/fail outcome of grading a Result against an Acceptance.

type WidebandConfig added in v0.4.3

type WidebandConfig struct {
	SampleRateHz       float64             // wideband input rate (required)
	CenterHz           uint32              // absolute capture center (0 ⇒ offsets only)
	Format             SampleFormat        //
	ChannelRateHz      float64             // per-carrier target; 0 ⇒ 48 kHz
	FFTSize            int                 // spectrum FFT; 0 ⇒ 4096
	GuardFrac          float64             // band-edge guard; 0 ⇒ 0.05
	PeakThresholdDb    float32             // 0 ⇒ 10
	MinSpacingHz       uint32              // 0 ⇒ 6.25 kHz
	TunerStrategy      string              // "" | "auto" | "ddc" | "polyphase"
	Candidates         []trunking.Protocol // per-carrier identify set; nil ⇒ all
	IdentifyMaxSamples int64               // per-carrier identify cap; 0 ⇒ whole carrier
	MaxCarriers        int                 // defensive cap on taps; 0 ⇒ no cap
	Conjugate          bool                //
	IQCorrect          bool                //
	// CollectSpectrum retains the Welch-averaged power spectrum on the result
	// (WidebandResult.Spectrum) so callers can plot the band with the detected
	// carriers overlaid. Off by default to keep the offline hunt path lean.
	CollectSpectrum bool         //
	Log             *slog.Logger //
}

WidebandConfig configures a wideband multi-carrier survey: the whole-capture sample rate + format, the channel rate each carrier is decimated to, and the per-carrier identify knobs.

type WidebandResult added in v0.4.3

type WidebandResult struct {
	Source        string  `json:"source" yaml:"source"`
	SampleRateHz  float64 `json:"sample_rate_hz" yaml:"sample_rate_hz"`
	CenterHz      uint32  `json:"center_hz" yaml:"center_hz"`
	ChannelRateHz float64 `json:"channel_rate_hz" yaml:"channel_rate_hz"`
	Strategy      string  `json:"strategy" yaml:"strategy"`
	// Spectrum is the averaged band power spectrum for display; nil unless
	// WidebandConfig.CollectSpectrum was set.
	Spectrum *SurveySpectrum `json:"spectrum,omitempty" yaml:"spectrum,omitempty"`
	// DetectedCount is the number of spectral carrier peaks before the
	// per-carrier identify filter dropped the ones that didn't decode to a
	// real protocol (leakage / noise peaks).
	DetectedCount int             `json:"detected_count" yaml:"detected_count"`
	Carriers      []CarrierResult `json:"carriers" yaml:"carriers"`
	Systems       []SystemRollup  `json:"systems" yaml:"systems"`
}

WidebandResult is the full survey outcome.

func SurveyWideband added in v0.4.3

func SurveyWideband(r io.ReadSeeker, source string, cfg WidebandConfig) (*WidebandResult, error)

SurveyWideband decodes the whole capture from r into memory and surveys it.

func SurveyWidebandIQ added in v0.4.3

func SurveyWidebandIQ(iq []complex64, source string, cfg WidebandConfig) (*WidebandResult, error)

SurveyWidebandIQ surveys an already-decoded wideband buffer: averaged power spectrum → carrier peaks → grid-snap → per-carrier DDC → per-carrier identify → cluster into systems. It is the in-memory core the hunt bridge and tests share.

Directories

Path Synopsis
Package sigref is SigLab's offline signal-identification reference database — a small, curated catalog (Artemis / sigidwiki in spirit) that names a carrier by its band, bandwidth, modulation, and symbol rate even when no decoder locks.
Package sigref is SigLab's offline signal-identification reference database — a small, curated catalog (Artemis / sigidwiki in spirit) that names a carrier by its band, bandwidth, modulation, and symbol rate even when no decoder locks.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL