Part 2 of TETRA End to End, a 14-part deep dive into how GopherTrunk turns
one real 25 kHz TETRA carrier — the MCC 250 / MNC 13 cell — into clear recorded
voice. Part 1
turned IQ into a dibit stream and planted
the property everything leans on: information rides in phase transitions, so a
residual carrier offset shows up as a constant 0..3 rotation of every dibit.
Now we need punctuation. A TETRA downlink is a continuous stream of bursts on a
rigid TDMA grid, and this part is about how GopherTrunk finds them: which burst
carries what, how the training sequences are correlated, and the one-slot
anchor lesson — ndbSBSlotShift — that only a real capture could teach.
TL;DR: The TMO downlink alternates two burst shapes. The synchronisation burst (SB) lays out freq-correction → BSCH(60 dibits) → sync training sequence(19) → broadcast(15) → BNCH(108); the BSCH is always scrambled with colour 0, so a cold receiver can decode its SYNC PDU (
tetra.ParseSyncPDU) and learn the cell’s colour code, frame and multiframe counts. The normal continuous downlink burst (NCDB) puts its data around the training sequence — BKN1 before, AACH halves either side, BKN2 after (internal/radio/tetra/traffic.go). Bursts are found by correlating three real ETSI training sequences under all four dibit rotations (SyncDetector). The SB anchors the 255-dibit slot grid, but its training sequence sits late in the burst, so the anchor lands one NDB slot early —ndbSBSlotShift = 3corrects it, verified against a real same-carrier capture where grant timeslots finally lined up with decoded slots.
Key takeaways
- Two bursts carry the whole downlink. SB for acquisition (BSCH + BNCH around the long sync training sequence), NCDB for everything else (signalling and traffic around the short normal training sequence).
- The BSCH is the bootstrap. Scrambled with colour 0 by spec, it decodes with zero configuration and hands over the colour code that unlocks every other logical channel — Part 4 is entirely about that handoff.
- Correlation, not magic constants. The training sequences are the real EN 300 392-2 §9.4.4.3 bit arrays; placeholder constants once made lock impossible on air, a postmortem told in From the Issue Tracker Part 17.
- The slot grid is anchored, and the anchor has an offset. A burst’s slot
number is
round((L − sbAnchor)/255)— plus a shift of 3, because the SB’s training sequence position and the NDB’s differ inside the slot. Get that wrong and every burst files under the wrong timeslot with no error anywhere.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Training sequences | real §9.4.4.3 bit arrays: NTS1/NTS2 (22b), extended (30b), STS (38b) | internal/radio/tetra/sync.go (NormalTrainingSeq1, SyncTrainingSeq) |
| Sliding correlator | pattern match within tolerance, all four rotations | sync.go (SyncDetector, rotateDibits) |
| SB geometry | BSCH [L−60, L), BNCH [L+34, L+142) around the STS |
internal/radio/tetra/process.go (processSB, sbBSCHDibits) |
| NCDB geometry | BKN1 [L−115, L−7), AACH halves, BKN2 [L+19, L+127) |
internal/radio/tetra/traffic.go, downlink.go (downlinkNCDB) |
| SYNC PDU | colour, TN/FN/MN, MCC/MNC from the BSCH | internal/radio/tetra/sync_pdu.go (ParseSyncPDU) |
| Slot grid anchor | SB pins slot 1; each slot is 255 dibits | traffic.go (slotOf, ndbSBSlotShift) |
In this post
- The burst zoo — SB and NCDB, and what each block inside them carries.
- Finding a burst — three training sequences, four rotations, one detector shape.
- The SYNC PDU — the bootstrap message a cold receiver can always read.
- The slot grid — 255 dibits per slot, and time as frames and multiframes.
- The anchor lesson — why the SB pins the grid one slot early.
The burst zoo
TETRA numbers its downlink physical layer around two burst shapes, both 255
dibits of slot but organised very differently. The synchronisation downlink
burst (SB) is the acquisition burst, transmitted in slot 1 of frame 18 of
every multiframe. Its layout, in dibits relative to the leading dibit L of
its 19-dibit synchronisation training sequence (STS):
// internal/radio/tetra/process.go (shape) — SB geometry
// freq-correction → BSCH(60) → STS(19) → broadcast(15) → BNCH(108).
// Relative to the STS leading dibit L: BSCH is [L-60, L), BNCH is [L+34, L+142).
const (
stsDibits = 19 // SyncTrainingDibits length
sbBSCHDibits = 60 // BSCH block 1 (120 type-5 bits)
sbBroadcastDibits = 15 // broadcast block between STS and BNCH
sbBNCHDibits = 108 // BNCH block 2 (216 type-5 bits, SCH/HD-coded)
)
The BSCH half carries the SYNC PDU (below); the BNCH half carries SYSINFO — the cell’s main carrier, band and access parameters, channel-coded like any SCH/HD block.
The normal continuous downlink burst (NCDB) carries everything else: signalling on the control channel, traffic on a voice carrier. Its defining quirk is that the data does not follow the training sequence — it surrounds it:
// internal/radio/tetra/traffic.go (shape) — NCDB layout, dibits relative to L
// BKN1 (block 1) : [L-115, L-7) 108 dibits (216 type-5 bits)
// AACH half 1 : [L-7, L) 7 dibits ( 14 bits)
// training seq : [L, L+11) 11 dibits ( 22 bits)
// AACH half 2 : [L+11, L+19) 8 dibits ( 16 bits)
// BKN2 (block 2) : [L+19, L+127) 108 dibits (216 type-5 bits)
Any decoder that slices “N dibits after the training sequence” — the natural
first implementation — misses BKN1 entirely and starts reading mid-AACH. Both
the control-channel slot decoder (downlinkNCDB in downlink.go) and the
traffic extractor are therefore built as rolling buffers with look-back: a
training-sequence hit is queued as pending until the buffer holds the full
[L−115, L+127) span, then the blocks are sliced around it. The 30-bit AACH
split across the midamble is the slot’s address label — its access-assignment
element says what the slot is carrying — and it becomes the demux key for
concurrent calls in Part 5.
Finding a burst: three sequences, four rotations
Bursts are located by correlating known training sequences against the dibit
stream. GopherTrunk stores the real ETSI EN 300 392-2 §9.4.4.3 sequences as
bit arrays — normal 1 and 2 at 22 bits / 11 dibits, extended at 30 / 15, and
the synchronisation sequence at 38 / 19 — and SyncDetector slides each over
the stream reporting matches within a mismatch tolerance. Two details carry
the weight:
- All four rotations, always. Part 1’s residual-CFO rotation means the
pattern may arrive uniformly rotated by 0..3, so every detector bank is
built as
NewSyncDetector(rotateDibits(pattern, r), tol)forr = 0..3— eight detectors for the two normal sequences, four for the STS. - NTS1 vs NTS2 is a signal, not a duplicate. A burst whose midamble matches
the second normal training sequence is a stolen half-slot (STCH) — urgent
signalling displacing speech — and the traffic extractor tags it
(
pendingHit.stolen) rather than treating it as a weaker NTS1 match.
The constants themselves have a scar. The original implementation shipped
placeholder uint64 “hex” values that were both truncated and matched no spec
value, so the control channel correlated against sequences that never existed
on air — lock was structurally impossible, while every synthetic test (built
from the same wrong constants) passed. That is this series’ villain making an
early appearance;
From the Issue Tracker Part 17
does the full autopsy, and the
training-sequence reference
lists the corrected values.
The SYNC PDU: what a cold receiver can read
The BSCH exists so a receiver with zero configuration can bootstrap. By §8.2.5.2 it is always scrambled with colour code 0 — every other channel uses the cell’s colour — so its 60 decoded type-1 bits are readable before you know anything about the cell. GopherTrunk parses them with the osmo-tetra-compatible field layout:
// internal/radio/tetra/sync_pdu.go (shape)
type SyncPDU struct {
ColourCode uint8 // 6-bit colour code (bits 4..9)
TN uint8 // timeslot number (bits 10..11)
FN uint8 // frame number (bits 12..16)
MN uint8 // multiframe number (bits 17..22)
MCC uint16 // mobile country code (bits 31..40)
MNC uint16 // mobile network code (bits 41..54)
}
Three of those fields — MCC, MNC, ColourCode — combine into the 30-bit
extended colour code that seeds the scrambler for everything else on the cell
(SyncPDU.ExtendedColourCode, Part 4’s subject). The other three are time:
TETRA counts a 4-slot frame, an 18-frame multiframe, and a 60-multiframe
hyperframe (§4.5.2), and the SYNC PDU tells you where in that hierarchy you
are. The decoder uses MN advancement as its sync-continuity heartbeat: rather
than log thousands of sync bursts, it logs once per multiframe on an MN change
and flags gaps — a monotonically advancing MN is the difference between “lock”
and “the correlator fired on noise,” a distinction that pays for itself again
on DMO in Part 11.
The slot grid: 255 dibits, four slots, 56.67 ms
Under the bursts is arithmetic. One slot is 255 dibits (510 bits); four slots make a 1020-dibit frame, which at 18000 dibits/s is 56.67 ms. On a voice carrier all four slots can carry independent calls, so a burst’s timeslot number matters: it decides which call’s recording a speech frame belongs to.
The traffic extractor derives the slot from the SB. Its STS detectors watch
the stream, and each hit pins sbAnchor; a later burst leading at dibit L
is then slot round((L − sbAnchor)/255) mod 4, with rounding absorbing small
intra-slot jitter. The anchor refreshes on every SB — once per multiframe — so
slow clock drift can’t accumulate.
The anchor lesson: ndbSBSlotShift
Here is the part no spec diagram hands you. The SB’s training sequence is not
where the NDB’s is: the STS sits late in the SB (after the frequency
correction and the whole BSCH block), while the NDB’s midamble sits at the
slot’s centre. So the raw subtraction (L − sbAnchor)/255 produces slot
numbers that are internally consistent but globally off by one — every burst
one slot after the SB reads as slot 0 instead of TN1.
// internal/radio/tetra/traffic.go (shape) — slotOf
const ndbSBSlotShift = 3 // (= −1 mod 4): a burst one slot after the anchor reads as TN1
func (te *TrafficExtractor) slotOf(L int) uint8 {
if !te.haveAnchor {
return 0 // unknown until an SB is seen
}
si := (int(math.Round(float64(L-te.sbAnchor)/float64(ndbSlotDibits))) + ndbSBSlotShift) % 4
/* … */
return uint8(si) + 1
}
What makes this a lesson rather than a constant is how it fails and how it
was fixed. A wrong shift produces no error at any layer: the bursts decode,
the CRCs pass, and every speech frame files under a mislabeled timeslot —
which on a multi-call carrier means calls swap audio. Nothing synthetic
catches it, because a synthetic fixture built from your own slot math is
self-consistent by construction (the villain again). The value 3 was verified
the only way it could be: against a reporter’s real same-carrier capture,
where the control channel’s granted timeslots (ts1, ts2) finally lined up
with the decoded slot tags once the shift was applied. And even then the code
treats the slot number with suspicion — the doc comment in traffic.go is
blunt that on real air the AACH usage marker is the reliable per-call demux
key while the slot number is telemetry, a distinction Part 5 turns into
routing.
How that principle shaped the Go code
- Geometry is a named constant set, not inline arithmetic.
ndbBKN1Start,ndbAACH1Start,sbBSCHDibitsand friends make each burst’s layout a reviewable table against the spec — and made the DMO geometry variant (Part 11) a diff instead of a rewrite. - Look-back is a buffer-management contract. Both NCDB decoders keep a
trailing margin (
ndbTrimMargin) plus any pending hit’s look-back, so a training hit near a chunk boundary never loses its BKN1. - Anchoring is refreshed, never trusted forever. The SB anchor updates every multiframe and reports slot 0 (“unknown”) until first seen — a traffic-only carrier with no SB degrades to unlabeled bursts rather than invented slots.
Where this goes next
We can now slice a burst’s 216 or 432 type-5 bits out of the stream — but type-5 bits are still scrambled, interleaved, punctured and convolved. Part 3 walks the channel-coding chain back to information bits: two RCPC mother codes, the Viterbi decoder, the interleavers — and the star exhibit, a CRC that is not an LFSR at all, whose wrong implementation silently dropped every on-air burst while all the synthetic tests stayed green.
FAQ
Why does the data surround the training sequence instead of following it? A midamble halves the maximum distance between any data bit and the channel estimate the training sequence provides — both blocks sit within ~half a slot of it. It also means burst detection is naturally centred: once the midamble correlates, both blocks are at fixed offsets either side.
What happens between SBs — is the receiver blind for a whole multiframe? No. The SB is only the anchor and bootstrap; every slot in between carries NCDBs the receiver decodes continuously. The once-per-multiframe cadence only bounds how often the slot-grid anchor and MN counter refresh.
Can the receiver lock without ever decoding a BSCH? It can correlate training sequences and extract bursts, but it can’t descramble anything beyond the BSCH itself without the colour code the SYNC PDU carries — so a “lock” without a BSCH decode is sync without comprehension. That failure mode (lock succeeds, no messages decode) is exactly what a broken scrambler looks like, and Part 4 tells that story twice.
Why tolerance-based correlation instead of exact match? Real bursts arrive with bit errors, and an exact match would drop a burst whose midamble took one hit even though its FEC-protected payload is fully recoverable. The tolerances (2 for the 11-dibit normal sequences, 3 for the 19-dibit STS) trade a manageable false-hit rate — the downstream CRCs reject impostors — for not losing marginal bursts. Part 13 shows what that false-hit rate means on a silent channel, and why counting raw hits is never evidence of traffic.
How does GopherTrunk know a burst is a stolen half-slot?
By which normal training sequence matched: NTS2 flags STCH (stolen) bursts,
NTS1 marks ordinary ones. The extractor counts stolen bursts
(TrafficExtractor.StolenBursts) so the composer’s voice-path summary can
report signalling-in-speech events.
Series navigation
Part 2 of 14 · ← Part 1: π/4-DQPSK & the Shape of a TETRA Carrier · Next → Part 3: Channel Coding — RCPC, Viterbi & the CRC That Isn’t an LFSR