ccdecoder

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: 46 Imported by: 0

Documentation

Overview

Package ccdecoder is the connector that closes the IQ → control- channel decoder gap listed in the README "Status & known gaps".

What this package does:

  • Owns the control SDR's IQ stream (one StreamIQ loop per Decoder lifetime).
  • Subscribes to events.KindHuntProgress so it learns which system / frequency the CC Hunter supervisor is currently attempting.
  • On every HuntProgress transition, swaps the active per- protocol pipeline (IQ → symbol-domain decoder → CC state machine) via the package-local factory map keyed on trunking.Protocol.
  • Down-converts every raw IQ chunk to a narrowband channel stream — rational polyphase decimation, ~48 kHz for the 4800-baud C4FM family, wider for TETRA — before the pipeline sees it. The per-protocol receivers size their matched filters for this channelized rate, not the raw SDR rate, so this stage is what lets a live 2.048 MHz RTL-SDR stream actually lock (issue #275).
  • Pumps every down-converted IQ chunk through the active pipeline's Process method. The pipeline's CC state machine publishes events.KindCCLocked / events.KindGrant on the same bus, which the supervisor + engine consume to drive the rest of the daemon.

What this package does NOT do:

  • It doesn't retune the SDR — that's the CC Hunter supervisor's job (`internal/scanner/cchunt`). The Decoder follows the supervisor's lead via HuntProgress events.
  • It doesn't open / close the SDR device — the daemon does that and hands a Tuner + IQSource through Options.
  • It doesn't decode every protocol from day one. Each pipeline is gated on having a control-channel state machine that accepts a raw dibit / bit stream. Protocols whose CC state machine still consumes pre-parsed PDUs (DMR / NXDN / dPMR / EDACS / MPT 1327 / LTR / Motorola / P25 P2 / TETRA) need a Process(...) adapter on their control package first — a documented follow-up per the per-protocol receiver PRs that already shipped.

Index

Constants

View Source
const DDCTargetRateHz = ddcTargetRateHz

DDCTargetRateHz is the exported alias of ddcTargetRateHz. The in-package callers keep using the lowercase name for source stability; new external callers (replay, integration tests) use the exported value.

Variables

View Source
var ErrIQStreamClosed = errors.New("ccdecoder: IQ stream closed unexpectedly")

ErrIQStreamClosed is returned by Run whenever the SDR's IQ stream is unexpectedly unavailable while the context is still live — either the channel closed mid-stream (the USB reaper died of an unrecoverable error: host controller hang, ENODEV, EPROTO storm under load) or StreamIQ failed to open at all on a Run retry (the underlying device has physically disconnected and the dead handle now rejects every control transfer with `usb: device disconnected`). Both shapes feed the same restart loop in the daemon; the underlying error is preserved via %w chaining so callers can still inspect the root cause. See issue #345.

Functions

func DDCTargetForProtocol added in v0.3.2

func DDCTargetForProtocol(p trunking.Protocol) float64

DDCTargetForProtocol is the exported wrapper over ddcTargetForProtocol. The offline siglab toolkit (and the replay subcommand) call it to size their own Downconverter to the SAME per-protocol channel rate the daemon uses — critical for TETRA, whose 18000-baud π/4-DQPSK needs the 144 kHz target rather than the 48 kHz C4FM-family default. Sizing replay's DDC to the C4FM target for a TETRA capture would leave under 3 samples per symbol and the Gardner loop would never lock.

func HasFactory added in v0.3.2

func HasFactory(p trunking.Protocol) bool

HasFactory reports whether a pipeline factory is registered for p.

func RegisteredProtocols added in v0.3.2

func RegisteredProtocols() []trunking.Protocol

RegisteredProtocols returns the protocols with a registered pipeline factory, in a stable (ascending Protocol-value) order so callers — the siglab TUI's protocol picker, a gen→test sweep — render a deterministic list.

func SetTestFactory

func SetTestFactory(protocol trunking.Protocol, f PipelineFactory) (restore func())

factories maps a trunking.Protocol to its pipeline factory. Only protocols whose ControlChannel state machine already accepts a raw dibit / bit stream are wired here. Others land in follow-up PRs as the per-protocol Process(...) adapters ship.

The Protocol enum currently lumps P25 Phase 1 and Phase 2 together; this factory targets Phase 1 (the more common deployment + the protocol with a complete IQ → dibits → CC → bus chain shipping today). A future PR splits Phase 1 / Phase 2 once the daemon's config grows a per-system phase selector.

DMR / NXDN / dPMR / EDACS / MPT 1327 / LTR / Motorola Type II / TETRA all have IQ → symbol receivers shipping but their ControlChannel state machines still consume pre-parsed PDUs. Adding `Process(stream, baseIdx)` adapters that buffer + detect sync + frame + dispatch into the existing parsers is a follow-up. SetTestFactory replaces the registered pipeline factory for a single protocol and returns a restore function the caller is expected to defer. INTENDED FOR INTEGRATION TESTS ONLY — the in-package unit tests substitute factories by mutating the unexported map directly. Out-of-package integration tests (e.g. cmd/gophertrunk's end-to-end "lights up live trunked reception" check) need an exported hook so they can pump known-good dibit streams through the daemon's real ccdecoder without owning a working C4FM modulator.

Production code MUST NOT call this — the factory map is initialised once at package load and the daemon assumes it stays stable for the rest of the process lifetime.

Types

type Decoder

type Decoder struct {
	// contains filtered or unexported fields
}

Decoder is the long-lived component that converts the control SDR's IQ stream into CC / grant events on the bus. Construct via New, run via Run.

func New

func New(opts Options) (*Decoder, error)

New constructs a Decoder. Returns an error when required Options are missing.

func (*Decoder) Close

func (d *Decoder) Close() error

Close releases the active pipeline. Safe to call from outside Run; Run also runs Close on the active pipeline as part of normal swap cleanup.

func (*Decoder) IQHealth added in v0.3.1

func (d *Decoder) IQHealth(system string) (trunking.HuntDiagnostics, bool)

IQHealth returns a best-effort snapshot of the control SDR's IQ health for the named system, for the cchunt supervisor to attach to a cchunt.failed event/log. ok is false when the decoder has no observations for that system — most often because the SDR delivered no IQ at all, or a different system is currently being decoded — and the supervisor then publishes its own "no IQ observed" diagnosis.

Satisfies the cchunt.IQHealthProvider interface (structurally; the supervisor decouples via that interface so it doesn't import this package).

func (*Decoder) Run

func (d *Decoder) Run(ctx context.Context) error

Run blocks until ctx cancels. It opens one StreamIQ loop on the control SDR, subscribes to KindHuntProgress, swaps the active per-protocol pipeline whenever the supervisor reports a new (system, frequency) under attempt, and pumps every IQ chunk through the active pipeline.

Returns ctx.Err() on shutdown; any StreamIQ error from the SDR surfaces as the return value.

type Downconverter added in v0.2.5

type Downconverter struct {
	// contains filtered or unexported fields
}

Downconverter decimates a wideband SDR IQ stream to a narrowband channel rate.

Decimation is a rational polyphase resample (dsp.Resampler) whose L/M ratio is chosen so the output rate lands exactly on the requested target for every standard SDR rate. When the SDR already streams at (or below) the target the resampler is skipped and the chunk passes straight through — keeping the rate==target unit tests (and any future low-rate SDR) on a no-op path.

It deliberately does NOT remove the front-end DC offset: a C4FM / FM control channel carries real signal energy at 0 Hz (the FM carrier component), so an IQ-domain DC blocker distorts the very signal being decoded — measured here as a >60% RMS error on a round-tripped C4FM stream. DC-spike handling, when a site needs it, belongs in the frequency domain (a deliberate tuning offset so the channel no longer sits at 0 Hz) or after the FM discriminator (coarse AFC on the real symbol stream); both are tracked as follow-ups to issue #275.

Exported (issue #402 Phase 2) so the gophertrunk replay subcommand can construct an identical down-converter and exactly mirror the production receiver chain instead of feeding the receiver raw wideband IQ — the latter sizes the matched filter and AFC/AGC time constants for 2.4 MHz instead of 48 kHz, so a replayed capture decodes nothing like its live counterpart.

func NewDownconverter added in v0.2.5

func NewDownconverter(inRateHz, targetHz float64) *Downconverter

NewDownconverter builds a down-converter that decimates inRateHz to ~targetHz. The exact achieved output rate is reported by OutRateHz (it equals targetHz for every SDR rate that reduces to a sane L/M, and equals inRateHz in pass-through mode).

func NewDownconverterWithOffset added in v0.2.8

func NewDownconverterWithOffset(inRateHz, targetHz, offsetHz float64) *Downconverter

NewDownconverterWithOffset is NewDownconverter plus a tuning offset: the stream is first frequency-shifted so a channel sitting at +offsetHz lands at 0 Hz, then decimated to ~targetHz. This is the "deliberate tuning offset" the package doc describes — needed to replay a wideband capture whose control channel is not centred (the live pipeline gets this for free from the SDR tuner). A zero offset is identical to NewDownconverter (no NCO is built, so existing centred-capture behaviour is byte-exact).

func (*Downconverter) OutRateHz added in v0.2.5

func (d *Downconverter) OutRateHz() float64

OutRateHz returns the achieved narrowband output rate (equals the requested target for standard SDR rates that reduce to a sane L/M; equals inRateHz in pass-through mode). Callers building a receiver against the downconverter's output should use this value for SampleRateHz so matched-filter sizing matches the actual stream.

func (*Downconverter) Process added in v0.2.5

func (d *Downconverter) Process(dst, raw []complex64) []complex64

Process decimates one raw IQ chunk to the narrowband rate. dst is reused if it has capacity; the returned slice holds the narrowband output (len ≈ len(raw)·outRateHz/inRateHz). In pass-through mode raw is returned unchanged. raw is never mutated.

func (*Downconverter) Reset added in v0.2.5

func (d *Downconverter) Reset()

Reset clears the decimation filter history. Called on every pipeline swap so a retune doesn't bleed the previous channel's filter state into the new one.

type FECObserver added in v0.4.2

type FECObserver interface {
	RecordTetraViterbiCorrections(system, channel string, corrections int)
}

FECObserver is the minimal metrics surface for the opt-in per-protocol FEC correction-depth histograms (today: TETRA). internal/metrics.Metrics satisfies it; the daemon wires it only when metrics.detailed_fec is set, so nil is the normal case and disables the histogram path end-to-end at zero cost (the pipeline never computes a correction depth).

type IQPowerObserver added in v0.1.7

type IQPowerObserver interface {
	RecordIQPowerDbFS(system string, dbfs float64)
	ClearIQPowerDbFS(system string)
	RecordIQDCRatioDb(system string, ratioDb float64)
	ClearIQDCRatioDb(system string)
	RecordIQClipRatio(system string, ratio float64)
	ClearIQClipRatio(system string)
	// RecordDecodeOverrun counts one IQ chunk dropped at the decode queue
	// because decode could not keep up with real time (issue #402). It is
	// distinct from the SDR driver's iq_underruns_total: a non-zero value
	// here means the CPU/host can't sustain the decode, not that the USB
	// delivery channel overran.
	RecordDecodeOverrun()
}

IQPowerObserver is the minimal Metrics surface the decoder uses to publish its window-averaged dBFS gauge. internal/metrics.Metrics satisfies this; nil disables the gauge entirely.

RecordIQDCRatioDb reports the per-window DC-bin power relative to total IQ power, in dB (so 0 means all power is in the DC bin, very negative means the DC content is negligible). It surfaces the RTL-SDR R820T2 DC-spike-on-channel failure mode behind issue #402: when the channel of interest sits on top of the tuner zero, the DC bin dominates the 48 kHz pipeline passband and the C4FM eye collapses. Healthy off-channel signals show ≤ -20 dB; a DC-dominated capture shows within ~5 dB of 0. RecordIQClipRatio reports the per-window fraction of IQ samples with an I or Q component pinned to the ADC rail (0 = none). Sustained non-zero values mean front-end overload/clipping — the average iq_power_dbfs gauge is RMS and hides peak clipping, so this is the authoritative clipping signal behind issue #402.

type IQSource

type IQSource interface {
	StreamIQ(ctx context.Context) (<-chan []complex64, error)
}

IQSource is the subset of sdr.Device the decoder consumes for IQ samples. Matches conventional.IQSource so the same Device satisfies both interfaces.

type Options

type Options struct {
	Bus     *events.Bus
	Log     *slog.Logger
	Tuner   Tuner    // currently unused but kept for API symmetry with cchunt
	IQ      IQSource // control SDR providing the live IQ stream
	Systems []trunking.System
	// SampleRateHz is the raw SDR IQ stream rate (e.g. 2_048_000).
	// The decoder's digital down-converter decimates it to a
	// narrowband channel rate (~48 kHz for most protocols); the
	// per-protocol receiver factories are handed that decimated
	// rate, not this one, so they size their matched filters for
	// the channelized stream.
	SampleRateHz float64
	// Metrics is the optional IQ-power observer the pump updates
	// once per iqPowerWindow. Nil disables the gauge but leaves the
	// low-power debug log in place — operators without Prometheus
	// still get a hint when the dongle goes silent.
	Metrics IQPowerObserver
	// FEC is the optional FEC correction-depth observer. Nil (the
	// default) disables the per-protocol FEC histograms; the daemon
	// supplies it only when metrics.detailed_fec is enabled.
	FEC FECObserver
	// IQCorrect enables blind I/Q-imbalance correction on the raw IQ
	// before decimation (issue #402). Off by default; opt-in per device
	// via config. Validate with `replay -iq-correct -diag` on a capture
	// before enabling in production.
	IQCorrect bool
	// Conjugate negates the imaginary part of every raw IQ sample before
	// channelization, undoing a spectrum-inverted / I-Q-swapped front end
	// (issue #264, the same correction as `replay -conjugate`). Off by
	// default; opt-in per device via config (iq_invert). A π/4-DQPSK
	// protocol such as TETRA cannot lock an inverted spectrum because the
	// inversion reverses every differential phase transition.
	Conjugate bool
	// LOOffsetHz is the live LO-offset (DC-spike-avoidance) tuning the daemon
	// applies on the control SDR (issue #402). The hardware LO is tuned
	// LOOffsetHz BELOW the logical control-channel frequency, so the wanted
	// channel sits at +LOOffsetHz in the delivered baseband; the
	// down-converter is built with this same offset so the channel is mixed
	// back to 0 Hz, off the front-end DC spur / 1/f noise / self-image. Zero
	// (the default) builds no NCO and is byte-exact with the pre-offset
	// behaviour — used when the path is disabled or at pass-through rates
	// where there is no room to offset.
	LOOffsetHz float64
	// Autotune, when non-nil, is the per-dongle carrier-error tracker for
	// the control SDR (sdr.autotune enabled). The decoder samples the
	// locked C4FM receiver's AFCOffsetHz once per second, folds it into the
	// running average, and shifts the digital down-converter by that
	// average at each (re)acquisition so the demod's AFC starts near lock.
	// Nil disables autotune at zero cost. See internal/autotune.
	Autotune *autotune.Manager
	// CarrierOffsetWarnHz is the magnitude of the total locked carrier offset
	// (autotune correction + receiver residual) above which the pump emits a
	// throttled WARN that GT may have locked onto an adjacent site's carrier
	// (issue #815). Zero selects defaultCarrierOffsetWarnHz. Raise it for a
	// high-drift dongle to avoid benign warnings.
	CarrierOffsetWarnHz int
	// DDCRecordDir, when non-empty, records the post-DDC narrowband IQ (the
	// channelized stream the active pipeline decodes, at the pipeline rate) to
	// two-channel 16-bit baseband WAVs in this directory. This is the "record
	// the digital down-converter output" tap: a small per-channel recording of
	// exactly the signal being decoded, instead of a fat wideband capture at
	// the SDR rate. Empty (the default) disables it at zero cost. Driven by a
	// baseband record entry with tap: ddc.
	DDCRecordDir string
}

Options configure a Decoder.

type PipelineFactory

type PipelineFactory func(PipelineOptions) (ProtocolPipeline, error)

PipelineFactory constructs a fresh ProtocolPipeline for one tuned system. The factory returns an error when the protocol's per-receiver / per-state-machine wiring isn't complete enough to drive a live CC pipeline end-to-end yet — the connector skips the retune in that case and the system stays in `state=hunting`.

type PipelineOptions

type PipelineOptions struct {
	Bus          *events.Bus
	Log          *slog.Logger
	SystemName   string
	FrequencyHz  uint32
	SampleRateHz float64
	System       trunking.System

	// SymbolTap, when non-nil, is invoked with every chunk of recovered
	// symbols a pipeline's receiver emits, just before they enter the
	// control-channel state machine. symbols holds dibits (values 0..3,
	// isBits=false) for the 4-level protocols and bits (values 0..1,
	// isBits=true) for the 2-level protocols; baseIdx is the absolute
	// symbol index the chunk starts at. It is a pure observation hook —
	// production never sets it. The offline siglab toolkit uses it to
	// count symbols and feed its protocol-agnostic signal-quality
	// analyzer for *every* protocol, without re-duplicating receiver
	// construction outside this factory. nil ⇒ zero overhead.
	SymbolTap func(symbols []uint8, isBits bool, baseIdx int)

	// FECObserver, when non-nil, receives a per-burst FEC correction
	// depth (channel bits the decode chain corrected) bound to this
	// system. The decoder supplies it only when metrics.detailed_fec is
	// enabled; today only newTETRAPipeline consumes it. nil ⇒ zero
	// overhead.
	FECObserver func(channel string, corrections int)

	// CarrierBiasHz, when non-nil, returns the frequency correction (Hz) the
	// decoder has already folded into the down-converter for the active
	// acquisition (the autotune-applied shift). newP25Phase1Pipeline adds it
	// to the receiver's residual AFCOffsetHz so the published
	// control_channel_carrier_offset_hz reports the TOTAL offset from the
	// configured frequency — matching the WARN in checkCarrierOffsetLocked —
	// instead of the residual-only value that drops toward 0 once autotune
	// re-centres the carrier (issue #815). Nil ⇒ residual only.
	CarrierBiasHz func() float64
}

PipelineOptions is the per-pipeline construction shape — the connector hands the bus + log down, plus the (system, frequency) the supervisor is currently attempting and the IQ sample rate the receiver needs to size its matched filter.

System carries the full trunking.System the supervisor is hunting, so per-protocol factories can read protocol-specific config off it (TETRA colour code + expected channel, P25 WACN, etc.) without needing a new field on PipelineOptions per protocol. SystemName + FrequencyHz remain at the top level because they're consumed by every factory.

type ProtocolPipeline

type ProtocolPipeline interface {
	Process(iq []complex64)
	Reset()
	Close() error
}

ProtocolPipeline is the contract every per-protocol receiver pipeline satisfies. Process consumes one chunk of complex IQ; Reset clears symbol-domain state on stream re-sync; Close releases any held resources (it's idempotent and may return nil).

func NewPipeline added in v0.3.2

func NewPipeline(p trunking.Protocol, opts PipelineOptions) (pipe ProtocolPipeline, ok bool, err error)

NewPipeline constructs the registered pipeline for protocol p with the given options. ok is false (and the pipeline nil) when no factory is registered for p — callers should treat that as "this protocol cannot be driven offline yet" rather than an error. err propagates a factory's own construction failure (e.g. incomplete per-system wiring).

This is the out-of-package entry point the offline siglab toolkit uses to drive any protocol through the same production pipelines the daemon runs; the daemon itself keeps using the internal factory map directly.

type Tuner

type Tuner interface {
	SetCenterFreq(hz uint32) error
}

Tuner is the subset of sdr.Device the decoder uses for retuning. Matches the same interface cchunt + conventional consume so the daemon can hand the same Device to all three.

Jump to

Keyboard shortcuts

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