Part 8 of P25 End to End, a 14-part deep dive that follows North America’s dominant trunking protocol through GopherTrunk — from a raw C4FM carrier to recorded, named, multi-site voice. Part 7 split a Phase 2 carrier between two AMBE+2 slots; this part returns to the voice most P25 systems actually carry. A Phase 1 traffic channel streams LDUs — 1728-bit units carrying nine IMBE frames each, with the call’s own metadata woven between them — and this part follows one from frame sync to WAV, including the layout bug that decoded exactly two of nine voice frames while every test stayed green.
TL;DR: Phase 1 voice arrives as alternating LDU1/LDU2 units: 1728 bits = FS + NID + 9 IMBE frames × 144 channel bits + 240 bits of Link Control (LDU1) or Encryption Sync (LDU2) + 32 bits of low-speed data + 24 status symbols interleaved every 70 bits (
internal/radio/p25/phase1/ldu.go). Each 144-bit frame deinterleaves, descrambles, and FEC-decodes — Golay(23,12) ×4, Hamming(15,11) ×3, one unprotected vector — to 88 information bits (internal/voice/imbe), which the pure-Go IMBE vocoder renders as 160 PCM samples at 8 kHz per 20 ms frame. The composer chain (internal/voice/composer/p25p1_voice.go) gates audio by the LC talkgroup, splits files on terminators, and only trusts a call’s AFC measurement pastminAutotuneLDUs = 5.
Key takeaways
- An LDU is a superframe, not a frame. Nine 20 ms voice frames — 180 ms of audio — travel with the call’s own signalling threaded between them, so a late-tuning receiver learns who is talking from the voice channel itself.
- The field layout is sourced, not derived. The interleaving order of voice, LC and LSD inside the 1680-bit payload came from an independent decoder (szechyjs/dsd); the previous, plausible layout round-tripped green while only u_0 and u_8 decoded on air (issue #489 follow-up).
- FEC strength follows bit sensitivity. Golay(23,12) guards the four most-perceptible parameter vectors, Hamming(15,11) the next three, and the least-sensitive seven bits ride unprotected — a budget, not an oversight.
- Voice frames flow even when FEC loses. An uncorrectable vector still
yields a (degraded) frame plus an error count the decoder’s adaptive
smoothing consumes — and the counters (
uncorrectable_ldus,gated_ldus) are the operator’s garbled-audio instrument.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| LDU structure | 1728-bit layout constants + compile-time sum check | internal/radio/p25/phase1/ldu.go |
| Status symbols | strip 24 × 2 bits interleaved every 70 payload bits | ldu.go (StripStatusSymbols) |
| Voice extraction | 9 × 144-bit frames → 11-byte decoded frames | ldu.go (ExtractVoiceFramesDetailed) |
| LDU framing | dibit stream → complete 1728-bit LDUs | ldu_assembler.go (LDUAssembler) |
| IMBE channel FEC | deinterleave + descramble + Golay/Hamming, 144→88 | internal/voice/imbe/channel.go (DecodeChannelToFrame) |
| The voice chain | gating, telemetry, ES publish, autotune | internal/voice/composer/p25p1_voice.go (runP25Phase1VoiceChain) |
| Vocoder mapping | protocol "p25" → pure-Go imbe, 8 kHz PCM |
internal/voice/recorder.go (DefaultVocoderForProtocol) |
In this post
- The LDU superframe — nine voice frames and the metadata between them.
- The layout that round-tripped green and decoded wrong — issue #489’s lesson.
- From 144 bits to 88 — the IMBE channel-coding inverse, surveyed.
- The composer chain — gating, terminators, and the telemetry that names bad audio.
- What LDU2 carries — encryption sync, and the out-of-set guard.
The LDU superframe
After Part 2’s
frame sync and NID, a voice transmission is a run of LDUs — LDU1 and LDU2
alternating, each 1728 bits, each 180 ms. The budget is fixed by
TIA-102.BAAA and pinned in ldu.go with a compile-time check that the
fields still sum to 1728:
| Field | Bits | Carries |
|---|---|---|
| Frame sync | 48 | the FSW from Part 2 |
| NID | 64 | NAC + DUID (BCH-protected) |
| Voice | 9 × 144 | IMBE frames u_0..u_8, 20 ms each |
| LC or ES | 240 | Link Control (LDU1) / Encryption Sync (LDU2) |
| Low-speed data | 32 | 2 cyclic codewords |
| Status symbols | 24 × 2 | trunking-layer signalling, interleaved |
The status symbols come out first: two bits after every 70 payload bits,
24 times (StripStatusSymbols), leaving a 1680-bit payload. What remains
is not voice-then-metadata but a deliberate weave — u_0 and u_1
back-to-back, then a 40-bit LC/ES block after each of u_1 through u_6,
both 16-bit LSD blocks together between u_7 and u_8. The weave is the
design: link control arrives in six pieces spread across 180 ms, so a
receiver that tunes in mid-call (which a trunking scanner always does)
assembles who-is-talking-to-whom from the voice channel itself, and a
burst error takes a slice of everything rather than all of one thing.
The layout that round-tripped green and decoded wrong
The figure’s annotation deserves its paragraph, because it is this
series’ recurring villain caught in the act. The original lduVoiceOffsets
table placed an LC/ES block between u_0 and u_1 (and the LSD between u_6
and u_7) — plausible, tidy, and wrong: every offset from u_1 to u_7 was 40
bits off. GopherTrunk’s own InjectStatusSymbols/encode side used the
same table, so every round-trip test passed while on real air only u_0
and u_8 — the two frames whose offsets happened to coincide — produced
audio. Recordings existed, contained mostly garble, and nothing failed.
The fix (issue #489 follow-up) re-sourced the layout from an independent
implementation — szechyjs/dsd’s process_p25_ldu1 read order — and the
current ldu.go documents the cumulative bit table field by field, with
ldu_realair_test.go holding it against a real capture. Same lesson as
Part 3’s SCCB byte offset and Part 7’s scrambler ordering: a
self-consistent codec validates its assumptions against nothing. Pin
layouts with literal vectors from an implementation that has decoded real
air.
From 144 bits to 88
Each extracted 144-bit voice frame then runs the IMBE channel-coding
inverse (internal/voice/imbe), three layers deep — deinterleave
(§7.5), descramble (§7.4, a PRBS keyed off u_0, which itself rides
unscrambled), then per-vector FEC:
// internal/voice/imbe/channel.go (shape)
// IMBE 4400 channel coding maps 88 information bits to 144 channel bits:
// u_0..u_3 23 bits each Golay(23,12,7) — most sensitive parameters
// u_4..u_6 15 bits each Hamming(15,11,3)
// u_7 7 bits no FEC — least-sensitive bits
func DecodeChannel(channel []byte) ([]byte, int, error) {
/* … per-vector Golay/Hamming decode, summing corrected bits … */
// ErrUncorrectable when a vector exceeds its radius — the partially
// recovered info bits are STILL returned so callers can log +
// frame-repeat upstream rather than drop 20 ms of audio.
}
The 88 recovered bits are the vocoder’s model parameters — fundamental
frequency, voicing decisions, spectral amplitudes — and the pure-Go
decoder synthesizes each frame into 160 PCM samples at 8 kHz (20 ms).
The deep math has its own series:
what a vocoder is,
the MBE model,
the IMBE decode
and the FEC + deinterleave layer
own it. Two facts matter here. First, the error count travels with the
frame: ExtractVoiceFramesDetailed reports per-frame corrected bits, and
the recorder hands them to the decoder’s adaptive smoothing so a noisy
channel gets gentler synthesis instead of harsh artifacts. Second, the
honest one-liner from the project’s vocoder-conformance work: measured
against a reference decode of identical frames, GopherTrunk’s IMBE shows a
mild high-band energy deficit — the same direction as the measured AMBE+2
3600×2450 gap, much milder — known and bounded rather than a mystery.
The composer chain
runP25Phase1VoiceChain (internal/voice/composer/p25p1_voice.go) is
where a grant becomes a recording. IQ for the granted frequency arrives on
a channel; the chain runs a ±6.25 kHz channel-select front end (half
the P25 channel spacing — without it, a wideband tap’s ±24 kHz span lets
the FM capture effect lock onto a stronger adjacent channel during the
talker’s syllable gaps, recording foreign talkgroups and garble), then the
Part 1 receiver, then LDUAssembler, and finally a sink that dispatches
per LDU:
- Terminators end transmissions. A TDU/TDULC rolls the recording for the next over — after harvesting the terminator’s link control, since Motorola systems carry the talker alias there as often as in LDU1 (issue #376).
- The talkgroup gate decides writes. LDU1’s Link Control names the talkgroup; the boundary tracker compares it to the grant and drops non-matching audio — a shared voice frequency must never cross-contaminate a recording. When the LC’s outer RS is uncorrectable the talkgroup is untrusted, so the tracker inherits the last decision rather than gating 20 ms of real audio on garbage — the dominant cause of fragmented recordings in one field capture.
- Telemetry names the failure. The rolling
composer: p25p1 decode qualityline carriesldus,uncorrectable_ldus,gated_ldus,corrected_bit_errsand the LC/ES RS counters — so “audio is garbled” resolves to which layer is losing, and an operator can watch the rate fall as they fix gain (issue #356 follow-up).
The chain also feeds the per-dongle autotune average — each call’s AFC
residual estimates the tuner’s frequency error — but only from calls that
earn trust: minAutotuneLDUs = 5 (roughly a second of voice) plus a <10%
uncorrectable rate, because a short or noisy call yields a data-dependent
snapshot that would drag the correction toward the kilohertz. A threshold
worth knowing when a marginal system’s calls run four LDUs long — that is
also Part 12’s
opening symptom. Decoded frames land through the recorder, whose default
map sends protocol "p25" to the pure-Go imbe vocoder
(DefaultVocoderForProtocol, internal/voice/recorder.go), and the
recording layer
takes it from PCM to WAV on disk.
What LDU2 carries
LDU2’s 240-bit slot holds Encryption Sync instead of link control: Message Indicator, Algorithm ID, Key ID. The chain parses it every LDU2 but publishes a bus event only when something changed — ALGID/KID/MI are stable within a call, and re-announcing them every 180 ms is noise. One guard is worth flagging now: an ES whose outer RS “succeeds” but yields an Algorithm ID outside the TIA-102 set is a residual-error mis-decode, and GopherTrunk drops it rather than surface a phantom key (issue #924) — downstream, a fake algorithm is indistinguishable from a real one. Everything else about the encryption story — where the flags live, the four-layer path to the call log, and what policy does with them — is Part 9’s whole subject.
How the superframe design shaped the Go code
- Constants that add up, or don’t compile.
ldu.gocloses with a compile-time identity over the field widths, so a field edit that breaks the 1728-bit budget fails at build, not on air. - Extraction returns partial results. Uncorrectable vectors still yield frames plus error counts — the audio chain degrades instead of gapping, and the counts feed both smoothing and telemetry.
- Metadata outruns the gate. Talker aliases, source IDs and ES are surfaced even for gated audio, because identity is worth learning from a call you chose not to record.
- Trust thresholds are named constants.
minAutotuneLDUs,qualityLogEveryLDUs, the RS-uncorrectable counters — every judgment call in the chain is a greppable number with a comment, not folklore.
Where this goes next
LDU2 just introduced the encryption fields; Part 9 follows them the whole way: where “encrypted” actually lives on the air across HDU, LDU2 and Phase 2’s MAC, the postmortem of a flag and its metadata separated by four layers, and what GopherTrunk’s policy engine — follow, log metadata, or skip — does with a call it cannot listen to.
FAQ
Why does P25 voice come in 180 ms chunks?
The LDU is the framing unit: nine 20 ms IMBE frames plus the signalling
overhead amortised across them. GopherTrunk’s receiver emits complete
LDUs (LDUAssembler), so voice latency through the decode path is
bounded by LDU assembly plus synthesis — well under real-time for live
listening.
What is the difference between LDU1 and LDU2? Only the 240-bit metadata slot: LDU1 carries Link Control (talkgroup, source, service options), LDU2 carries Encryption Sync (MI/ALGID/KID). Voice layout is identical, and they alternate for the duration of a transmission so both kinds of metadata repeat about every 360 ms.
Why is my P25 recording missing pieces of a conversation?
Check gated_ldus in the decode-quality log line. A high count means the
in-band talkgroup didn’t match the grant — adjacent-channel bleed on a
wideband tap or a shared voice frequency — so the composer correctly
dropped that audio. frames − gatedLDUs approximates what actually
reached the WAV.
Does GopherTrunk use mbelib or DVSI hardware for IMBE?
Neither by default: internal/voice/imbe is a clean pure-Go IMBE 4400
decoder, the sole backend in default builds, mapped from protocol "p25"
by the recorder. The .imb/.amb sidecar option (recordings.mbe_files)
writes DSD-FME-compatible frame files if you want to A/B against another
decoder — the interop is frame-exact, per docs/vocoders.md.
Can GopherTrunk decode encrypted P25 voice? No — like SDRTrunk, it identifies encryption (ALGID, KID, MI) and applies policy, but does not decrypt. What it does with that identification — skip, record-silent, or follow with metadata — is Part 9.
Series navigation
Part 8 of 14 · ← Part 7: Phase 2 TDMA — Two Voices per Carrier · Next → Part 9: Encryption Signalling — Flags, Metadata & Policy