Part 8 of Voice Coding. Part 7 unpacked AMBE+2’s 49 information bits into MBE parameters. This post is about what happens when those bits arrive damaged, and about the one corner of the AMBE+2 tone map the public spec leaves blank — the “knox” range — where GopherTrunk ships a mechanism but deliberately no frequencies.
TL;DR: The forward-error-correction that protects AMBE+2 on the air — Golay(23,12) over DMR’s 72-bit frame, trellis + Reed-Solomon on P25 Phase 2 — lives in the protocol decoders, not in
internal/voice/ambe2. The vocoder receives 49 post-FEC bits. What it does with residual damage is concealment: replay the last good frame with progressive attenuation, and let the FEC’s corrected-bit count drive adaptive spectral smoothing. The knox path is a runtime extension point for vendor-specific dual-tone frequencies the public AMBE+2 spec doesn’t document — present as an API, absent as data.
Key takeaways
- Error correction is upstream. DMR’s
ambefec.godeinterleaves 72 on-air bits into C0–C3, runs Golay(23,12), descrambles C1, and emits the 49-bit payload. The vocoder never sees the parity bits. - The vocoder does concealment, not correction. Two mechanisms: bad-frame
replay (
MaxBadFrames = 6,BadFrameAttenuation = 0.7) and adaptive smoothing driven bySetFrameErrors. - Bad-frame replay is largely defensive — every valid voice
b0resolves to a legalL, soUnpackParamsonly errors on a wrong-length input. The structure is retained for parity with IMBE and as a landing spot for future FEC signalling. - Knox tones are honest about their limits.
b1 ∈ [144, 163]is vendor-specific; GopherTrunk routes it to silence unless an operator registers(freqA, freqB)viaSetKnoxTone/RegisterPreset.
Cheat sheet
| Concept | Where it lives | Role |
|---|---|---|
| On-air FEC | internal/radio/dmr/voice/ambefec.go (P25 P2: phase2) |
Golay(23,12) + descramble → 49-bit payload |
SetFrameErrors(n) |
decoder.go |
feed the FEC corrected-bit count to smoothing |
MaxBadFrames = 6 |
mbe/agc.go |
consecutive replays before muting |
BadFrameAttenuation = 0.7 |
mbe/agc.go |
per-replay amplitude multiplier |
SetKnoxTone(b1, a, b) |
knox.go |
register a vendor dual-tone pair |
RegisterPreset(p) |
knox.go |
apply a named bundle of knox pairs |
In this post
- Where the FEC actually is — the protocol/vocoder boundary.
- Concealment #1: bad-frame replay — and why it’s mostly defensive.
- Concealment #2: adaptive smoothing — the corrected-bit count at work.
- The knox path — a mechanism without a spec, done honestly.
Where the FEC actually is
“AMBE+2 FEC” is an ambiguous phrase, so let’s pin it down. An AMBE+2 voice frame on a DMR carrier is 72 bits, of which only 49 are the vocoder payload. The other 23 are error-control coding — a Golay(23,12) code over the perceptually-critical bits, plus a C0-seeded descramble. That wrapping is undone in the DMR radio layer, not the voice layer:
// internal/radio/dmr/voice/ambefec.go (shape)
// Each 72-bit on-air AMBE+2 frame wraps 49 bits of vocoder payload in FEC.
// Deinterleave into C0..C3, run Golay(23,12) over C0 and C1, descramble C1
// with the C0-seeded PRNG, assemble the 49-bit ambe_d payload.
const (
ambeOnAirBits = 72
ambeInfoBits = 49
)
P25 Phase 2 does the equivalent with its own machinery (trellis decode,
Reed-Solomon, deinterleave) in the phase2 package. By the time bits reach
internal/voice/ambe2, the heavy lifting is done — the decoder is handed the
same 49 clean-ish information bits regardless of which protocol carried them.
That is exactly the separation that let one vocoder serve three protocols in
Part 7.
Concealment #1: bad-frame replay
If a frame does fail to unpack, the decoder doesn’t emit a click of silence. It replays the last good frame’s parameters with a per-frame amplitude taper, so a brief glitch fades rather than punches:
// internal/voice/ambe2/decoder.go (shape)
case err != nil && d.lastGoodParams.L > 0 && d.badFrameCount < mbe.MaxBadFrames:
d.badFrameCount++
atten := math.Pow(mbe.BadFrameAttenuation, float64(d.badFrameCount))
repeatedM := d.lastGoodM
for l := 1; l <= d.lastGoodParams.L; l++ {
repeatedM[l] *= atten
}
d.synthFrame(d.lastGoodParams, &d.lastGoodLog2M, &repeatedM, pcm)
d.applyOutput(pcm, out, true) // freeze AGC so the taper is audible
BadFrameAttenuation = 0.7 means one bad frame plays at 70% of the previous
good frame; six in a row taper to 0.7⁶ ≈ 0.12. After MaxBadFrames = 6
consecutive replays (~120 ms), the cache clears and the decoder emits silence —
long enough to hide a real FEC slip, short enough that an extended dropout
fades naturally instead of looping the same envelope. The AGC is frozen
(freezeEnvelope = true) during a replay so the deliberate attenuation isn’t
immediately clawed back by gain — the listener actually hears the signal
degrade.
Here’s the honest caveat, straight from the decoder’s own doc comment: this
path is largely defensive. AmbePlusLtable guarantees every voice b0
resolves to a valid L ∈ [9, 56], so UnpackParams only returns an error on a
wrong-length input — which Decode catches even earlier. The replay machinery
is retained to mirror the IMBE decoder’s shape and to give a future
protocol-layer FEC signal (a “this frame is erased” flag) a place to land.
Concealment #2: adaptive smoothing
The more active mechanism is the one wired to the FEC’s own telemetry. When the
protocol layer FEC-decodes a frame, it knows how many bits it had to correct —
a direct proxy for channel quality. It hands that count to the vocoder before
the matching Decode:
// internal/voice/ambe2/decoder.go (shape)
func (d *Decoder) SetFrameErrors(correctedBits int) { d.frameErrs = correctedBits }
func (d *Decoder) Decode(frame []byte) ([]int16, error) {
correctedBits := d.frameErrs
d.frameErrs = 0
muteByER := d.smoother.UpdateErrorRate(correctedBits)
// ... unpack + synthesize ...
d.smoother.Smooth(&folded, &M, correctedBits) // cap spikes, reclaim voiced
if muteByER {
for l := 1; l <= folded.L; l++ { M[l] = 0 } // silence a hopeless frame
}
}
On a clean channel (correctedBits == 0) the smoother is inert — the faithful
path is untouched. On a degrading channel it caps error-induced amplitude
spikes and reclaims obviously-voiced harmonics that a bit-flip flipped to
unvoiced, taming the “warble” you’d otherwise hear. And when the error rate
crosses a mute threshold, muteByER zeroes the amplitudes, which the voiced
generator’s amplitude tilt fades out cleanly. This is the voice.ErrorAware
interface in action; the recorder only calls SetFrameErrors when the upstream
chain supplies a count (see
Part 9).
The knox path
AMBE+2 tone frames (b0 ∈ {0x7E, 0x7F}) carry a b1 index that names the
tone. Most of the map is well-defined and shared across every open decoder:
b1 ∈ [5, 122] is a single tone at b1·31.25 Hz; b1 ∈ [128, 143] is a DTMF
key from the ITU-T Q.23 4×4 matrix (697/770/852/941 Hz rows × 1209/1336/1477/1633
Hz columns). Those GopherTrunk synthesizes directly. Then there’s the gap:
b1 ∈ [144, 163] — the knox / call-alert range — whose frequencies are
vendor-specific (Motorola Trbo, Hytera, and generic implementations differ) and
which the public AMBE+2 spec simply does not document.
GopherTrunk’s answer is a runtime extension layer: the mechanism ships, the numbers don’t.
// internal/voice/ambe2/knox.go (shape)
const (KnoxIndexLow = 144; KnoxIndexHigh = 163)
// Register a vendor-specific dual-tone pair; (0,0) clears it back to silence.
func SetKnoxTone(b1 int, freqA, freqB float64) error
func KnoxTone(b1 int) (float64, float64, bool) // false if unset → silence
// A named bundle of pairs, applied via SetKnoxTone, for operators with a
// curated per-vendor table sourced from DSDcc / DSD-FME / a service manual.
func RegisterPreset(p KnoxPreset) error
The table is guarded by an RWMutex: SetKnoxTone takes the write lock,
the decoder’s tone-frame branch reads once per matching frame (~50 ns, lost in
the noise of the ~5 µs synthesis). When a pair is registered, a knox frame
synthesizes through the exact same summed-sinewave synthDualTone path DTMF
uses, with phase carried across frames so a held tone is click-free. When it
isn’t, the frame falls through to silence. No guessed frequencies ship in the
tree — a claim about a tone we can’t verify is worse than an honest silence.
The problem we hit here
The tempting shortcut is to hardcode some vendor’s knox frequencies as a
default. We didn’t, and the reason is the same discipline the rest of the codec
follows: the AMBE+2 tone map for [144, 163] isn’t in the public spec, and the
three vendors we know of disagree. Shipping one vendor’s numbers as a default
would mean confidently synthesizing the wrong tone on two-thirds of systems —
an inaccurate technical claim baked into audio. Better to expose the seam and
let an operator with a real reference fill it in.
Where this goes next
Part 9
climbs up a layer to the composer — the component that owns the per-protocol
chains feeding these vocoders, decides which vocoder a grant needs, and wires
SetFrameErrors from the FEC layer into the decoder.
FAQ
Does the AMBE+2 vocoder correct bit errors? No. Forward-error-correction (Golay(23,12) + descramble on DMR, trellis + RS on P25 Phase 2) runs in the protocol decoders and yields a 49-bit payload. The vocoder does concealment on what’s left: bad-frame replay and adaptive smoothing.
What happens on a run of unrecoverable frames?
The decoder replays the last good frame with 0.7ⁿ attenuation for up to
MaxBadFrames = 6 frames (~120 ms), then clears its cache and emits silence.
The AGC is frozen during replay so the fade is audible rather than pumped back
up.
What is a knox tone?
A vendor-specific dual-tone (call-alert) signalled by AMBE+2 tone indices
b1 ∈ [144, 163]. The public spec doesn’t document the frequencies, so
GopherTrunk synthesizes them only when an operator registers a (freqA, freqB)
pair via SetKnoxTone or RegisterPreset; otherwise the frame is silence.
Why not ship default knox frequencies? Because different vendors use different frequencies for the same index, and the values aren’t publicly specified. Shipping one vendor’s numbers as a default would synthesize the wrong tone on other systems — an inaccurate claim in audio. The mechanism ships; the numbers are the operator’s to supply.
Series navigation
Part 8 of 12 · ← Part 7: AMBE+2 — One Decoder, Two Rates · Next → Part 9: The Composer