equalizer

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 equalizer implements adaptive channel equalizers used to fight simulcast distortion — the inter-symbol interference produced when multiple transmitters cover the same frequency at slightly different arrival delays at the receiver. Premium hardware scanners market this capability as "True I/Q"; with an SDR we always have I/Q, so the win is what we do with it.

Two complementary algorithms ship here:

lms.go   Least-Mean-Squares adaptive FIR equalizer. Trained
         with reference (or decision-directed) symbols; fast to
         converge but needs a known training sequence (or a
         slicer it can trust).
cma.go   Constant Modulus Algorithm — blind equalizer for
         constant-envelope modulations (PSK family). Drives the
         output toward a constant magnitude without ever needing
         a reference; useful when the upstream demod has no
         preamble to lock to.

The package operates on complex64 IQ samples / symbols (matching the rest of the DSP stack). Equalizers slot between the channelizer and the symbol-time-recovery / demodulator stages of a per-call chain — the demod-pipeline composer is the natural integration point once a protocol decoder needs them on a real signal.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CMA

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

CMA is the Constant Modulus Algorithm — a blind adaptive equaliser that requires no training sequence. It exploits the fact that PSK-family signals (BPSK / QPSK / π/4-DQPSK / 8PSK) have a constant modulus on the air; multipath / simulcast distortion blurs that constant-magnitude property, and CMA drives the output back toward it.

Cost function and gradient (Godard / CMA-2):

J = E[(|y|^2 - R^2)^2]
∂J/∂w*  ∝  (|y|^2 - R^2) · y · conj(x)
w[n+1]  =  w[n]  -  μ · (|y|^2 - R^2) · y · conj(x)

Pick R^2 so the equilibrium weight scaling matches the expected constellation. For unit-magnitude PSK use R^2 = 1; QPSK with Gray-coded ±1±j has |y|^2 = 2 so R^2 = 2 is conventional.

Caveats:

  • CMA is phase-blind. After convergence the constellation may sit at any rotation; downstream symbol mapping must apply a constellation-aware phase recovery (a per-constellation rotator using known training symbols, or differential decoding).
  • On non-constant-modulus signals (FM, OQPSK, QAM) CMA's cost function isn't zero at the right answer; use LMS in decision-directed mode there.

func NewCMA

func NewCMA(taps int, stepSize, target float32) *CMA

NewCMA constructs a blind equaliser. `target` is the desired squared modulus (R^2): use 1.0 for unit-modulus PSK, 2.0 for ±1±j QPSK.

func (*CMA) Process

func (c *CMA) Process(x complex64) (complex64, float32)

Process consumes one input sample and returns the equalised output. The error proxy (|y|^2 - R^2) is also returned for diagnostics / convergence-monitoring; once it settles near zero the equaliser has opened the constellation.

func (*CMA) Reset

func (c *CMA) Reset()

Reset returns the equaliser to centre-spike initial state.

func (*CMA) Taps

func (c *CMA) Taps() []complex64

Taps returns a copy of the current weight vector.

type FSE added in v0.3.3

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

FSE is a T/2 fractionally-spaced blind (CMA) adaptive equalizer. It consumes two input samples per symbol — the half-symbol-earlier midpoint interpolant and the on-time symbol sample produced by the timing loop — and emits one equalized symbol.

