TETRA End to End, Part 1: π/4-DQPSK & the Shape of a TETRA Carrier

Part 1 of TETRA End to End, a 14-part deep dive that follows one real 25 kHz TETRA carrier — a live TMO cell, MCC 250 / MNC 13, captured by an operator and replayed through GopherTrunk hundreds of times — all the way from raw IQ to clear recorded voice. The Protocol Decoders survey gave TETRA one episode; this series gives it the full treatment, because almost everything hard we learned about decoding radio in the last year happened on this carrier. One villain recurs the whole way: the test that passes because both sides share the same bug — green synthetic ≠ on-air correct. This opener is about the physical layer everything else stands on: what a TETRA carrier actually looks like, and why information riding in phase *transitions shapes every design decision downstream.*

TL;DR: TETRA is π/4-DQPSK at 18000 symbols/s in a 25 kHz channel — not four-level FSK like the P25/DMR/NXDN family. Each symbol carries one dibit in the phase change from the previous symbol, recovered as arg(s·conj(last)) (internal/dsp/demod/dqpsk.go), so no absolute carrier phase is ever needed — a property the equalizer work in Parts 8–10 leans on hard. The constellation rotates π/4 every symbol, so there is always a transition to clock off. GopherTrunk channelizes TETRA to 144 kHz — 8 samples/symbol (ddcTargetForProtocol, internal/scanner/ccdecoder/ddc.go) where the C4FM family gets 48 kHz, and the receiver (internal/radio/tetra/receiver) is an RRC matched filter → Gardner timing → AFC → differential decode chain sized entirely from that rate.

Key takeaways

  • The dibit lives in the transition, not the symbol. The demodulator computes s·conj(last) and slices the phase delta into quadrants — a constant phase rotation of the whole constellation cancels out. That one property decides what is and isn’t safe to put in front of this decoder.
  • The constellation never sits still. A π/4 rotation per symbol means consecutive symbols always differ, giving timing recovery a transition to lock to and leaving eight visible constellation points from two interleaved QPSK sets.
  • TETRA gets its own Gray map and its own channel rate. TetraBitsToDibits is deliberately separate from the C4FM framing.DibitsToBits, and the DDC target is 144 kHz where every 4800-baud protocol gets 48 kHz.
  • The downlink never stops. A TETRA base station transmits continuously — four TDMA slots per 56.67 ms frame — so the receiver is a stream processor, not a burst hunter, and lock quality is measurable every single frame.

Cheat sheet

Concern What it does Where it lives
Differential demod arg(s·conj(last)) − rotation → dibit quadrant internal/dsp/demod/dqpsk.go (DQPSK.Decode)
π/4 wrapper + RRC matched filter (α = 0.35) + rotation constant internal/dsp/demod/piover4_dqpsk.go (PiOver4DQPSK)
Bit↔dibit convention TETRA Gray map, distinct from C4FM linear internal/radio/tetra/sync.go (TetraBitsToDibits)
Channel rate 144 kHz for TETRA, 48 kHz for C4FM family internal/scanner/ccdecoder/ddc.go (ddcTargetForProtocol)
IQ → dibit chain filter → timing → AFC → differential decode internal/radio/tetra/receiver/receiver.go (Receiver.Process)
Timing recovery Gardner loop, the default clock mode receiver.go (ParseClockMode, ClockGardner)

In this post

  • Not “European P25” — the assumptions TETRA breaks, in one table.
  • Information in the transition — the differential decode and what it buys.
  • A constellation that never sits still — eight points, two QPSK sets, one Gray map.
  • 144 kHz or nothing — why the channel rate is a per-protocol decision.
  • The receiver in one pass — the chain from IQ to dibits, and the hooks we’ll use later.

Not “European P25”

It’s tempting to file TETRA as “the European trunking protocol” and assume the P25 machinery transfers. Almost none of it does. Every C4FM-family protocol in GopherTrunk — P25 Phase 1, DMR, NXDN, dPMR — is four-level FSK: an FM discriminator turns frequency into amplitude, and a slicer maps four amplitude levels to dibits. TETRA is a different animal on every axis that matters:

Axis P25 Phase 1 (C4FM) TETRA TMO
Modulation 4-level FSK (frequency) π/4-DQPSK (differential phase)
Symbol rate 4800 sym/s 18000 sym/s
Channel width 12.5 kHz 25 kHz
Channel bit rate 9.6 kbps 36 kbps before slot muxing
Downlink bursty continuous 4-slot TDMA
GT channel rate 48 kHz 144 kHz
Vocoder IMBE (MBE family) ACELP (EN 300 395-2)

