Part 6 of TETRA End to End, a 14-part deep dive into how GopherTrunk turns one real 25 kHz TETRA carrier into clear recorded voice. Part 5 ended holding CRC-valid 137-bit speech frames — the traffic channel’s whole output. Every other digital voice protocol GopherTrunk decodes speaks some MBE dialect, rendered by the vocoder family the Voice Coding series dissected. TETRA speaks something else entirely: ACELP, the algebraic code-excited linear prediction family that also underlies GSM-EFR and G.729. GopherTrunk’s implementation is pure Go, fixed-point, and bit-exact against the ETSI reference decoder — this part is how it works and what “clean-room” means when the acceptance test is sample-for-sample equality.
TL;DR:
internal/voice/acelpdecodes one 137-bit EN 300 395-2 speech frame into 240 samples of 8 kHz PCM (30 ms). The frame is 23 quantiser indices (bits2prm): three split-VQ LSP indices dequantised bydLsp334into the LPC filter, then four 60-sample subframes, each with a pitch lag (20..143, 1/3-sample resolution), a 4-pulse algebraic codebook index (dD4i60), and a 6-bit energy index (decEner, MA-predicted in the log2 domain). Excitation = adaptive + algebraic vectors scaled by the decoded gains, pushed through thesynFiltLPC synthesis filter. All of it runs on ported 16/32-bit saturating fixed-point ops (ops.go), because bit-exactness lives or dies on overflow behaviour. The registry adapter (VocoderName = "tetra-acelp") applies the reference’sPost_Processsaturating ×2 — omit it and the audio sits 6 dB under reference. Erased frames (BFI) repeat the previous LSPs and parameters with decayed gains.
Key takeaways
- ACELP is a source-filter model, not a spectral model. MBE transmits a description of the spectrum; ACELP transmits a recipe for the excitation — pitch echo plus four algebraic pulses — and filters it through a transmitted vocal-tract model. That’s why its bit classes (Part 5) map so cleanly to perceptual damage.
- Fixed-point is the spec, not an optimisation. The reference codec defines behaviour in saturating 16/32-bit arithmetic; matching it sample-for-sample means porting the arithmetic, overflow flags and all. Floating point would sound fine and match nothing.
- The decoder is honest about erasures. A missing or CRC-failed frame (BFI) doesn’t glitch: LSPs and parameters repeat, energies decay — the concealment the CRC gate of Part 5 relies on.
- One saturating ×2 is part of the codec. The reference’s
Post_Processdoubles every output sample with saturation; GopherTrunk applies it in the vocoder adapter so recordings match reference level exactly.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Frame unpack | 137 bits → BFI + 23 indices (bitWidths) |
internal/voice/acelp/params.go (bits2prm) |
| LSP dequantise | 3 split-VQ indices (8+9+9 bits) → 10 LSPs | params.go (dLsp334), lsp_tables.go |
| LPC interpolation | per-subframe filter from old/new LSPs | internal/voice/acelp/lpc.go (intLpc4, lspAz) |
| Adaptive codebook | pitch prediction at 1/3-sample resolution | internal/voice/acelp/codebook.go (predLt, inter32_1_3) |
| Algebraic codebook | 4 pulses from index/sign/shift + noise filter | codebook.go (dD4i60, algebraicPulses) |
| Gain decode | 6-bit energy VQ, log2-domain MA prediction | internal/voice/acelp/gain.go (enerPredictor.decEner) |
| Synthesis | excitation → speech through 1/A(z) | internal/voice/acelp/syn.go (synFilt) |
| Registry adapter | "tetra-acelp", 18-byte frames, Post_Process ×2 |
internal/voice/acelp/vocoder.go (NewVocoder) |
In this post
- A different animal from MBE — what ACELP transmits and why.
- Unpacking 137 bits — the parameter layout of a frame.
- The excitation — adaptive pitch, algebraic pulses, decoded gains.
- Synthesis in saturating fixed point — why the basic ops are the codec.
- Erasures, the adapter, and what clean-room means here — BFI concealment, the ×2, and provenance.
A different animal from MBE
The MBE model that serves P25, DMR and NXDN transmits a description of the output: band energies, voicing decisions, a fundamental. ACELP transmits a procedure for the input: here is a filter approximating the vocal tract (the LPC coefficients, sent as line spectral pairs), and here is how to build the signal you should push through it — take an echo of what you excited the filter with a pitch period ago, add a handful of sharp pulses at coded positions, scale both. “Analysis-by-synthesis” means the encoder ran this exact decoder in a loop to pick the codebook entries whose output best matched the microphone; the decoder just replays the winning recipe. One consequence paid off in Part 5: parameters differ wildly in sensitivity — a wrong LSP or gain index corrupts the filter or energy trajectory for a whole frame (class 2), while a mislocated excitation pulse is a click at worst (class 0).
Unpacking 137 bits
bits2prm splits a frame into 23 indices whose widths are the codec’s anatomy
in one array:
// internal/voice/acelp/params.go (shape)
var bitWidths = [numParams]int{
8, 9, 9, // split-VQ LSP indices (dico1..3)
8, 14, 1, 1, 6, // subframe 1: pitch, code, sign, shift, energy
5, 14, 1, 1, 6, // subframe 2
5, 14, 1, 1, 6, // subframe 3
5, 14, 1, 1, 6, // subframe 4
}
26 bits of LSPs describe the vocal tract for the whole 30 ms; the remaining
111 are four subframes of excitation recipe. The first subframe spends 8 bits
on pitch to encode an absolute lag (20..143 samples with 1/3 fractions);
subframes 2–4 spend 5, coding a delta around subframe 1’s lag — pitch moves
slowly, so relative coding is nearly free. dLsp334 dequantises the three LSP
indices against their codebooks and — with intLpc4 — interpolates between
last frame’s LSPs and this frame’s so the filter glides rather than steps at
subframe boundaries.
The excitation: pitch echo plus four pulses
Per subframe, the decoder builds 60 samples of excitation from two sources.
The adaptive codebook is simply the recent excitation history replayed at
the decoded lag: predLt reaches back T0 samples (interpolating with
inter32_1_3 when the lag has a 1/3 fraction) — that recursion is what makes
voiced speech periodic. The algebraic codebook adds the innovation — four
pulses whose positions come from the 14-bit index, with a global sign and
shift:
// internal/voice/acelp/decoder.go (shape) — one subframe
predLt(d.oldExc, excOff+iSubfr, int(T0), int(t0Frac), subfrLen) // adaptive vector
ap3 := pondAi(aSub, d.fGamma3) // A(z/0.75)
ap4 := pondAi(aSub, d.fGamma4) // A(z/0.85)
/* … F[] = impulse response of ap3/ap4 weighting filter, + 0.8·pitch echo … */
dD4i60(prm[base+sfCode], prm[base+sfSign], prm[base+sfShift], zeroF, 64, code)
gainPit, gainCode := d.ener.decEner(prm[base+sfEnergy], bfi, aSub, excSub, code, subfrLen)
for i := 0; i < subfrLen; i++ {
L := lMult0(d.oldExc[excOff+iSubfr+i], gainPit)
L = lMac0(L, code[i], gainCode)
d.oldExc[excOff+iSubfr+i] = int16(lShrR(L, 12)) // Q12 → Q0
}
synFilt(aSub, d.oldExc[excOff+iSubfr:], synth[iSubfr:iSubfr+subfrLen], subfrLen, d.memSyn[:], true)
Two refinements distinguish this from textbook CELP. The pulses aren’t placed
as raw impulses: dD4i60 convolves them with F[], a perceptual noise-shaping
filter built per subframe from the bandwidth-expanded LPC (γ = 0.75/0.85) plus
a fixed 0.8-gain pitch contribution — the innovation arrives pre-shaped to
hide under the signal. And the gains aren’t transmitted directly: decEner
decodes one 6-bit index against an MA prediction of the pitch and code
energies from the previous subframe, in the log2 domain — the codec bets that
energy is predictable and only sends the correction. (That inter-subframe
predictor is decoder state, which is why frame decoding is order-dependent and
the conformance test of Part 7 must run whole files, not cherry-picked
frames.)
Synthesis in saturating fixed point
The least glamorous file is the most load-bearing. ops.go ports the
reference’s basic operators — addOp, sature, lMult0, lShrR, the full
16/32-bit saturating family, overflow flag included — and every block above is
built exclusively from them. This is not retro affectation: the reference
codec defines the decoder as sequences of these operations, and legal
outputs are whatever they produce, saturation artifacts and all. A float
implementation would sound indistinguishable and never match a single sample;
matching sample-for-sample (Part 7’s test is literally mismatches != 0 →
fail) requires the arithmetic to be the spec. The same discipline showed up on
the C side of the fence with teeth: the reference tools themselves miscompute
on LP64 hosts unless Word32 is forced to 32 bits — every saturating op
silently returns garbage — a trap Part 7 documents because it cost real time.
Erasures, the adapter, and what “clean-room” means here
Erasures. When Part 5’s CRC gate drops a burst, the vocoder still owes the
recording 30 ms. On BFI the decoder repeats the previous frame’s LSPs and
quantiser parameters (oldParm, oldT0) while decEner decays the predicted
energies — a fading echo of the last good frame instead of a click or a hole.
A too-short input frame is treated as BFI automatically.
The adapter. vocoder.go bridges to the
vocoder registry:
VocoderName = "tetra-acelp", FrameSize() of 18 bytes (137 bits packed
MSB-first), registered in voice.DefaultRegistry at init so the recorder’s
tetra → tetra-acelp mapping resolves it by name. Its Decode applies the
one post-step the raw decoder omits — the reference’s Post_Process, a
saturating multiply-by-two on every sample. That’s not a tweak; skip it and
GopherTrunk’s output sits exactly 6 dB below the reference decoder’s, which
both fails conformance and makes TETRA recordings quiet next to every other
protocol’s.
Clean-room. The phrase earns definition. This is a from-scratch Go
implementation — no C compiled in, no cgo, no copied source — written to the
EN 300 395-2 spec with the ETSI reference codec’s behaviour as the
acceptance target; the quantiser tables are the numeric constants the standard
itself publishes. The package documentation is candid about provenance and
posture: the reference implementation is ETSI-copyrighted, the algorithm
family historically patent-encumbered (the core patents largely expired), and
so the decoder lives in its own package behind an explicit vocoder
registration, with the shipping posture documented in docs/vocoders.md
rather than assumed. Honesty here is cheap; discovering a licensing problem
after shipping is not.
Where this goes next
“Bit-exact against the reference” has been asserted three times now without
proof. Part 7
supplies it: the two independent conformance passes — same bitstream into the
ETSI reference decoder and GopherTrunk’s, demanding identical PCM; and a real
IQ capture through the whole chain, grant-correlated — plus the LP64 Word32
trap in the reference build, and the chain-validation lesson that closes the
loop on Part 3’s CRC story.
FAQ
Why does TETRA use ACELP when everything else GopherTrunk decodes uses MBE? Different lineages: the US land-mobile ecosystem standardised on DVSI’s MBE family, while ETSI drew on the European telecom tradition that produced GSM-EFR and G.729 — ACELP was its state of the art at TETRA’s design time, and at 4.567 kbit/s of speech data it fits TETRA’s slot budget with room for the Part 5 protection.
Is 137 bits per 30 ms really enough for speech? It’s ~4.57 kbit/s, and yes — because so little of it is waveform. 26 bits model the vocal tract, and the rest is the excitation recipe the analysis-by-synthesis encoder chose. The result is characteristic codec speech: intelligible, speaker-recognisable, and unmistakably synthetic.
What sample rate and width does the decoder emit? Signed 16-bit PCM at 8 kHz, 240 samples per frame. The recorder resamples or containers it the same way as every other vocoder’s output — the registry interface hides the codec entirely.
How does concealment interact with the CRC gate upstream? The gate turns “wrong bits” into “missing frame,” and the decoder turns “missing frame” into a decayed repeat of the last good one. That pairing is deliberate: a wrong class-2 parameter can sound far worse than 30 ms of faded echo, so the system prefers erasure over corruption.
Could the vocoder be validated without the ETSI reference? Only weakly — round-trip tests through GopherTrunk’s own encoder would be Part 3’s self-consistent trap wearing a codec costume. Sample-exact comparison against an independently built decoder is the strong form, and it’s the entire subject of the next part.
Series navigation
Part 6 of 14 · ← Part 5: TCH/S — From Traffic Burst to Speech Frame · Next → Part 7: Conformance — Bit-Identical Against the ETSI Reference