Why fractionally spaced: a symbol-spaced equalizer can only invert the channel at the symbol rate, so it cannot repair a sub-symbol pulse-shape mismatch. The P25 CQPSK/LSM receiver matched-filters with an RRC, but a real P25 C4FM transmitter shapes a different (CPM) pulse, so the outer ±1800 Hz rails arrive under-shot and the constellation closes — the symbol-spaced CMA cannot open it (issue #492). A T/2 equalizer spans two phases per symbol and therefore synthesizes the receive matched filter implicitly, correcting the pulse mismatch (and any multipath ISI) and is insensitive to residual symbol timing phase.

Like CMA it is blind: the constant-modulus cost J = E[(|y|²−R²)²] needs no training symbols and is rotation-invariant, so the FSE runs ahead of the carrier (Costas) loop. The fractional spacing enlarges the cost's null space (degrees of freedom that leave |y| unchanged), which lets the taps random- walk on a clean, ISI-free channel; a small leakage term w←(1−leak)·w shrinks unexcited taps toward zero so real ISI sustains the taps but noise alone does not. The on-time centre tap is phase-pinned to the positive real axis after each update (as in CMA) so the downstream carrier loop does not read tap-phase drift as a frequency offset.

func NewFSE added in v0.3.3

func NewFSE(symbolSpan int, stepSize, target, leak float32) *FSE

NewFSE builds a T/2 fractionally-spaced CMA equalizer. symbolSpan is the equalizer length in symbols; the filter holds 2*symbolSpan T/2-spaced taps. Use an even symbolSpan so the centre tap aligns with an on-time sample (the emission order is [mid, on], so even tap indices are on-time samples). stepSize is the CMA step; target is R² (1.0 for unit-modulus π/4-DQPSK after AGC); leak is the leakage coefficient (0 disables it; ~1e-4..1e-3 typical).

func (*FSE) Process added in v0.3.3

func (f *FSE) Process(mid, on complex64) (complex64, float32)

Process consumes the two T/2-spaced samples of one symbol period (mid = the half-symbol-earlier interpolant, on = the symbol-time sample) and returns the equalized symbol y plus the CMA error proxy |y|²−R². The output is computed once, at the symbol instant, over the full 2-sps tap line; the gradient updates every tap against its own aligned T/2 input sample so both new samples participate.

func (*FSE) Reset added in v0.3.3

func (f *FSE) Reset()

Reset returns the equalizer to centre-spike initial state.

func (*FSE) Taps added in v0.3.3

func (f *FSE) Taps() []complex64

Taps returns a copy of the current weight vector.

type LMS

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

LMS is a complex-valued tapped-delay-line adaptive equalizer trained with the standard Least-Mean-Squares update rule:

y[n]   = sum_k w_k(n) * x[n-k]              // FIR output
e[n]   = d[n] - y[n]                        // training error
w[n+1] = w[n] + μ · e[n] · conj(x[n-k])     // weight update

Notes:

  • The reference signal d[n] can be a true training preamble or, in decision-directed mode, the slicer's hard decision on y[n].
  • μ (StepSize) sets the trade-off between convergence speed and mean-squared-error floor; 0.005 to 0.05 are reasonable starting points for symbol-spaced channels.
  • Initialised to a centre spike: w_{N/2}(0) = 1, others zero. That starts the equaliser as a pass-through so a benign channel stays roughly intact while training begins.

The struct is not safe for concurrent use; one equaliser belongs to one demod chain.

func NewLMS

func NewLMS(taps int, stepSize float32) *LMS

NewLMS constructs an equaliser with `taps` complex weights and the supplied step size. taps must be > 0; an odd taps count is recommended so the centre spike is well-defined.

func (*LMS) Process

func (e *LMS) Process(x, desired complex64) (complex64, complex64)

Process consumes one input sample x and updates the filter. The `desired` argument is the reference / training symbol; in decision-directed mode supply the upstream slicer's hard decision on the previous output. Returns the equalised output y[n] and the instantaneous error e[n].

func (*LMS) Reset

func (e *LMS) Reset()

Reset returns the equaliser to its centre-spike initial state.

func (*LMS) SetStepSize

func (e *LMS) SetStepSize(step float32)

SetStepSize updates μ. Larger steps converge faster but settle to a noisier weight vector.

func (*LMS) Taps

func (e *LMS) Taps() []complex64

Taps returns a copy of the current weight vector. Useful in tests and when an operator wants to inspect what the equaliser has learned (or stash and restore taps across calls on the same channel).

type SnapshotCMA added in v0.8.6

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

SnapshotCMA is a blind CMA equalizer safe to place ahead of a DIFFERENTIAL demodulator, where the plain CMA (which applies its live, continuously adapting taps) is not.

The problem it solves: CMA's cost function J = E[(|y|²−R²)²] is rotation- invariant, so as the taps adapt the equalized output's absolute phase wanders freely. A coherent slicer with its own carrier recovery tolerates that, but a *differential* decoder forms s[n]·conj(s[n−1]) — and a phase that changes between consecutive symbols does NOT cancel there, corrupting every dibit. Feeding a continuously-adapting CMA to a differential decoder empirically drives the decode to zero.

The fix — adapt continuously, apply a FROZEN snapshot: SnapshotCMA keeps two tap sets. wAdapt is updated every sample by CMA(2,2); wApply is a snapshot of wAdapt refreshed every snapEvery samples and is the filter actually applied to the output. Held fixed between snapshots, wApply imposes only a constant phase (which the differential decoder cancels) while still inverting the linear channel. Only the one sample straddling a snapshot sees a phase step; with snapEvery on the order of a burst that is a dibit or two, absorbed by the FEC. wApply is center-spike initialised, so before CMA converges the output is the unmodified input — the equalizer can only help or, on a clean signal, leave it essentially unchanged.

Input is normalised by a CUMULATIVE mean power estimate — a constant-ish scale that converges to the whole-session RMS and stays put. This matters: the CMA update scales with |x|³, and a *local* (EMA) normalisation that tracks a bursty TDMA downlink's slot-to-slot power swings gives a moving modulus target and CMA converges to garbage. A divergence guard re-seeds the tracking filter if a normalisation transient or deep fade blows the taps up, so one bad patch cannot poison later snapshots. The target modulus is R²=1 (the cumulative normalisation makes the input near-unit-power), so SnapshotCMA is for unit-modulus PSK (BPSK / QPSK / π/4-DQPSK / 8PSK).

Validated on real TETRA captures: inserted ahead of the π/4-DQPSK differential decoder it roughly doubles CRC-valid TCH/S burst yield (soft-decision 410→778, ~1.9×) with no regression on already-clean captures.

func NewSnapshotCMA added in v0.8.6

func NewSnapshotCMA(taps int, stepSize float32, snapEvery int) *SnapshotCMA

NewSnapshotCMA builds a snapshot CMA equalizer. taps is the FIR length (rounded up to odd for an exact center spike); stepSize is the CMA step (µ); snapEvery is the number of samples between frozen-tap snapshots. Panics on taps <= 0 or snapEvery <= 0.

func (*SnapshotCMA) Process added in v0.8.6

func (e *SnapshotCMA) Process(x complex64) complex64

Process consumes one input sample and returns the equalized output (the frozen wApply filter's response), while adapting the tracking filter and refreshing the snapshot on schedule.

func (*SnapshotCMA) Reset added in v0.8.6

func (e *SnapshotCMA) Reset()

Reset returns the equalizer to its center-spike pass-through state. Call on stream re-sync so a stale channel estimate does not corrupt the re-acquired stream.

func (*SnapshotCMA) Taps added in v0.8.6

func (e *SnapshotCMA) Taps() []complex64

Taps returns a copy of the currently-applied (frozen) weight vector.

type SnapshotLMS added in v0.8.8

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

SnapshotLMS is a training-sequence-aided linear equalizer that is safe to place ahead of a DIFFERENTIAL demodulator — the trained counterpart to SnapshotCMA.

Where SnapshotCMA adapts BLINDLY to a constant-modulus target, SnapshotLMS trains on a burst's KNOWN training sequence (the π/4-DQPSK normal/synchronisation midamble). That difference matters: CMA's cost J = E[(|y|²−R²)²] is satisfied by any equalizer output that has the right modulus, so it has spurious minima and can converge to a constant-modulus but WRONG solution — the failure the package history records (a numerically-unstable CMA once drove differential EVM 34%→8% while CRC stayed 0). Training to a known reference removes that ambiguity: the LMS error e = d − y is minimised only when the composite channel·equalizer maps the reference back to itself, so a good training sequence pins the true channel inverse (phase and all), not merely its modulus. This is the #1001 follow-up: per-burst training-sequence-aided equalization for the same-carrier TETRA voice that blind CMA only partially recovers.

It shares SnapshotCMA's differential-safety principle: ADAPT on the training sequence, then APPLY a FROZEN snapshot of the taps to the whole burst. A held- constant filter imposes only a constant phase/scale, which cancels in the downstream s[n]·conj(s[n−1]) differential decode; a continuously-adapting filter would not (its inter-symbol phase drift corrupts every dibit). So the intended use is: Train(midamble_rx, midamble_ref) once per burst, then Equalize the whole burst's symbols through the frozen taps before the differential decoder.

The taps are centre-spike initialised, so before training (or on a benign channel with a short/absent reference) Equalize is a near pass-through — the equalizer can only help or leave a clean burst essentially unchanged. The forward FIR convention matches LMS exactly (tap 0 multiplies the newest sample), so a snapshot of an LMS weight vector means the same thing in Equalize.

func NewSnapshotLMS added in v0.8.8

func NewSnapshotLMS(taps int, stepSize float32) *SnapshotLMS

NewSnapshotLMS builds a training-sequence equalizer with taps complex weights and LMS step size stepSize. taps must be > 0; an odd count gives a well-defined centre spike. Panics on taps <= 0 (mirroring NewLMS).

func (*SnapshotLMS) Equalize added in v0.8.8

func (e *SnapshotLMS) Equalize(dst, src []complex64) []complex64

Equalize applies the frozen taps to src as a plain FIR, appending the equalized symbols to dst[:0] and returning it (dst may be nil or reused across bursts). The delay line persists across calls so a burst can be streamed in chunks; call Reset between independent bursts. Using the frozen taps for the whole burst is what keeps the output safe for a differential decoder (constant phase cancels).

func (*SnapshotLMS) Reset added in v0.8.8

func (e *SnapshotLMS) Reset()

Reset returns the equalizer to its centre-spike pass-through state and clears the Equalize delay line — call between independent bursts / on re-sync so a stale channel estimate does not corrupt the next burst.

func (*SnapshotLMS) Taps added in v0.8.8

func (e *SnapshotLMS) Taps() []complex64

Taps returns a copy of the currently-applied (frozen) weight vector.

func (*SnapshotLMS) Train added in v0.8.8

func (e *SnapshotLMS) Train(rx, ref []complex64, passes int)

Train adapts the tracking filter over the aligned (received, reference) symbol pairs, then freezes the result into the applied taps. rx[i] is the received symbol for reference symbol ref[i]; both must be the same length (the shorter prevails). passes repeats the sweep over the reference so a short midamble still converges (1 for a long training sequence, several for a burst-length one); a passes < 1 is treated as 1. Training does NOT reset the adapt taps, so repeated Train calls refine the same estimate — call Reset first for a fresh channel.

Jump to

Keyboard shortcuts

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