sync

package
v1.0.9 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 1 Imported by: 0

Documentation

Overview

Package sync provides symbol-time recovery and frame sync correlators.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func SlicePiOver4DQPSK added in v0.8.3

func SlicePiOver4DQPSK(y complex128) complex128

SlicePiOver4DQPSK maps an equalized sample to the nearest point of the 8-PSK super-constellation the π/4-DQPSK / H-DQPSK family visits: two QPSK constellations offset by π/4, i.e. the eight phases k·π/4 at unit radius. It is the decision slicer for DecisionUpdate on this modulation — the absolute symbols alternate between the {π/4, 3π/4, …} and {0, π/2, …} rings, and their union is the 8-PSK grid, so slicing to the nearest of the eight phases is the correct hard decision on the absolute symbol regardless of which ring it came from. Magnitude is normalised to unit radius so the reference does not fight the equalizer's gain.

Types

type Correlator

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

Correlator searches a stream of soft symbols for a known sync pattern by running a sliding inner product. It returns the indices where the correlation magnitude exceeds threshold.

func NewCorrelator

func NewCorrelator(pattern []float32, threshold float32) *Correlator

func (*Correlator) Process

func (c *Correlator) Process(dst []int, src []float32, baseIndex int) ([]int, int)

Process scans src and appends to dst the absolute indices (relative to the start of the first call to Process) where the pattern matches above threshold. The input index for each sample increases by 1 across calls.

type Gardner

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

Gardner is a feedback symbol-timing recovery loop for complex IQ signals. It's the standard non-data-aided timing detector used in PSK / QAM demodulators where the symbol decisions aren't yet available — useful for the π/4-DQPSK family (TETRA TMO, P25 Phase 2) where the receivers currently rely on naive decimation.

Algorithm (Gardner 1986): for each symbol period the detector samples once at the symbol time t_s and once at the midpoint t_s − sps/2. The timing error is

e[n] = Re{ (s[n] − s[n−1])^* · m[n] }

where s[n] is the symbol-time sample, s[n−1] is the previous symbol-time sample, and m[n] is the midpoint sample between them. The error has zero mean at the correct sampling instant and signed deviation otherwise; a scalar update applies it to the sub-sample phase.

Compared to MuellerMuller (this package) which is real-valued and decision-directed, Gardner:

  • Works on complex samples without sign decisions, so it starts converging before the demod has acquired symbol polarity.
  • Has zero self-noise at the optimum sampling instant for RRC-filtered PSK signals, so the surviving phase estimate is stable.
  • Requires a midpoint sample, so the natural input is samples at ≥ 2 sps (4 sps is typical for noisier captures).

Inputs are oversampled complex samples (e.g. 8 sps after the matched filter). Output is one sample per recovered symbol.

func NewGardner

func NewGardner(sps, gain float64) *Gardner

NewGardner constructs a Gardner timing-recovery loop. sps is the nominal samples-per-symbol (≥ 2); gain is the loop step (a small positive value, typical range 0.01..0.1). Panics on invalid sps. A non-positive gain defaults to 0.02 (slightly slower convergence than MuellerMuller, picked for stability on noisier IQ).

func (*Gardner) Mu added in v0.3.1

func (g *Gardner) Mu() float64

Mu returns the loop's current sub-sample phase accumulator (it counts down from sps toward 0 each input sample and resets by +sps at every emitted symbol). Read-only; exposed for diagnostics so a CQPSK replay can render the timing-loop state the way the C4FM path renders its Mueller-Müller mu (issue #492).

func (*Gardner) Process

func (g *Gardner) Process(dst, src []complex64) []complex64

Process consumes oversampled complex IQ samples and emits one recovered symbol per nominal symbol period. dst is reused if it has capacity. Symbols are interpolated linearly between adjacent input samples at the loop's current sub-sample phase. Cross-call state preserves the timing estimate so chunked streams converge once rather than per-chunk.

func (*Gardner) Process2x added in v0.3.3

func (g *Gardner) Process2x(dst, src []complex64) []complex64

