DMR End to End, Part 6: Conventional IPSC — Two Slots as Two Calls

Part 6 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 5 read the Full Link Control and built late entry on it. This part is the state machine that consumes it — ConventionalChannel, the per-repeater decoder for Tier II and IPSC — and the two field reports that rebuilt it: one carrier carrying two calls, and replies vanishing inside hangtime.

TL;DR: A conventional DMR repeater interleaves TS1 and TS2 on one carrier, each able to hold its own call. tier2.ConventionalChannel used to track one scalar call with Timeslot 0, so two talkgroups ping-ponged into one and both slots’ AMBE sliced into one “DJ scratchy” stream. It now keys calls by destination (calls map[uint32]*convCall) and gives each a synthetic Timeslot 1/2 (assignSlot) — an engine-identity token only, because the wire never labels a burst’s physical slot. Audio rides dmrSameCarrierTaps = 2 taps whose slotRouter binds by embedded-LC talkgroup; Grant.DMRInterleavedVoice selects the 264/288 cadence decoder. And the 10 Sep IPSC report — replies “completely missed while the radios decode them” — was a header deduped against a call the voice path had already released: headerRekeyDibits (0.25 s) and superframeRekeyGapDibits (2 s) now re-grant it, counted in rekeys, A/B’d on the reporter’s flac with GT_DMR_DROP_TERMINATORS=1 (1 grant → 5).

Key takeaways

  • Identity is per destination, not per slot. Two concurrent calls carry distinct destinations; the synthetic slot exists only so the engine’s (freq, timeslot) identity keeps them apart.
  • The wire does not say which slot a burst is in. Both slots share the BS sync words and the slot type carries colour + data type only — audio is routed by embedded-LC talkgroup, never by slot number.
  • Two decoders of the same terminator disagree in time. The voice path and the control path see the same burst on different taps; a reply can arrive before the control path knows the call ended.
  • A header is only ever sent at keyup. One well past a tracked call’s last header is a new transmission — release and re-grant.

Cheat sheet

Concern What it does Where it lives
State machine headers → grants, terminators → releases internal/radio/dmr/tier2/conventional.go
Concurrent calls destination-keyed map, synthetic slot 1/2 calls map[uint32]*convCall, assignSlot
Engine identity (System, GroupID, Timeslot) internal/trunking/engine.go (channelKey)
Two-slot voice interleaved decoder + per-tap router dmrSameCarrierTaps (cmd/gophertrunk/daemon.go), slotRouter (composer/dmr_voice.go)
Teardown terminator releases its own destination terminatorDest, releaseCall, dmrvoice.TerminatorDetector
Re-key inside hangtime header 0.25 s / LC 2 s past the anchor re-grants headerRekeyDibits, superframeRekeyGapDibits, Counters.Rekeys

In this post

  • One scalar for two slots — the bug as the operator heard it.
  • A map keyed by destination — and a slot that is only a token.
  • Two taps, one router — how audio finds its call.
  • Teardown by destination — terminators end only their own slot.
  • The re-key inside hangtime — the 10 Sep report, root-caused.
  • What the captures say — scrubs, counts, what is still unverified.

One scalar for two slots

Cookbook Part 3 gave the operator’s view: a Tier II repeater is two-slot TDMA, two independent conversations on one carrier. The old ConventionalChannel had one scalar call (inCall, lastTG, lastSrc) and every grant carried Timeslot 0. The two Voice LC Headers alternated and ping-ponged the state, and the engine, which keys a call on (System, GroupID, Timeslot) (Trunking Engine Part 3), folded both onto one (freq, 0) channel.

The audio symptom had a second cause. The composer picks the AMBE decoder from Grant.DMRInterleavedVoice. Tier III stamped it; Tier II never did — dmr_interleaved_voice was resolved in daemon.go (resolveDMRInterleavedVoice) and dropped on the floor, because tier2.Options had no such field. So the single-slot decoder spliced two calls’ AMBE frames into one superframe: the “DJ scratchy” audio of #644.

A map keyed by destination, and a slot that is only a token

The fix replaces the scalar with a map and hands each concurrent call an identity token:

// internal/radio/dmr/tier2/conventional.go (shape)
calls map[uint32]*convCall // keyed by decoded destination (TG or called RID)

func (c *ConventionalChannel) assignSlot(now time.Time) uint8 {
    /* scan c.calls for used1 / used2 and the stalest call */
    if !used1 { return 1 }
    if !used2 { return 2 }
    slot := c.calls[stalestDest].slot   // both taken ⇒ a terminator was missed: evict
    delete(c.calls, stalestDest)
    return slot
}

A new destination claims the lowest free synthetic slot; a repeated header for the same (dest, src) is a dedupe; a new source on a tracked destination is a talker change on the same slot. publishGrant stamps the slot into Grant.Timeslot and c.interleavedVoice into Grant.DMRInterleavedVoice, wired through tier2.Options.InterleavedVoice from both constructors; Tier I stays single-slot.

