Skip to content

Symbol Timing Recovery

Symbol timing recovery demo

track.SymbolSync recovers the symbol clock of an asynchronous data stream — one whose symbol rate is not locked to (and here runs 0.4 % fast of) the receiver's sample clock. A selectable timing-error detector (ted) closes a PI loop around an integer timing NCO and a Farrow interpolator: the NCO's post-wrap accumulator value is the interpolation fraction µ — free, with no floating-point timing phase — so the timing accumulation stays exact while only the interpolation is floating point.

The signal is an RC-shaped BPSK stream with a static fractional-sample offset, a 0.4 % clock-rate offset, and AWGN at 14 dB.

What you're seeing

Top — Recovered symbols. The interpolated symbol's real part per symbol: a brief pull-in, then clean ±1 once timing locks — the data is recovered with zero bit errors (a global inversion is don't-care).

Middle — Tracked clock. The recovered samples/symbol converging onto the true offset rate (dashed). The loop tracks the asynchronous clock — a moving rate, not just a static phase.

Bottom — Input eye. The raw oversampled input folded to two symbols: the eye is open, but with an async clock the optimal sampling instant drifts — which is exactly why a fixed sampler fails and a tracking interpolator is needed.

How it works

One per-sample integer-NCO loop produces two interpolants per symbol — derived from the phase value (not a parity counter, so a loop correction can never desync them) — that both timing-error detectors share:

per sample:  push x[n] into the Farrow; advance the integer timing NCO
             half-scale crossing  -> mid-symbol (transition-gate) interpolant
             full-scale wrap      -> on-time interpolant   (µ from the NCO)
per symbol:  e = TED(mid, on_time, prev_on_time)   # ted="gardner" or "dttl"
             PI loop -> adjust the NCO frequency (slip-free)  # no phase nudge
             emit the on-time interpolant

Two detectors are available via ted:

  • "gardner" (default) — e = Re{ conj(mid) * (on_time - prev_on_time) }. Blind (non-data-aided): works for any constellation and at any SNR, at the cost of a non-transition-symbol self-noise floor.
  • "dttl" — the sign-sign Data Transition Tracking Loop (M.K. Simon): e is nonzero only when a hard decision on on_time actually flips relative to prev_on_time, gated by the same transition-gate sample Gardner uses. Decision-directed, so it's valid only for BPSK/QPSK (independent, rectangular I/Q decision boundaries — not 8PSK/QAM), and degrades faster than Gardner at low SNR (wrong decisions corrupt the gating).

Steering the NCO through its frequency (folding the proportional term into the rate rather than nudging the phase) keeps the strobe count smooth — a direct phase nudge near a wrap boundary would insert or delete a symbol (a cycle slip).

import numpy as np

from doppler.track import SymbolSync
from doppler.wfm import rc_h, wfm_awgn_amplitude

SPS = 4
BETA = 0.35
NSYM = 2500
CLOCK_RATE = 1.004  # 0.4% fast symbol clock (asynchronous)
OFFSET = 1.7  # static fractional-sample timing offset
SNR_DB = 14.0


def make_signal(seed=7):
    """RC-shaped BPSK whose symbol clock runs CLOCK_RATE fast, offset by
    OFFSET samples, at SNR_DB — asynchronous to the sample clock.

    **This is the one stimulus in the gallery `wfmgen` cannot build**, and
    the reason is the thing being demonstrated: `Segment.sps` is an integer,
    so a composer scene has no way to say "the symbol clock runs 1.004x fast
    and starts 1.7 samples late". That offset IS the question a
    `SymbolSync` answers, so it is placed here by hand.

    What does NOT get written by hand is the pulse or the level. Both are
    library primitives and both were transcribed here before:

    - `rc_h` is the analytic raised cosine at **arbitrary, non-grid times**,
      which is precisely the shape a drifting clock needs — its docstring
      says to use it rather than a transcription of the formula, and the
      private `rc_pulse` this replaces was that transcription. It takes `t`
      in SYMBOL periods where the old one took samples, hence the `/ SPS`.
    - `wfm_awgn_amplitude` is the per-component sigma for a target SNR over
      fs, replacing `sqrt(10 ** (-SNR_DB / 10)) * p / sqrt(2)`.
    """
    rng = np.random.default_rng(seed)
    a = rng.integers(0, 2, NSYM) * 2 - 1
    n = NSYM * SPS
    s = np.zeros(n)
    span = 8 * SPS
    for k, ak in enumerate(a):
        c = k * SPS * CLOCK_RATE + OFFSET
        if c + span >= n:
            break
        idx = np.arange(max(0, int(c - span)), min(n, int(c + span)))
        s[idx] += ak * rc_h((idx - c) / SPS, BETA)
    s = s.astype(np.complex64)
    std = wfm_awgn_amplitude(SNR_DB, np.mean(np.abs(s) ** 2))
    s = s + (rng.normal(0, std, n) + 1j * rng.normal(0, std, n)).astype(
        np.complex64
    )
    return s, a
rx, sent = make_signal()
ss = SymbolSync(sps=SPS, bn=0.01, zeta=0.707, order="cubic")
symbols = ss.steps(rx)  # one timing-corrected symbol per recovered instant
assert abs(ss.rate - SPS * CLOCK_RATE) < 0.01  # recovered the async clock

order selects the Farrow interpolator (linear / parabolic / cubic); ted selects the timing-error detector (gardner / dttl); bn / zeta set the loop bandwidth and damping. The synchronizer tracks residual clock offsets up to roughly ±1 % cleanly — it follows acquisition, just as the carrier loops track the residual after the FFT search.

Source: src/doppler/examples/symsync_demo.py.