Part 3 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 2 sliced bursts from sync matches and left one asymmetry hanging: only burst A of a voice superframe carries a sync at all. This part is about the rhythm that locates the other five — and the thread’s second twin. A repeater interleaves two timeslots so one call’s bursts sit 264 or 288 dibits apart; a simplex handheld transmits alone at the same cadence with silence where the other slot would be. A decoder that assumed back-to-back bursts sliced that silence as speech, and its counters looked healthy.
TL;DR: DMR voice is a superframe of six bursts A–F, 360 ms, 18 AMBE+2 frames (
internal/radio/dmr/voice/superframe.go). Only A carries a voice sync; B–F are found by cadence — the same-slot stride between a call’s bursts.NewDecoderassumes 132 (back-to-back);NewInterleavedDecoderauto-detects 264 (no CACH) or 288 (12-dibit CACH before each burst). Detection is authoritative from a CRC-valid embedded LC (lockedByLC) and provisional from AMBE Golay(23,12) corrected-bit scores (ambeErrorScore, ceiling 24, margin 6) — a wrong guess can be overridden later (reopened #644). Each superframe carries a relativePhasethe composer’sslotRouterbinds to a talkgroup via the embedded LC. A direct-mode handheld is one burst per 60 ms frame — also 288 dibits — sotrunking.DMRVoiceCadenceDetectednow defaults every DMR protocol to the cadence-detecting decoder; the single-slot decoder sliced the gaps, reporting bogusambe_okandlc_superframes=0on the #836 captures.
Key takeaways
- Five of six voice bursts have no landmark. Burst A’s sync anchors the superframe; B–F are cut at a fixed stride, so a wrong stride splices the other timeslot — or the gap — into every AMBE frame.
- Cadence is detected, not assumed. A CRC-valid embedded LC locks it; absent one, the AMBE FEC score picks a provisional winner a later LC can still overturn — the fix for a call that “sounded encrypted” throughout.
- Phase is relative.
(start / (step/2)) mod 2tells two interleaved calls apart; it is not TS1/TS2, because both slots share the BS sync. - A repeater’s other slot and a handheld’s silence are the same stride. 288 dibits either way — so one decoder now serves all three tiers, and the 132-dibit decoder’s counters on a simplex capture were fiction.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Superframe | bursts A–F, 18 AMBE frames, LC from B–E | internal/radio/dmr/voice/superframe.go (VoiceSuperframe) |
| Cadences | 132 single-slot; 264 / 288 interleaved | superframe.go (NewDecoder, NewInterleavedDecoder, cachDibits) |
| Cadence lock | LC authoritative, AMBE score provisional | superframe.go (resolveAndSlice, lockedByLC, ambeErrorScore) |
| Score gates | winner ≤ 24 corrected bits, runner-up ≥ 2× + 6 | superframe.go (ambeCadenceLockCeiling, ambeCadenceLockMargin) |
| Phase | relative slot label per superframe | superframe.go (sliceAt); composer/dmr_voice.go (slotRouter) |
| Per-protocol default | cadence decoder on for every DMR protocol | internal/trunking/site.go (DMRVoiceCadenceDetected) |
| Replay instrument | GT_DMR_INTERLEAVED=1, per-phase counts |
cmd/gophertrunk/dmr_ipsc_replay_test.go (TestDMRIPSCReplay) |
In this post
- Six bursts, one sync — the superframe and what each burst carries.
- Three cadences for one call — 132, 264, 288, and where the CACH comes from.
- Detecting the stride — LC first, Golay score second, provisional until proven.
- Phase is a relative label — separating two calls without TS1/TS2.
- The gap that read as voice — #836’s simplex captures and the 132-dibit decoder.
Six bursts, one sync
A DMR voice call is organised into superframes of six 132-dibit bursts, A through F, spanning 360 ms and carrying three 72-bit AMBE+2 frames each — 18 per superframe (superframe reference). Burst A is framed by a voice sync word. Bursts B–F replace the sync with embedded signalling — a 16-bit EMB around a 32-bit fragment — and B–E’s four fragments reassemble into the embedded Link Control that names the call (Part 5). The decoder’s output carries both:
// internal/radio/dmr/voice/superframe.go (shape)
type VoiceSuperframe struct {
Frames [18][]byte // bursts A..F, three 72-bit frames each
StartDibit int // absolute dibit index of burst A
Phase uint8 // relative slot label on a 2-slot carrier
HasLC bool // embedded LC from B–E passed BPTC + checksum
LC dmr.FLC
/* … EMB colour code, RC, talker alias, GPS … */
}
The consequence Part 2 planted now bites: only burst A produces a sync
match. The Decoder locks onto it with a detector restricted to the four
voice syncs, then cuts B–F at a fixed stride and pulls AMBEFrames from
each. Get the stride wrong and every frame after A is cut from the wrong
dibits, and nothing in B–F will complain — nothing in them is a landmark.
Three cadences for one call
Part 1 did the arithmetic: a 60 ms frame is 288 dibits. What sits between two bursts of one call depends on who is transmitting:
| Stream | Between a call’s bursts | Stride |
|---|---|---|
| synthetic back-to-back (old fixtures) | nothing | 132 |
| 2-slot carrier, no CACH | the other slot’s burst | 264 |
| base-station outbound (live repeater) | CACH + other slot + CACH | 288 |
| direct-mode handheld | 156 dibits of receiver noise | 288 |
The CACH is the 24-bit Common Announcement Channel a base station inserts
before each outbound burst
(CACH reference); GopherTrunk
uses it purely as spacing, cachDibits = 12. The constructors encode the
table:
// internal/radio/dmr/voice/superframe.go (shape)
func NewDecoder() *Decoder { return newDecoder([]int{dmr.BurstDibits}) } // 132
func NewInterleavedDecoder() *Decoder {
return newDecoder([]int{2 * dmr.BurstDibits, 2 * (dmr.BurstDibits + cachDibits)}) // 264, 288
}
A single-cadence decoder locks its one stride at construction; the interleaved decoder buffers enough for every candidate to be sliceable, then chooses.
Detecting the stride
resolveAndSlice decides the cadence per call with two grades of evidence.
The strong one is signalling: a stride whose bursts B–E reassemble a
CRC-valid embedded LC is right — a wrong slice cannot reassemble one — so
it locks authoritatively (lockedByLC = true). The weak one is speech
quality: ambeErrorScore sums the Golay(23,12) corrected-bit count across
a slice’s 18 AMBE frames (DecodeAMBEFrame, Part 11); a correct slice
needs very few corrections, a wrong one pulls bits from the other slot or
the CACH and averages ~4–5 per frame.
// internal/radio/dmr/voice/superframe.go (shape)
const (
ambeCadenceLockCeiling = 24 // winner's total corrected bits (~1.3/frame)
ambeCadenceLockMargin = 6 // runner-up must exceed 2×winner by this
)
func (d *Decoder) resolveAndSlice(start int, syncName string) VoiceSuperframe {
if d.lockedStep != 0 && d.lockedByLC {
return d.sliceAt(start, d.lockedStep, syncName) // authoritative
}
for i, step := range d.cadenceCandidates {
sf := d.sliceAt(start, step, syncName)
if sf.HasLC { d.lockedStep, d.lockedByLC = step, true; return sf }
/* … score with ambeErrorScore, track best and runner-up … */
}
clearWinner := bestScore <= ambeCadenceLockCeiling &&
bestScore*2+ambeCadenceLockMargin <= secondScore
/* … clearWinner ⇒ provisional lock (lockedByLC stays false) … */
}
The provisional grade is the hard-won part. The first #644 fix chose
cadence by LC alone; on a carrier whose LC never validated it fell back to
264, which on a 288 carrier sliced every burst 24 dibits off — structured
noise that “sounded encrypted”. The AMBE score fixed that. Then #644
reopened: a call that opened with no decodable LC could have the score
pick the wrong stride, and the old code froze that lock — and a wrong slice
never reassembles the LC that would correct it, so the rest of the call
garbled with no way back. Now an FEC lock stays re-checkable every
superframe: a later CRC-valid LC, or a clear FEC winner at another stride,
moves it. TestInterleavedDecoderLCOverridesWrongProvisionalCadence drives
that sequence — a CACH-free preamble locks 264 provisionally, then 288
traffic with a valid LC arrives and the lock must move.
Phase is a relative label
A 2-slot carrier runs two calls at once, and the interleaved decoder emits
superframes for both. The BS-Voice sync in burst A is identical on both
slots, and the wire format does not label a burst’s physical slot. What the decoder can compute is
parity — two slots’ burst-A anchors sit one physical burst (step/2) apart,
so sliceAt stamps sf.Phase = (start / (step/2)) % 2 on a multi-candidate
decoder.
Phase is a relative discriminator — stable per call, distinct between
the two concurrent calls, not an absolute TS1/TS2. Binding a phase to a
talkgroup is the embedded LC’s job, done by the composer’s slotRouter
(internal/voice/composer/dmr_voice.go): a superframe whose LC names this
call’s destination binds its phase; one naming a different destination
marks that phase foreign (foreignPhaseMask); and if no LC decodes, after
unboundPhaseFallbackGrace = 2 LC-less superframes the router binds the
active slot’s phase so the call records rather than dropping. The same
parity becomes the synthetic Timeslot on a Tier II grant, the
engine-identity token Part 6 builds two concurrent calls on.
The gap that read as voice
Now the twin in the field. Part 1 described a direct-mode handheld: one 132-dibit burst, then 156 dibits of receiver noise — a same-slot stride of 288, indistinguishable in cadence from a repeater’s TS1 bursts with TS2 and two CACHs between them.
For the whole life of
#836 the Tier I
pipeline, siglab and replay -record-voice ran the single-slot decoder;
only Tier II/III got the interleaved one. On the reporter’s 15 Sep
446.500 MHz captures it anchored burst A correctly and Its counters were not just low, they were wrong:
lc_superframes=0 because no LC reassembles from noise, and ambe_ok
inflated, because the gate mutes the discriminator on absent samples and
muted gaps Golay-decode as valid all-zero words. The same defect resurfaced
in the Enhanced Privacy harness a day later (Part 12) as frames that looked
scrambled through their FEC — hence the standing instruction: a C0/C1
Golay histogram is the first thing to check when frames look scrambled.
The fix is a default, not a new decoder:
// internal/trunking/site.go (shape)
// On air no DMR carrier lays a call's bursts back to back — the #836
// captures decode their embedded LC in 26 of 35 superframes at the 288
// cadence and in none at 132.
func DMRVoiceCadenceDetected(p Protocol) bool {
switch p {
case ProtocolDMR, ProtocolDMRTier2, ProtocolDMRTier1:
return true
}
return false
}
resolveDMRInterleavedVoice in daemon.go applies it unless the operator
forces dmr_interleaved_voice; replay and siglab build their
trunking.System from the same function. TestDMRDirectModeRealAirKeyup
asserts superframes with an LC naming talkgroup 99 at 288, and
TestDMRIPSCReplay warns when it sees MS-sourced syncs without
GT_DMR_INTERLEAVED=1. The direct-mode chain is on-air verified on the
15 Sep captures; the reporter’s live run on a build carrying these fixes
is still open (#836).
How the cadence twin shaped the Go code
- One decoder, a list of candidates.
newDecoder([]int{…})takes the strides it may choose between; single-slot is the one-element case, not a fork. - Evidence has grades.
lockedByLCseparates “proven by signalling” from “guessed by FEC quality”, and only the second grade is re-checked — the reopened #644 lesson as a boolean. - Labels are honest about what they are.
Phaseis documented as relative,Timeslotas synthetic; nothing pretends to know TS1 from TS2. - Defaults follow the air, not the fixture.
DMRVoiceCadenceDetectedexists because every synthetic DMR fixture laid bursts back to back — the self-consistent trap in TDMA dress.
Where this goes next
Cadence tells the decoder where a burst is; whether to believe it is the FEC stack’s job. Part 4 walks BPTC(196,96), RS(12,9) and the two CRC masks — and the voice burst whose AMBE bits Golay-decoded to a phantom “Terminator with LC” and ended a live call mid-sentence.
FAQ
What is a DMR voice superframe?
Six consecutive bursts of one call, labelled A–F, spanning 360 ms and
carrying 18 AMBE+2 voice frames of 72 bits. Burst A carries a voice sync
word; bursts B–E carry the four fragments of the embedded Link Control that
names the talkgroup and radio. GopherTrunk decodes it in
internal/radio/dmr/voice.
Why are a DMR call’s bursts 264 or 288 dibits apart? Because a 2-slot carrier interleaves the other timeslot’s burst between a call’s own: 2 × 132 = 264 dibits with no CACH, or 2 × (132 + 12) = 288 when a base station inserts its 12-dibit CACH before each outbound burst. The interleaved decoder auto-detects which is in use.
How does GopherTrunk know which cadence a call uses? Two ways. A CRC-valid embedded Link Control reassembled from bursts B–E is authoritative — a wrong stride cannot produce one. Without it, the decoder scores each candidate by the Golay(23,12) corrections its AMBE frames needed and locks the clear winner provisionally; a later valid LC can override it.
Can GopherTrunk tell TS1 from TS2?
Not from the air interface alone: both slots use the same BS-Voice sync and
the burst carries no slot number. Each superframe gets a relative Phase
from its anchor’s parity, and the composer’s slotRouter binds a phase to
the talkgroup named by the embedded LC. The grant’s Timeslot is a
synthetic identity token.
Why did direct-mode DMR decode no embedded LC before the #836 fixes?
Because Tier I, siglab and replay used the single-slot decoder, which
assumes a call’s bursts are back to back. A simplex handheld transmits one
burst per 60 ms frame, so bursts B–F were cut from the inter-burst gaps —
no LC could reassemble, and muted gaps Golay-decoded as valid all-zero
words, inflating ambe_ok.
Series navigation
Part 3 of 14 · ← Part 2: Bursts, Sync Words & Polarity · Next → Part 4: The FEC Stack & the Forged Terminator