Why synthetic? Because the wire does not say. A base-station burst carries the BS sync word on both slots and the slot type holds only colour code and data type. Tier III’s grant CSBK carries a real timeslot bit, so tier3.publishGrant maps slot + 1 into the same field (Part 8); Tier II has nothing to read. The token only makes the engine’s channelKey differ between two concurrent calls — it never routes audio.

Two taps, one router

Conventional voice rides the same carrier the state machine decodes, so the daemon registers same-carrier taps instead of a voice SDR:

// cmd/gophertrunk/daemon.go (shape)
const dmrSameCarrierTaps = 2 // one per TDMA slot
// sameCarrierVoiceTaps: DMR Tier II / Tier I → 2, TETRA → 4, DMO → 1,
// trunked protocols → 0 (their voice hops to a separate traffic channel)

Each tap runs the composer’s DMR chain (Voice Coding Part 9). With the interleaved flag it builds dmrvoice.NewInterleavedDecoder, which knows same-slot bursts sit 264 dibits apart (no CACH) or 288 (a 12-dibit CACH before each burst), auto-detects which cadence reassembles a CRC-valid embedded LC, and tags each superframe with VoiceSuperframe.Phase — a relative discriminator, not a TS1/TS2 label.

slotRouter decides which superframes belong to this tap’s call:

// internal/voice/composer/dmr_voice.go (shape)
func (r *slotRouter) accept(sf dmrvoice.VoiceSuperframe) bool {
    if sf.HasLC {
        if dest, ok := lcCallDestination(sf.LC); ok {
            if dest == r.groupID { r.bound = int(sf.Phase); return true }
            r.foreignPhaseMask |= 1 << (sf.Phase & 1) // positively the other slot
            return false
        }
    }
    if r.bound >= 0 { return int(sf.Phase) == r.bound }
    /* unbound: after unboundPhaseFallbackGrace (2) LC-less superframes, bind the active phase */
}