The one that shapes this whole series is the first row. A C4FM slicer asks “what amplitude is this symbol?” — an absolute question. A TETRA demodulator asks “how far did the phase move since the last symbol?” — a relative one. The demodulation primer covers both families in the abstract; here we care about what the relative question buys and costs on a real carrier.

Information in the transition

The core of the demodulator is a dozen lines. At each symbol time it multiplies the current symbol by the conjugate of the previous one — which subtracts their phases — removes the π/4 rotation offset, and slices the remaining phase delta into one of four quadrants:

// internal/dsp/demod/dqpsk.go (shape) — Decode
for i, s := range src {
    // Phase delta: arg(s * conj(last)) − rotation.
    ar := real(s)*real(d.last) + imag(s)*imag(d.last)
    ai := imag(s)*real(d.last) - real(s)*imag(d.last)
    phi := math.Atan2(float64(ai), float64(ar)) - d.rotation
    /* … wrap to [-π, π) … */
    switch {
    case phi >= -math.Pi/4 && phi < math.Pi/4:
        dst[i] = 0b00
    case phi >= math.Pi/4 && phi < 3*math.Pi/4:
        dst[i] = 0b01
    /* … 0b11, 0b10 … */
    }
    d.last = s
}

Read what is not in that loop: no carrier phase estimate, no phase-locked loop, no reference symbol. d.last is the only memory, initialized to 1+0i. If the whole constellation arrives rotated by some unknown constant — because the LO started at an arbitrary phase, or a filter imposed a phase shift — the rotation appears in both s and last and cancels in the conjugate product. That is the deep reason differential modulation exists, and it is worth planting now because it becomes load-bearing later: any processing inserted before this decoder is safe only if it imposes a constant phase. A filter whose phase wanders symbol-to-symbol does not cancel in s·conj(last) and corrupts every dibit — the exact trap the equalizer work in Parts 8–10 had to design around, and a theme the Weak-Signal Engineering series develops in full.

The cost of the relative question is error doubling: one bad symbol corrupts two transitions. TETRA’s channel coding (Part 3) is budgeted for that.

A constellation that never sits still

Plain DQPSK encodes dibits as phase deltas of 0°, ±90°, 180°. The 180° case is a problem: the trajectory passes through the origin, the envelope collapses, and timing recovery loses its reference. π/4-DQPSK fixes this by adding 45° to every transition — the four deltas become ±45° and ±135°, and consecutive symbols always land on alternating QPSK constellations, 45° apart. You see eight points on a scope, but only four transitions carry information.

+45° → dibit 00 +135° → dibit 01 two QPSK sets, 45° apart even symbols land on one set, odd symbols on the other — the trajectory never crosses the origin deltas: ±45°, ±135° — never 0°, so every symbol is a transition timing recovery can clock off a constant rotation of ALL points cancels in s·conj(last)
π/4-DQPSK: eight visible points from two interleaved QPSK sets, but only the four phase deltas carry dibits — and a constant rotation of everything cancels in the differential decode.

The mapping from bit pairs to those deltas is TETRA’s own Gray code, and GopherTrunk keeps it in exactly one place:

// internal/radio/tetra/sync.go (shape)
func TetraBitsToDibits(bits []uint8) []uint8 {
    out := make([]uint8, len(bits)/2)
    for i := range out {
        b1, b2 := bits[2*i]&1, bits[2*i+1]&1
        out[i] = (b1 << 1) | (b1 ^ b2) // TETRA Gray: 00→0, 01→1, 11→2, 10→3
    }
    return out
}

TetraBitsToDibits and its inverse are the single source of truth, kept deliberately apart from the C4FM-linear framing.DibitsToBits — mixing the conventions produces garbage that looks exactly like a demod bug. One residual wrinkle: a leftover carrier-frequency offset advances the phase a constant amount per symbol, which shows up as the whole dibit stream rotated by an unknown 0..3. That’s why every training-sequence correlator in Part 2 runs under all four rotations.

144 kHz or nothing

GopherTrunk channelizes every protocol to a fixed per-protocol rate, and the choice is made in one function:

// internal/scanner/ccdecoder/ddc.go (shape)
func ddcTargetForProtocol(p trunking.Protocol) float64 {
    if p == trunking.ProtocolTETRA || p == trunking.ProtocolTETRADMO {
        return tetraDDCTargetRateHz // 144_000
    }
    return ddcTargetRateHz // 48_000 — the 4800-baud C4FM family
}