Process2x is identical to Process — same timing loop, error detector and cross-call stash — but emits TWO samples per recovered symbol: the half-symbol-earlier midpoint interpolant followed by the on-time symbol sample, i.e. dst grows by [mid, sym] each symbol. This is the T/2 feed for the fractionally-spaced equalizer (issue #492). When the midpoint look-back is not yet available (stream start / chunk head) the pair is emitted with mid = sym, a degenerate pre-lock symbol the equalizer's adaptation tolerates. The symbol cadence is bit-for-bit the same as Process, so chunked consumers stay in sync.

func (*Gardner) Reset

func (g *Gardner) Reset()

Reset clears the loop state. Call on stream re-tune so the next chunk doesn't carry a stale timing estimate.

func (*Gardner) SPS added in v0.3.1

func (g *Gardner) SPS() float64

SPS returns the loop's nominal samples per symbol. Paired with Mu so a diagnostic can render mu as a fraction of the symbol period.

type LMSEqualizer added in v0.8.3

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

LMSEqualizer is a T-spaced (symbol-spaced) complex adaptive linear equalizer for the π/4-DQPSK / H-DQPSK symbol stream. It sits after carrier recovery (QPSKCostas) and before the differential decode, and removes the residual inter-symbol interference (ISI) a real channel leaves on the *absolute* symbols: RRC pulse-shape mismatch, a fractional timing error the Gardner loop settles at, and mild multipath — all of which smear a symbol's energy into its neighbours and widen the differential-phase decision, so the outer FEC (the P25 Phase 2 RS(24,16,9)) sees symbol errors on an otherwise-decodable burst (issue #915).

It is a transversal FIR filter with N complex taps run over the symbol stream, adapted by the normalised least-mean-squares (NLMS) rule:

y[n] = Σ_k w[k]·x[n−k]                     (equalized output)
e[n] = d[n] − y[n]                          (error vs. reference d)
w[k] ← w[k] + μ · e[n] · conj(x[n−k]) / (ε + ‖x‖²)

NLMS (rather than plain LMS) normalises the step by the delay-line energy, so convergence is insensitive to the absolute signal level the upstream AGC settles at. The taps initialise to a centre spike (w[centre] = 1, rest 0), so an un-adapted equalizer is a pure delay — a no-op on a clean signal, matching the primitive-first convention the Gardner / Costas loops follow (a centred, undistorted stream trains the taps back to ~the centre spike and the path stays transparent).

The reference d[n] comes from one of two sources:

  • Trained: the known transmitted symbol, during a sync/preamble whose symbols the receiver knows a priori (the 20-symbol P25 Phase 2 outbound frame sync). This is the "sync-trained" mode — the taps converge on known data, immune to decision errors.

  • Decision-directed: the nearest constellation point of y[n], during the unknown payload that follows. Correct once the sync-trained taps have opened the eye; the caller supplies the slicer so the primitive stays modulation-agnostic (SlicePiOver4DQPSK is the slicer for this family).

The filter is stateful (it carries the delay line across calls) and not safe for concurrent use. One per tuned frequency / per call chain, reset on stream re-sync.

func NewLMSEqualizer added in v0.8.3

func NewLMSEqualizer(numTaps int, mu float64) *LMSEqualizer

NewLMSEqualizer builds a symbol-spaced equalizer with numTaps taps and NLMS step μ. numTaps is forced odd and ≥ 1 so a symmetric centre tap exists (an even count rounds up); μ is clamped to (0, 2) — the NLMS stability bound — defaulting to 0.3 when out of range. The taps start as a centre spike so the filter is initially a pure delay.

func (*LMSEqualizer) Apply added in v0.8.3

func (e *LMSEqualizer) Apply(x complex64) complex64

Apply pushes one input symbol and returns the equalized output WITHOUT adapting the taps (a frozen equalizer). Use when adaptation is disabled or the eye is known open and further adaptation would only add noise.

func (*LMSEqualizer) CMAUpdate added in v0.8.3

func (e *LMSEqualizer) CMAUpdate(x complex64) complex64

CMAUpdate pushes one input symbol, equalizes it, and adapts the taps by the Constant Modulus Algorithm (Godard, p = 2) — it drives |y| toward the unit modulus every π/4-DQPSK / H-DQPSK symbol carries, using NO phase decision.

This is the correct blind equalizer for P25 Phase 2. The H-DQPSK absolute constellation is not a fixed phase grid: the modulator adds a π/8 rotation *cumulatively every symbol* (and the carrier loop is designed to keep it, so it survives to the symbol stream), so the absolute symbols spin through every phase. A decision-directed phase slicer has no fixed grid to lock to and would fight the spin; CMA is immune to it — it only sees the modulus, which is constant regardless of rotation and of any residual carrier phase. It removes the amplitude ripple / inter-symbol interference a channel imposes; the leftover phase is cancelled downstream by the differential decode.

The taps still initialise to a centre spike, so on a clean unit-modulus signal the CMA error is ~0 and the equalizer stays transparent. Returns the equalized symbol.

func (*LMSEqualizer) DecisionUpdate added in v0.8.3

func (e *LMSEqualizer) DecisionUpdate(x complex64, slice func(complex128) complex128) complex64

DecisionUpdate pushes one input symbol, equalizes it, adapts the taps toward the sliced (decided) constellation point, and returns the equalized symbol. slice maps an equalized sample to its nearest ideal constellation point. Use on the unknown payload after the sync-trained taps have opened the eye.

func (*LMSEqualizer) Delay added in v0.8.3

func (e *LMSEqualizer) Delay() int

Delay returns the equalizer's reference delay in symbols: the centre-tap index. The equalized output at step n corresponds to the input symbol the centre tap sees, so a caller training on known symbols must align the reference to this delay — feed reference d[n] = tx[n − Delay()] (plus any channel bulk delay the taps will additionally absorb). Getting the alignment wrong makes the taps chase an unrelated symbol and never converge.

func (*LMSEqualizer) EqualizeGated added in v0.8.3

func (e *LMSEqualizer) EqualizeGated(x complex64, slice func(complex128) complex128, tol float64) complex64

EqualizeGated pushes one input symbol, equalizes it, and adapts the taps toward the sliced decision ONLY when the equalized sample is within tol of that decision — i.e. the symbol is reliable. On an unreliable symbol (the equalized point sits far from every constellation point) the taps are frozen for that sample. It returns the equalized symbol.

This is the streaming form used in the live receiver, where no known training sequence is fed in. Plain decision-directed LMS adapts on every symbol, including its own decision errors, and can walk the taps away from the solution when the eye starts closed; the reliability gate breaks that feedback — the taps only move on symbols the current equalizer already decodes confidently. The frame sync and clean payload are exactly those high-confidence symbols, so the equalizer sharpens the eye on the reliable structure and leaves the noise-dominated symbols alone (issue #915). tol is a Euclidean distance on the unit-radius constellation; ~0.4 keeps only symbols comfortably inside a decision region. A centre-spike (un-adapted) equalizer on a clean signal decides every symbol reliably and adapts toward its own (correct) decisions, so it stays transparent.

func (*LMSEqualizer) LastErrorSq added in v0.8.3

func (e *LMSEqualizer) LastErrorSq() float64

LastErrorSq returns |e|² of the most recent Train / DecisionUpdate — a convergence diagnostic. It falls toward the noise floor as the taps converge; a value that stays high means the equalizer has not locked (too few taps, wrong reference, or a channel beyond a linear equalizer's reach).

func (*LMSEqualizer) Reset added in v0.8.3

func (e *LMSEqualizer) Reset()

Reset returns the equalizer to its initial centre-spike state and clears the delay line. Call on stream re-sync so the taps shed a stale channel estimate.

func (*LMSEqualizer) TapEnergy added in v0.8.3

func (e *LMSEqualizer) TapEnergy() float64

TapEnergy returns Σ|w[k]|² — the total tap energy. A linear equalizer inverting a channel notch grows its taps; an unbounded growth signals divergence (μ too large for the channel) or noise enhancement.

func (*LMSEqualizer) Train added in v0.8.3

func (e *LMSEqualizer) Train(x, ref complex64) complex64

Train pushes one input symbol, equalizes it, adapts the taps toward the known reference symbol ref, and returns the equalized symbol. Use during a sync/preamble whose transmitted symbols are known a priori.

type MuellerMuller

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

MuellerMuller is a feedback symbol-timing recovery loop for real-valued PAM signals. The loop adjusts a sub-sample symbol clock toward the optimum sampling instant by minimizing |s[n] - sgn(s[n-1])*s[mid]|.

Inputs are oversampled samples (e.g. 8 sps after the matched filter). Output is one sample per recovered symbol.

func NewMuellerMuller

func NewMuellerMuller(sps, gain float64) *MuellerMuller

func (*MuellerMuller) Mu added in v0.2.5

func (m *MuellerMuller) Mu() float64

Mu returns the current sub-sample phase accumulator (rad-equivalent; in (-1, sps] depending on where the loop is in the symbol period). At steady state on a noise-free signal mu cycles deterministically around the symbol period; a slow monotonic drift indicates the nominal sps does not match the stream's actual sample-rate / baud ratio. Exposed read-only for issue-#402-style diagnostics where the daemon (or replay) periodically logs the loop's internal state so a persistent clock slip can be distinguished from a slicer / AFC failure. Not safe for concurrent calls with Process.

func (*MuellerMuller) Process

func (m *MuellerMuller) Process(dst []float32, src []float32) []float32

Process consumes oversampled real samples and emits one recovered symbol per nominal symbol period. dst is reused if it has capacity. The symbol clock carries across calls, so a long stream may be processed in chunks without the recovered symbol count depending on the chunk size: prevTail bridges each chunk boundary so src[0] of a continuation chunk is a real clock step rather than being skipped.

func (*MuellerMuller) SPS added in v0.2.5

func (m *MuellerMuller) SPS() float64

SPS returns the nominal samples-per-symbol the loop was constructed with. Paired with Mu() so a diagnostic log line can render mu as a fraction of the symbol period without re-deriving the construction value.

type QPSKCostas added in v0.3.1

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

QPSKCostas is a non-data-aided carrier-recovery loop for the π/4-DQPSK / QPSK family. It de-rotates a recovered symbol stream to remove a residual carrier-frequency offset — the error a differential decoder cannot fix on its own, because the differential decode cancels a constant carrier *phase* but not a constant per-symbol *rotation* (2π·Δf/baud), which instead spins the whole differential constellation and lands every symbol in the wrong quadrant (issue #492).

The error detector is the four-fold-symmetric differential phase: for an ideal symbol the differential product y[n]·conj(y[n−1]) sits at the constellation's nominal differential phases, so 4× its angle is a constant (mod 2π) regardless of the data and the constellation alternation. That constant is 4× the per-symbol constellation rotation: π for the π/4-DQPSK / LSM family (rotation π/4), and π/2 for P25 Phase 2 H-DQPSK (rotation π/8). lockPhase carries it; pass it via NewQPSKCostasForRotation, or use NewQPSKCostas for the π/4 default. The wrapped residual of (4·angle − lockPhase), scaled back by 4, is the leftover per-symbol carrier rotation — recovered without any symbol decision, so the loop converges before polarity is known and is immune to the alternation that defeats a plain 4th-power-of-symbol estimator. Using the wrong lockPhase leaves a constant per-symbol bias that eats the differential decision margin (a π/8 bias halves it for H-DQPSK).

It is a second-order loop: a proportional path corrects phase quickly and an integrator accumulates the frequency estimate (reported in Hz via OffsetHz). Its unambiguous pull-in is only ±baud/8 (±π/4 per symbol), so a coarse acquisition — e.g. dsp.EstimateCarrierOffsetHz on the matched-filter output — must bring the residual inside that range before this loop is engaged.

func NewQPSKCostas added in v0.3.1

func NewQPSKCostas(baudHz, loopBWHz, damping float64) *QPSKCostas

NewQPSKCostas builds a loop tuned to loopBWHz (the loop's noise bandwidth) at the given symbol rate, with the supplied damping (≈0.707 critically). Non-positive baud panics; non-positive loopBWHz or damping fall back to sane defaults. The discrete proportional / integral gains follow the standard second-order PLL design from the normalised loop bandwidth θ = loopBWHz / baudHz.

func NewQPSKCostasForRotation added in v0.5.7

func NewQPSKCostasForRotation(baudHz, loopBWHz, damping, rotation float64) *QPSKCostas

NewQPSKCostasForRotation is NewQPSKCostas for a constellation whose per-symbol rotation is `rotation` rad (π/4 for the π/4-DQPSK / LSM family, π/8 for P25 Phase 2 H-DQPSK). The detector locks to 4×rotation; passing the right value keeps the loop from settling with a constant per-symbol bias that would eat the differential decision margin.

func (*QPSKCostas) OffsetHz added in v0.3.1

func (c *QPSKCostas) OffsetHz() float64

OffsetHz returns the loop's current estimate of the residual carrier frequency offset in Hz (freq · baud / 2π).

func (*QPSKCostas) Reset added in v0.3.1

func (c *QPSKCostas) Reset()

Reset clears the loop so a stream re-sync starts from zero phase and zero frequency estimate.

func (*QPSKCostas) Update added in v0.3.1

func (c *QPSKCostas) Update(y complex64) complex64

Update consumes one recovered symbol, de-rotates it by the loop's current carrier-phase estimate, advances the loop, and returns the de-rotated symbol. Feed the returned symbols straight to the differential decoder — the carrier rotation has been removed, so the differential phases land back on their nominal {±π/4, ±3π/4} grid.

Jump to

Keyboard shortcuts

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