An LC naming this call’s destination binds the phase; one naming another destination marks that phase foreign for ever. The fallback keeps a carrier whose LC never decodes recording — and is the one sharp edge the code names: with no LC at all (#644), both routers fall back to phase parity and could bind the same phase, recording one slot twice.

Teardown by destination

A Terminator with LC carries the header’s FLC under the terminator RS seed, so terminatorDest recovers which call is ending and releaseCall(dest) publishes one call.release keyed by (System, GroupID) — a TS1 terminator cannot tear down the TS2 call. When its LC does not decode, Part 4’s rule applies with a two-call twist:

DBG dmr/tier2: terminator slot type without a BPTC-valid payload ignored cc=12
DBG dmr/tier2: ambiguous terminator (LC undecodable, two calls active); leaving to hangtime cc=12
DBG dmr/tier2: terminator dst=11 slot=1

A lone call still ends promptly on a BPTC-valid but RS-failing terminator; with two calls active a guess could cross-tear the wrong slot, so the channel releases nothing and each voice chain’s hangtime (voice_hangtime_ms, default 3500) closes its call. The composer runs its own dmrvoice.TerminatorDetector, ending its call only when the FLC destination equals groupID (composer: dmr terminator — releasing call). Two independent detectors of one terminator, on two taps. Keep that in mind.

The re-key inside hangtime

The 10 Sep IPSC report: “some calls are completely missed by GT while the radios decode them.” The operator’s debug.log told the story. The composer’s detector released each call at PTT release. The tier2 slicer’s copy of the same terminator — on its weaker channelizer bin-edge tap — decoded 0.8–6.5 s later, or never (terminator slot type without a BPTC-valid payload ignored). The control path kept the call tracked, so the reply’s header for the same (tg, radio) hit the dedupe branch as a repeated header and was never granted. The fix is Part 5’s anchor arithmetic, read the other way:

// internal/radio/dmr/tier2/conventional.go (shape)
const headerRekeyDibits       = 1200 // 0.25 s past the last header copy
const superframeRekeyGapDibits = 9600 // 2 s from the last LC's superframe start

if existing.src == src {
    if c.ingestDibit-existing.anchorDibit <= headerRekeyDibits { /* dedupe */ }
    c.cnt.rekeys.Add(1)
    c.log.Debug("dmr/tier2: re-key — Voice LC Header for a still-tracked call; previous terminator undecoded here",
        "dst", dest, "src", src, "gap_dibits", c.ingestDibit-existing.anchorDibit)
    c.releaseCall(dest)                 // idempotent for an engine that already ended it
    c.publishGrant(dest, src, individual, enc, emer, prio, ts, slot.ColorCode, false)
}

A Voice LC Header is only ever sent at keyup, so one more than 0.25 s past the tracked call’s last header is a new transmission. Copies of one header span ~120–180 ms; a re-key’s header is at least one superframe (360 ms) past the previous over’s last superframe start — and the capture has one reply keyed so fast that a 0.5 s rule missed it. When the header is also lost, a CRC-valid embedded LC naming a tracked call more than superframeRekeyGapDibits after its last LC re-grants by late entry (dmr/tier2: re-key by late entry): 2 s from the last superframe start is ~1.6 s of silence, so a four-superframe fade is tolerated and a reply after a ≥3 s hang time is caught.

The gotcha that cost a round: Process assembles a chunk’s superframes before slicing its data bursts, so a new over’s first superframe refreshes the call ahead of its own header — against the last LC, that header looked stale. Hence two clocks per call, anchorDibit (header rule) and lastDibit (silence rule), and convCall.touch only moves forward.

two decoders of one terminator voice path over 1 · tg 11 src A TERM call.release at PTT release control path (weaker tap) over 1 tracked · anchor TERM? HDR over 2 · tg 11 src A gap > headerRekeyDibits (1200) old: deduped as a repeated header — reply never granted new: releaseCall + re-grant · rekeys++
The 10 Sep miss: the voice path released the call, the control path's copy of the terminator never decoded, and the reply's header was deduped against a call already ended — until the anchor distance made it a re-key.

What the captures say

TestDMRIPSCReplay (cmd/gophertrunk/dmr_ipsc_replay_test.go) reads wav/flac directly and prints a grant / release / terminator timeline in stream time (4800 dibits/s). Two scrubs model the field conditions on the reporter’s dmr_ipsc_beacon_voice.flac:

Run Grants Notes
GT_DMR_DROP_TERMINATORS=1, old code 1 of 5 every reply deduped
GT_DMR_DROP_TERMINATORS=1, new 5 rekeys=4
unscrubbed 5 one on-air re-key had no terminator at all
GT_DMR_DROP_HEADERS=1 4 late entries unchanged

Mid-hangtime the repeater also repeats the other slot’s last terminator (tg 25862 here) — harmless. The synthetic pins are conventional_twoslot_test.go (two destinations → two grants with distinct slots, a TS1 terminator releasing only its own) and conventional_rekey_test.go (TestConventionalRekeyWithinHangtimeRegrants fails against the old dedupe with 1 grant, 0 rekeys).

Two verification states, stated the way From Spec to Shipping Part 10 insists. The re-key rule is real-air A/B’d offline on the operator’s capture and corroborated by their log; the live run is pending. The two-slot audio path is synthetic-verified only: the one concurrent-traffic IQ contributed (dmr_ipsc_60sec_bw25k_cs16.raw) is dead — ~−75 dBFS RMS, no frame sync — so that A/B still needs a decodable capture (#764/#771).

How the two-slot problem shaped the Go code

  • Identity tokens are typed as such. convCall.slot is documented as an engine-identity token never consulted for audio, so nobody later “fixes” routing by reading it.
  • Two clocks, both stream-time. anchorDibit and lastDibit exist because one clock served two rules badly.
  • Releases are idempotent by design. releaseCall publishes for a call the voice path may already have ended, so two detectors can disagree in time.
  • Counters carry the field report. rekeys is the number that would have been non-zero in the 10 Sep log — the lesson of Issue Tracker Part 22’s drifting pipelines.

Where this goes next

Between calls a keyed IPSC repeater is not silent — it fills both slots with Idle bursts, and sometimes with a train of CSBKs nobody can yet read. Part 7 pins the idle beacon against MMDVMHost’s constant, turns it into a site-alive lock, and is honest about the cc=7 CSBK train still unpinned.

FAQ

How does GopherTrunk decode both DMR timeslots of one repeater? The conventional state machine keys calls by decoded destination and gives each a synthetic Timeslot 1 or 2 so the engine keeps them apart; two same-carrier voice taps run the interleaved AMBE decoder and a slotRouter that keeps only superframes whose embedded LC names its call.

Why is the DMR timeslot “synthetic” in conventional mode? Because a base-station burst carries no reliable physical slot label — both slots use the same BS sync words and the slot type holds only colour code and data type. The token separates two concurrent calls in the engine; audio is routed by embedded-LC talkgroup, not by slot number.

Why did GopherTrunk miss DMR replies on an IPSC repeater? The voice path released each call at PTT release while the control path’s copy of the terminator decoded late or never on a weaker tap, so the reply’s header was deduped against a call already ended. A header more than 0.25 s past the call’s anchor now re-grants (rekeys).

What does dmr_interleaved_voice do? It is a tri-state override for the cadence-detecting AMBE decoder. Unset, every DMR protocol gets it (trunking.DMRVoiceCadenceDetected); the value is stamped on each grant as DMRInterleavedVoice so the composer picks the right decoder. Forcing false reproduces the single-slot splice.

Is the two-slot DMR path verified on air? Partly. The re-key rule is A/B’d on the reporter’s real capture; the two-slot audio path is pinned by failing-first synthetic tests but still awaits a decodable concurrent-traffic capture — the only one so far was undecodable at ~−75 dBFS.

Series navigation

Part 6 of 14 · ← Part 5: Link Control, Embedded LC & Late Entry · Next → Part 7: The Idle Beacon & the CSBK Train