At 18000 sym/s, 144 kHz is exactly 8 samples per symbol, and the receiver’s Gardner loop, AFC, channel filter and AGC are all sized from that figure. The exported DDCTargetForProtocol exists so offline replay builds an identical down-converter — sizing a TETRA replay to the 48 kHz C4FM default would leave under 3 samples per symbol and the timing loop would never lock. The war story of what happens when a capture arrives below the channel rate (a 50 kHz recording that decoded to a plausible-but-wrong 16667 sym/s) is told in Protocol Decoders Part 7; the standing rule is that the down-converter normalises in both directions so the receiver always sees its designed rate.

The receiver in one pass

internal/radio/tetra/receiver composes the chain: optional channel-select filter (±15 kHz, because the 144 kHz passband is ±72 kHz and adjacent carriers leak in — measured to cut on-air symbol errors by an order of magnitude), the α = 0.35 RRC matched filter, symbol-timing recovery, AFC, then the differential decode. Timing defaults to Gardner (ParseClockMode("") returns ClockGardner), which the timing-recovery deep dive covers in general form; the AFC runs before timing because a spinning constellation corrupts the Gardner metric. The whole thing is stateful and per-carrier: one Receiver per tuned frequency.

Two hooks in receiver.Options are worth noticing now even though we won’t use them for several parts: SoftSink emits the complex differential per symbol (the soft information Part 8 builds on), and SymbolSink emits the raw pre-differential symbols (the linear-channel domain the trained equalizer in the later parts needs). Both are nil by default, cost nothing when unused, and exist precisely because the differential product s·conj(last) destroys information a linear equalizer requires — a consequence of this part’s central property, arriving early.

How that principle shaped the Go code

  • One convention, one owner. The Gray map lives only in TetraBitsToDibits/TetraDibitsToBits; the rotation constant lives only in receiver.Rotation. Nothing downstream re-derives either.
  • Rates are named, not implied. SymbolRate, tetraDDCTargetRateHz and the 8 sps product are constants with doc comments, so “the receiver’s designed rate” is a greppable fact rather than folklore.
  • The demod is shared, parameterised. PiOver4DQPSK serves both TETRA (rotation π/4) and P25 Phase 2 H-DQPSK (π/8) — the same primitive, one rotation argument apart, so a fix in the differential core lands in both.

Where this goes next

A dibit stream at 18000/s is not yet a protocol — it’s a firehose with no punctuation. Part 2 adds the structure: the synchronisation and normal downlink bursts, the training sequences that mark them, the 255-dibit slot grid — and the hard-won lesson of ndbSBSlotShift, where anchoring that grid one slot wrong silently misfiles every traffic burst on the carrier.

FAQ

Why differential modulation at all — why not coherent QPSK? Coherent QPSK needs a carrier-phase estimate, and a phase-recovery loop on a fading mobile channel is a liability: every cycle slip is a burst of errors. Differential encoding trades ~2–3 dB of sensitivity for never needing absolute phase — the receiver works the moment two consecutive symbols arrive, which also makes TDMA slot boundaries and late tune-ins cheap.

Does GopherTrunk ever see the eight constellation points? Yes — the raw symbols exist between timing recovery and the differential decode, and SymbolSink exposes exactly that stream. The hard decode only ever looks at the transition, but the diagnostics panels and the trained equalizer (later in this series) both consume the raw-symbol view.

Why is the TETRA channel rate 144 kHz when the channel is only 25 kHz wide? 144 kHz is a processing rate, not a bandwidth: 8 samples per symbol at 18000 sym/s. The occupied signal is ≈±12 kHz; the surplus passband is why the receiver optionally inserts its own ±15 kHz channel-select filter before the matched filter.

What happens if a capture was recorded at 50 kHz instead? The down-converter interpolates it up to 144 kHz so the receiver still runs at 8 samples/symbol. Feeding it raw gives ~2.78 samples/symbol, which rounds to 3 and drifts the recovered clock ~7% — a plausible-looking symbol rate that never locks. That story is in Protocol Decoders Part 7.

Is the 0..3 dibit rotation from carrier offset a bug? No — it’s inherent. A residual CFO adds a constant phase per symbol, which the quadrant slicer reads as a constant dibit offset. The AFC removes most of it, and everything that correlates patterns (sync detectors, Part 2) searches all four rotations so a residual rotation can’t hide a burst.

Series navigation

Part 1 of 14 · Next → Part 2: The Burst Zoo & the Slot Grid