Part 2 of DMR End to End, a 14-part deep dive that follows the world’s most widely deployed digital PMR protocol through GopherTrunk — from a 4FSK carrier to two simultaneous recorded calls, direct-mode handhelds, and decrypted Enhanced Privacy voice. Part 1 turned IQ into dibits at 4800 a second and planted the thread: one carrier, two of everything. This part gives the firehose its punctuation — burst, sync word, slot type — and meets the first concrete twin: DMR’s data and voice sync words are each other’s image under a polarity flip, so an inverted front end makes every data burst look like voice and vice versa, and the sync detector alone cannot tell which stream it is hearing.
TL;DR: A DMR burst is 132 dibits: 49 of payload, a 5-dibit slot-type field, the 24-dibit sync, another slot-type field, 49 more of payload (
internal/radio/dmr/burst.go). Nine 48-bit sync words (dmr.AllSyncs,sync.go) mark burst boundaries; aSyncDetectorreports the best match within tolerance (2 in production). The words are closed underPolarityFlip— adding 2 mod 4 to every dibit turns BS-Data into BS-Voice bit for bit (TestSyncPairsClosedUnderPolarityFlip) — so an inverted stream syncs happily and reports the wrong twin. The slot type (slottype.go) is colour code + data type under a (20,8,7) code (framing.HammingDecode20_8, corrects 3), read only from data-sync bursts; polarity is resolved by decoding at bothCandidatePolaritiesand letting FEC arbitrate. Tier II then locks the polarity on the first FEC-valid burst (c.polarity,tier2/process.go) and never drops it.
Key takeaways
- The burst is a fixed frame around a central landmark. Sync and slot type sit mid-burst; a match at index i means a burst from i − 77 to i + 54.
- A sync match tells you where, not which. The nine words are closed under the flip, so a BS-Voice match is either a clean voice burst or an inverted data burst; only FEC can say.
- The slot type routes before anyone parses. Colour code plus data type
under a distance-7 code lets
IngestBurstdispatch a burst without touching its 196-bit payload. - Polarity is learned once and kept. Tier II tries both polarities until the first FEC-valid burst, then only the learned one — which makes “slot types only from data syncs” exact and closes the door on Part 4’s forged terminator.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Burst layout | 49 + 5 + 24 + 5 + 49 dibits | internal/radio/dmr/burst.go (Burst, PayloadBits, SlotTypeBitsAll) |
| Nine sync words | 48-bit patterns as 24 dibits; sliding detector | internal/radio/dmr/sync.go (AllSyncs, SyncDetector) |
| Polarity flip | (dibit + 2) & 3; candidates {0, 2} |
burst.go (PolarityFlip, RotateBurstDibits, CandidatePolarities) |
| Data sync per polarity | which words carry a slot type after rotation | sync.go (IsDataSync, SyncIsDataAtPolarity) |
| Slot type | colour code + data type, (20,8,7) t = 3 | slottype.go (ParseSlotType); framing/hamming20.go |
| Polarity lock | first FEC-valid burst, kept across resets | tier2/process.go, conventional.go (polarity, burstValid) |
In this post
- The burst, with offsets — 132 dibits, and how a match becomes a burst.
- Nine words, twenty-four dibits — the sync alphabet and the detector.
- The polarity twin — why a match can’t tell data from inverted voice.
- The slot type — colour code, data type, twelve parity bits.
- Locking the polarity — Tier II’s first FEC-valid burst.
The burst, with offsets
Protocol Decoders Part 5 drew the burst; this part needs its coordinates, because everything that follows is expressed in dibit offsets from the sync:
// internal/radio/dmr/burst.go (shape)
// dibits 0..48 payload first half
// dibits 49..53 slot type before sync
// dibits 54..77 sync / embedded signalling
// dibits 78..82 slot type after sync
// dibits 83..131 payload second half
const (
BurstDibits = 132
HalfPayloadDibits = 49
SlotTypeDibits = 5
SyncDibits = 24
)
Burst.PayloadBits concatenates the halves into the 196-bit
BPTC(196,96) codeword a data
burst carries; SlotTypeBitsAll concatenates the two 5-dibit fields into
the 20-bit slot-type codeword. A voice burst uses the same envelope
differently — 54 + 24 + 54 dibits, three 72-bit AMBE+2 frames and no
slot-type fields (voice.VoiceBits) — the fact Part 4’s forged
terminator hinges on.
The framers slice a burst from a match with three shared constants — the
match index is the last sync dibit, so burstLookback is 77,
burstLookahead 54 and bufKeep 163 — and the
burst reference explains
why sync and slot type sit in the middle.
Nine words, twenty-four dibits
ETSI TS 102 361-1 §9.1.1 defines nine 48-bit sync words, split by source and traffic type:
// internal/radio/dmr/sync.go (shape)
var (
BSVoice = mkSync("BS-Voice", 0x755FD7DF75F7)
BSData = mkSync("BS-Data", 0xDFF57D75DF5D)
MSVoice = mkSync("MS-Voice", 0x7F7D5DD57DFD)
MSData = mkSync("MS-Data", 0xD5D7F77FD757)
MSRC = mkSync("MS-RC", 0x77D55F7DFD77)
DMVoice1 = mkSync("DM-Voice-TS1", 0x5D577F7757FF)
DMVoice2 = mkSync("DM-Voice-TS2", 0x7DFFD5F55D5F)
DMData1 = mkSync("DM-Data-TS1", 0xF7FDD5DDFD55)
DMData2 = mkSync("DM-Data-TS2", 0xD7557F5FF7F5)
)
Read the hex digits: every nibble is 5, 7, D or F — in dibits, only ever ±3. Sync words are built from outer symbols exclusively, which is why a receiver whose AGC has collapsed the eye still matches syncs while every payload fails, and why Part 9’s gap noise (outer symbols sliced as inner) kills sync detection outright.
SyncDetector.Process slides a 24-dibit ring over the stream, counts
mismatches against each pattern with early exit, and appends a
Match{Index, Pattern} when the best is within tolerance — 4 of 24 by
default, 2 in every production adapter. Tier I restricts the detector to the four direct-mode words; Tier II
and III take all nine
(sync-pattern reference).
The polarity twin
A spectrum-inverted or I/Q-swapped front end (issue #264 — the RTL-SDR
Blog V4 / R828D path) negates the FM discriminator output, mapping +3 ↔ −3
and +1 ↔ −1. In Part 1’s dibit map — high bit is the sign — that is exactly add 2 mod 4 to every dibit: PolarityFlip =
2 in burst.go, self-inverse (2 + 2 ≡ 0), applied by RotateBurstDibits
and enumerated with identity as CandidatePolarities = {0, 2}.
And the sync alphabet is closed under that flip. Apply it to BS-Voice
(0x755FD7DF75F7) and the result is 0xDFF57D75DF5D — BS-Data, bit for
bit. MS-Voice ↔ MS-Data and both DM pairs behave the same way; MS-RC is the
lone word without a twin. TestSyncPairsClosedUnderPolarityFlip pins all
four pairs in both directions.
So the detector already fires on an inverted stream — it just reports the
flipped twin. Nothing at the sync layer can tell the rows apart, and
GopherTrunk does not try: both adapters hand IngestBurst a candidate at
each polarity and let the FEC below — slot type, then BPTC(196,96), then
RS(12,9) or the CSBK CRC — drop the wrong one with no state change. It is the rule of
P25 Part 2
and
TETRA Part 2’s
rotation searches: the sync layer finds candidates; FEC decides. (An
operator can also fix an inverted device once: iq_invert: true sets
ccdecoder.Options.Conjugate, negating Q on every raw sample — the
correction TETRA requires.)
The slot type
Each data burst carries its slot type twice — 10 bits before the sync, 10 after — concatenated into one 20-bit codeword:
// internal/radio/dmr/slottype.go (shape)
// bits 0..3 colour code · bits 4..7 data type · bits 8..19 parity
func ParseSlotType(bits []byte) (SlotType, int, error) {
var cw uint32 /* … pack the 20 bits MSB-first … */
data, errs := framing.HammingDecode20_8(cw) // errs < 0 ⇒ uncorrectable
if errs < 0 { return SlotType{}, -1, ErrSlotTypeUncorrectable }
return SlotType{ColorCode: (data >> 4) & 0x0F, DataType: DataType(data & 0x0F)}, errs, nil
}
The code is a (20,8,7) shortened Hamming/Golay with minimum distance 7, so
it corrects up to three bit errors. framing.HammingDecode20_8 decodes by brute-force minimum
distance over all 256 valid codewords rather than a syndrome table: 256
entries fit in cache and the twelve parity masks stay auditable against the
spec. Hold on to 256 of 2²⁰: it is the seed of Part 4’s story.
The eight information bits are what a decoder needs before it can interpret
a payload. The colour code is DMR’s system discriminator
(reference); on an IPSC
profile the operator can pin one via dmr_color_code, which IngestBurst
enforces first. The data type is the router — DTVoiceLCHeader 0x1,
DTTerminatorWithLC 0x2, DTCSBK 0x3, DTIdle 0x9
(slot-type reference) —
and IngestBurst switches on it to handleVoiceHeader, handleTerminator,
handleCSBK or handleIdle, learning what kind of burst arrived even
when the payload is too damaged to use — the ISCH’s role in
P25 Phase 2.
Locking the polarity
Tier III offers every burst at both candidates and lets FEC sort it out. Tier II learned to do better, because of a bug Part 4 tells in full: until the polarity is known, a voice burst A at the untried polarity looks like a data burst, so its AMBE bits get parsed as a slot type. The fix is two gates:
// internal/radio/dmr/tier2/process.go (shape)
for _, k := range dmr.CandidatePolarities {
if c.polarity >= 0 && uint8(c.polarity) != k { continue } // locked
if !dmr.SyncIsDataAtPolarity(m.Pattern, k) { continue } // data syncs only
var b dmr.Burst /* … copy 132 dibits from the buffer … */
dmr.RotateBurstDibits(&b, k)
slot, _, err := dmr.ParseSlotType(b.SlotTypeBitsAll())
if err != nil { continue }
c.burstValid = false
c.IngestBurst(&b, slot)
if c.burstValid && c.polarity < 0 {
c.polarity = int(k)
c.log.Debug("dmr/tier2: discriminator polarity fixed by first FEC-valid burst",
"polarity", k, "sync", m.Pattern.Name)
}
}
SyncIsDataAtPolarity(p, k) is the per-polarity form of “is this a data
sync”: at identity it is IsDataSync(p); at the flip it is IsVoiceSync(p),
because a voice-sync match on an inverted stream is a data burst. So a
BS-Voice match is offered only at k = 2 and a BS-Data match only at k = 0 —
the parser never sees AMBE bits as a slot type. burstValid is set by the
FEC-validated paths (header BPTC + RS, CSBK CRC, terminator LC, the Idle
pattern), and the first time it comes back true the polarity is fixed:
DBG dmr/tier2: discriminator polarity fixed by first FEC-valid burst polarity=2 sync=BS-Voice
That line means the dongle is inverted — the cue to set iq_invert. The
lock is never dropped: ResyncReset, the reset
Part 10’s deaf-heal drives, clears the dibit buffer and pending matches but
keeps the polarity, because inversion is a property of the hardware, not of
the stream position. TestProcess_DecodesPolarityFlippedVoiceLCHeader is the #264
regression: a Voice LC Header stream rotated by +2 must still yield
cc.locked and a grant.
How the polarity twin shaped the Go code
- The flip is one constant, used in both directions.
PolarityFlipis self-inverse, soRotateBurstDibitsboth models an inverted front end in tests and undoes one in production. - Data-ness is a function of (sync, polarity), never of sync alone.
SyncIsDataAtPolarityreplaced a bareIsDataSynccheck once the forged terminator showed why. - Evidence flows back up through one flag.
burstValidis the only channel from the FEC-validated handlers to the polarity decision; a new validated burst type (Idle, Part 7) joins by setting one boolean. - State that describes hardware survives resets.
polarityis excluded fromResyncReset, and its doc comment says why — the issue-tracker lesson that a fix needs its reasoning next to it.
Where this goes next
Bursts are punctuation; the next question is rhythm. Part 3 follows a voice superframe across six bursts and meets the second twin: a repeater interleaves two slots so a call’s bursts sit 264 or 288 dibits apart, a simplex handheld leaves the other slot empty at the same cadence — and a decoder that assumed 132 sliced the gaps as voice.
FAQ
What does a DMR sync word do?
It marks where a burst sits in the dibit stream and which role it plays —
base station or mobile, data or voice, direct-mode slot 1 or 2. GopherTrunk’s
SyncDetector reports the best of the nine ETSI words within two dibit
errors; the match anchors a 132-dibit burst 77 dibits back and 54 forward.
Why can’t the sync word tell GopherTrunk the stream’s polarity? Because the nine words are closed under the discriminator-polarity flip: adding 2 mod 4 to every dibit turns each data sync into its voice twin and back. An inverted front end therefore produces perfectly valid sync matches — of the wrong type. The decoder tries both polarities and lets the slot-type, BPTC and CRC codes reject the wrong one.
What is the DMR slot type? A 20-bit field split around the sync of every data burst: 4 bits of colour code, 4 bits of data type, and 12 parity bits of a (20,8,7) code that corrects three errors. It tells the decoder which system a burst belongs to and whether it is a voice header, terminator, CSBK, idle or data block — before the payload is touched.
How does GopherTrunk handle an RTL-SDR Blog V4 that inverts the spectrum?
Automatically, per burst: the Tier II and III adapters decode each burst at
both polarities until FEC validates one, and Tier II then locks that polarity
for the life of the stream. An operator can also set iq_invert: true on the
device to conjugate the raw IQ up front.
Do voice bursts have a slot type? No. Only data bursts carry the two 10-bit slot-type fields; a voice burst uses those positions for AMBE+2 speech bits, and bursts B–F carry embedded signalling where burst A carries its sync. That is why GopherTrunk parses a slot type only from data-sync bursts at the stream’s polarity.
Series navigation
Part 2 of 14 · ← Part 1: The 4FSK Carrier & the Tier Family · Next → Part 3: Two Slots, One Carrier — Repeater vs Simplex Cadence