Part 7 of Voice Coding, a 12-part tour of how GopherTrunk turns compressed digital-radio voice frames into PCM. Parts 2–6 built the MBE model, IMBE decode, and acquisition. This post crosses the aisle to the other half of the MBE family — AMBE+2 — and shows how one Go decoder serves three protocols by swapping a table, not a codepath.
TL;DR:
internal/voice/ambe2is the pure-Go AMBE+2 2400 bps decoder P25 Phase 2, DMR Tier II/III, and NXDN all use. It removes the CGO dependency on libmbe. Its job is the front half of the vocoder — unpack 49 information bits into nine quantization indices, run the codebook + inverse-DCT reconstruction, and project the result into the sharedmbe.Paramsshape — then hand off to the exact same synthesis primitives IMBE uses. Two rate configurations (3600×2400 and 3600×2450) differ only in bit positions and tables; the synthesis core never knows which one ran.
Key takeaways
- AMBE+2 is the same Multi-Band Excitation algorithm family as IMBE: 8 kHz / 20 ms / 160-sample PCM built from voiced harmonics plus an FFT-shaped unvoiced excitation.
- One
Decodertype serves two rates: the default"ambe2"(3600×2400, P25 P2 / NXDN) and"ambe2-dmr"(3600×2450, DMR). The only difference is whichunpackfunction and codebook tables are wired in. - The clever bit is
foldGammaIntoTl: it rewrites the spectral residuals so the IMBE-shapedmbe.PredictLog2Mlproduces spec-correct AMBE+2 output without an AMBE+2-aware synthesis variant. - Every table value is machine-translated from szechyjs/mbelib’s ISC-licensed
constants; the algorithm’s separate patent status is unchanged by a pure-Go
re-implementation (see
docs/vocoders.md).
Cheat sheet
| Index | Bits | Role in the frame |
|---|---|---|
b0 |
7 | fundamental-frequency parameter → ω₀ + L; also the tone-frame flag (b0 & 0x7E == 0x7E) |
b1 |
4 | V/UV voicing-pattern row → Vl[1..L] |
b2 |
6 | gain delta → DeltaGamma; absolute γ resolved cross-frame |
b3 |
9 | PRBA index for Gm[2..4] |
b4 |
7 | PRBA index for Gm[5..8] |
b5..b7 |
4 each | HOC indices → higher Cik coefficients (bands 1–3) |
b8 |
3 | HOC index for band 4 (LSB forced 0) |
In this post
- What AMBE+2 is and where it sits relative to IMBE.
- The 49-bit unpack — nine indices, a codebook lookup, and two DCTs.
- One decoder, two rates — how the 2400 and 2450 variants share a body.
- Fold-into-Tl — the design trick that reuses the IMBE synthesis core.
Where AMBE+2 fits
The Protocol Decoders series hands us a stream of information bits — the bare vocoder payload after the upstream protocol layer (P25 Phase 2, DMR, or NXDN) has stripped its own error-control coding. For AMBE+2 that payload is exactly 49 bits per 20 ms frame. GopherTrunk packs those into 7 bytes (49 bits + 7 padding), which is the same contract the old libmbe C wrapper accepted, so callers didn’t have to repack when the pure-Go decoder replaced it:
// internal/voice/ambe2/decoder.go (shape)
const (
InfoBits = 49 // information bits per frame
FrameBytes = 7 // 49 bits + 7 padding
VocoderName = "ambe2" // 3600x2400 (P25 P2 / NXDN)
)
func (d *Decoder) FrameSize() int { return FrameBytes }
AMBE+2 and IMBE are cousins: both are Multi-Band Excitation vocoders, both emit 160 int16 samples per frame by summing voiced cosine harmonics and an overlap-added unvoiced excitation. What differs is the bit format — how the handful of model parameters (pitch, voicing, spectral envelope, gain) are quantized into the frame. So GopherTrunk splits the work: the AMBE+2 package owns the front half (bits → parameters); the shared MBE package owns the back half (parameters → PCM).
The 49-bit unpack
UnpackParams reads the 49 bits into nine indices, b0 through b8, each
drawn from a scattered bit layout (the AMBE+2 bit-interleave is not
contiguous — bit 48 is the low bit of b0, for instance). From there it is
table lookups and DCTs:
// internal/voice/ambe2/params.go (shape)
b0 := int(info[0])<<6 | ... | int(info[48]) // scattered layout
f0 := math.Pow(2, -4.311767578125-2.1336e-2*(float64(b0)+0.5))
w0 := f0 * 2 * math.Pi
unvc := 0.2046 / math.Sqrt(w0) // unvoiced amplitude scale
L := int(AmbePlusLtable[b0]) // harmonic count, 9..56
b0sets the fundamental frequencyω₀and, viaAmbePlusLtable, the harmonic countL. It also doubles as the tone-frame flag.b1selects a row ofAmbePlusVuv, the voicing-pattern table, that paints each harmonic voiced or unvoiced (Vl[l]).b2indexesAmbePlusDgfor the per-frame gain delta.b3,b4are PRBA (Predictive Residual Block Average) indices that seed an inverse 8-point DCT-II, producing the first two DCT coefficients of each of four spectral bands.b5–b8are HOC (Higher-Order Coefficient) indices that fill in the remaining per-band coefficients.
A per-band inverse DCT then expands those coefficients into the L spectral
residuals Tl[1..L] — the log-amplitude envelope the synthesizer reconstructs
harmonics from. The whole flow mirrors szechyjs/mbelib’s
mbe_decodeAmbe2400Parms 1:1, deliberately, so any codebook drift between the
two implementations stays detectable in the test suite.
One decoder, two rates
Here is the design payoff. AMBE+2 ships in two flavours GopherTrunk cares about: 3600×2400 (P25 Phase 2, NXDN) and 3600×2450 (DMR). The “3600” is the on-air channel rate including the protocol’s FEC; the “2400”/”2450” is the vocoder payload rate. Both still arrive here as 49 information bits — the difference is purely which bits mean what and which codebook tables decode them.
GopherTrunk models that as a single Decoder struct whose only per-variant
state is a function pointer and a name:
// internal/voice/ambe2/decoder.go (shape)
type Decoder struct {
// ... shared synthesis state (SynthState, AGC, DCBlock, ...) ...
unpack func([]byte) (Params, error) // UnpackParams or unpackParams2450
name string
}
func New() *Decoder { d := ...; d.unpack = UnpackParams; d.name = "ambe2"; return d }
func NewDMR() *Decoder { d := New(); d.unpack = unpackParams2450; d.name = "ambe2-dmr"; return d }
unpackParams2450 is a near-copy of UnpackParams — same PRBA→DCT→HOC→Tl
reconstruction, different b0..b8 bit positions and different tables
(dmrLtable, dmrVuv, dmrDg, dmrPRBA24/58, dmrHOCb5..b8). Everything
after the unpack — the gamma fold, mbe.PredictLog2Ml, enhancement, voiced +
unvoiced synthesis, the AGC — is byte-for-byte identical between the two.
How that principle shaped the Go code
The temptation with two rates is two decoders, or a rate flag threaded through
every synthesis function. GopherTrunk avoids both. But there’s a subtler
problem: the shared mbe.PredictLog2Ml was written for IMBE’s parameter
form, which folds gain into the amplitudes differently than the AMBE+2 spec.
The AMBE+2 spec form is:
log2Ml[l] = γ·(prev_interp[l] − mean_prev_interp)
+ (Tl[l] − mean(Tl))
+ (γ_abs − 0.5·log2(L))
Rather than write an AMBE+2-aware predictor, the decoder pre-folds the
gamma and mean-removal into Tl up front, so the IMBE-shaped predictor
produces the right answer unchanged:
// internal/voice/ambe2/decoder.go (shape)
func foldGammaIntoTl(p Params, gamma float64) mbe.Params {
meanTl := mean(p.Tl[1:p.L+1])
bigGamma := gamma - 0.5*math.Log2(float64(p.L))
folded := p.Params
for l := 1; l <= p.L; l++ {
folded.Tl[l] = p.Tl[l] - meanTl + bigGamma
}
return folded
}
The absolute gain itself is the one genuinely cross-frame quantity AMBE+2 has
that IMBE lacks: γ_curr = DeltaGamma + 0.5·γ_prev. The decoder caches
prevGamma and resolves it before folding. This is the whole reason the
package is “the front half plus a fold” — that ~10-line function is what lets
two vocoder families share one synthesizer.
The unvoiced scale, and tones
Two AMBE+2-specific touches ride along. First, unvoiced harmonics are
attenuated by Unvc = 0.2046/√ω₀ before enhancement — GopherTrunk applies it
in the same place mbelib does, between the log2→linear amplitude conversion and
the §6.2 enhancement. Second, b0 ∈ {0x7E, 0x7F} marks a tone frame rather
than voice: single tones (b1·31.25 Hz), DTMF dual-tones from the ITU-T Q.23
keypad matrix, and vendor-specific “knox” pairs. Those get their own oscillator
path — the subject of
Part 8.
Where this goes next
Part 8 takes on the error-control side of AMBE+2: how a bad frame is replayed with progressive attenuation, how the FEC corrected-bit count drives adaptive smoothing, and what the vendor-specific “knox” tone path actually is (and honestly, what it isn’t). After that, Part 9 zooms out to the composer that wires demodulated frames into whichever vocoder a protocol needs.
FAQ
Is AMBE+2 the same as AMBE or IMBE? They are the same family — all Multi-Band Excitation vocoders that model speech as voiced harmonics plus unvoiced noise. IMBE is P25 Phase 1’s 4400 bps codec; AMBE+2 is the newer 2400 bps codec used by P25 Phase 2, DMR, and NXDN. GopherTrunk decodes both in pure Go over a shared synthesis core.
What is the difference between 3600×2400 and 3600×2450?
Both carry 49 information bits per 20 ms frame, so they are the same rate to
the vocoder; the “3600” is the on-air channel rate with FEC. The variants
differ only in bit layout and codebook tables — P25 Phase 2 and NXDN use
3600×2400 ("ambe2"), DMR uses 3600×2450 ("ambe2-dmr").
Does the pure-Go decoder avoid AMBE patents?
No — re-implementing the algorithm in Go doesn’t change its patent status. The
ISC license covers the table values (facts about the published algorithm),
not the algorithm itself. Operators in licence-restrictive jurisdictions should
evaluate before deploying; see docs/vocoders.md.
Why fold gamma into Tl instead of writing an AMBE+2 predictor?
Because it lets AMBE+2 and IMBE share one synthesis core. foldGammaIntoTl
rewrites the spectral residuals so the IMBE-shaped mbe.PredictLog2Ml yields
spec-correct AMBE+2 output — one small function instead of a parallel
synthesis pipeline.
Series navigation
Part 7 of 12 · ← Part 6: Acquisition & Squelch · Next → Part 8: AMBE+2 FEC & the Knox Path