Vocoders
Digital trunked-radio voice traffic is carried by one of three vocoders — two DVSI-derived, plus TETRA’s own ACELP:
- IMBE — used by P25 Phase 1 LDU1/LDU2 voice frames. Core US patents
(filed early-to-mid-1990s, 20-year term) have expired. The
algorithm is implementable in pure Go without licence concerns;
GopherTrunk ships a pure-Go decoder at
internal/voice/imbe. - AMBE+2 — used by P25 Phase 2, DMR (Tier II / III), and NXDN. AMBE+2
is patent-encumbered. DVSI sells hardware vocoders (USB-3000 /
AMBE-3003) and licences software ports. Open-source software
implementations (e.g.
mbelib) implement the algorithm; the code is permissively licensed (mbelib is ISC) but the patents are the user’s risk to evaluate. - TETRA ACELP — the full-rate speech codec used by TETRA voice
(ETSI EN 300 395-2). This is not a DVSI/MBE-family vocoder; it is
a conventional ACELP codec (LSP-quantised LPC + adaptive/algebraic
codebooks), specified openly by ETSI. GopherTrunk ships a pure-Go
decoder at
internal/voice/acelp. See the TETRA ACELP section below for the clean-room / patent posture.
Re-implementing AMBE+2 in pure Go does not change the patent posture — the algorithm itself is what the patents cover, regardless of implementation language. Operators in licence-restrictive jurisdictions should evaluate with counsel before deploying. GopherTrunk’s default build ships the pure-Go AMBE+2 decoder default-on; the legal responsibility for operating it falls on the deployer, not the project.
How GopherTrunk handles this
The internal/voice package defines a Vocoder interface and a
process-global Registry. Each backend registers a factory at init()
time. The set of factories present in a binary is determined by the
import set:
type Vocoder interface {
Name() string
FrameSize() int
Decode(frame []byte) ([]int16, error)
Reset()
Close() error
}
| Backend | Build tag | Default? | Status |
|---|---|---|---|
null (silence) |
none | yes | Always available |
imbe (pure-Go, P25 P1) |
none | yes | Producing intelligible audio; level calibration pending reference data (voice-calibration.md) |
ambe2 (pure-Go) |
none | yes | Producing audio; level calibration pending reference data; DTMF tones synthesise, knox tones via ambe2.SetKnoxTone or stay silent |
tetra-acelp (pure-Go) |
none | yes | Producing audio; TETRA full-rate voice (ETSI EN 300 395-2), verified bit-exact against the reference codec; up to 4 concurrent same-carrier timeslots |
dvsi (USB-3000 chip) |
-tags dvsi |
no | Wire-protocol + Vocoder scaffolding shipping; USB transport stub (returns ErrNoDevice) — hardware integration follows in a separate PR |
Live-pipeline auto-decode
When CallStart fires, the recorder maps Grant.Protocol to a
vocoder name and instantiates a fresh vocoder per call. Each
WriteRawFrame call decodes its frame and appends the resulting
PCM to the call’s WAV, alongside the optional .raw sidecar.
Default mapping (see voice.DefaultVocoderForProtocol):
| Grant.Protocol | Vocoder | Notes |
|---|---|---|
p25 |
imbe |
P25 Phase 1 LDU1 / LDU2 |
p25-phase2 |
ambe2 |
P25 Phase 2 |
dmr-tier2 |
ambe2 |
DMR Tier II conventional |
dmr-tier3 |
ambe2 |
DMR Tier III trunked |
nxdn |
ambe2 |
|
dpmr |
ambe2 |
dPMR Mode 3 |
tetra |
tetra-acelp |
full-rate ACELP; up to 4 concurrent same-carrier timeslots |
Analog protocols (motorola, edacs, ltr, mpt1327, etc.)
have no entry — for those, the composer’s FM chain feeds
WritePCM directly. EDACS ProVoice (Grant.ProVoice == true)
has no in-binary decoder either; it always gets a .raw sidecar
regardless of the global WriteRaw flag, so researchers can
decode out-of-band.
Operators override the mapping via
RecorderOptions.VocoderForProtocol:
voice.NewRecorder(voice.RecorderOptions{
// …other fields…
// Replace the IMBE mapping with the silence vocoder for
// testing, leave AMBE+2 alone:
VocoderForProtocol: map[string]string{
"p25": "null",
"p25-phase2": "ambe2",
// …other defaults…
},
})
Pass an explicit empty (non-nil) map to disable auto-decode
entirely — the .raw sidecar then becomes the only audio
output for digital calls.
Raw sidecar (escape hatch)
The recorder emits a raw-frame sidecar (.raw next to the WAV)
when WriteRaw is enabled or for ProVoice grants, so users can
run their own decoder on the captured frames without trusting
the in-binary vocoders. This is the escape hatch for operators
who want bit-exact mbelib / DSD-FME / OP25 output or who prefer
to defer the decoding choice to post-processing.
Decoding a captured .raw sidecar
The daemon ships a decode subcommand that runs the registered
in-binary vocoders against a .raw frame stream out-of-band:
gophertrunk decode -in call.raw -out call.wav -vocoder imbe
gophertrunk decode -in dmr.raw -out dmr.wav -vocoder ambe2
gophertrunk decode -list-vocoders # enumerate registered names
Stdin / stdout work for the input via -in -, so capture pipelines
can stream into the decoder without a temporary file:
some-source | gophertrunk decode -vocoder imbe -out out.wav
The library function backing this — voice.DecodeStream(in,
vocoderName, out) — is exported from internal/voice so other
consumers (web UIs, batch processors, post-mortem analysis tools)
can reuse the same decode path without spawning a binary. See
internal/voice/streamdecode.go.
Implementation notes
internal/voice/mbe/is the shared MBE-family synthesis core: cross-frame log-amplitude prediction, voiced harmonic generator, unvoiced FFT excitation + §6.4 overlap-add window, §6.2 spectral enhancement, and a per-frame fast-attack / slow-release AGC. Consumed by bothimbeandambe2.internal/voice/imbe/holds the IMBE-specific front half: 88-bit unpack, Golay/Hamming FEC inverse, scrambler, PRBA/HOC + inverse DCTs producing the spectral residuals the shared core consumes.internal/voice/ambe2/holds the AMBE+2-specific front half: 49-bit unpack, codebook lookups (auto-generated from szechyjs/mbelib’sambe3600x2400_const.hunder ISC, regenerable viascripts/gen-ambe2-tables.sh), inverse DCTs producing the spectral residuals, and the cross-framegammabookkeeping AMBE+2 requires.
Both decoders share the same constructor surface
(New() / NewWithSeed(seed) / NewWithConfig(seed, mbe.AGCConfig))
so operators can pin reproducibility for tests or tune the AGC for
their downstream chain.
Why a plugin model
This is exactly what SDR# / OP25 / DSD do. The key benefits:
- The default binary has no external library dependencies for voice (no CGO, no system shared library, no install scripts).
- Users with DVSI hardware can opt in by building with
-tags dvsi. The Vocoder + AMBE-3003 wire protocol + voice.Vocoder interface conformance ship ininternal/voice/dvsi/; the USB / FTDI transport that talks to the physical chip is a stub today (returnsErrNoDevice) so the recorder fallback chain activates cleanly. Hardware integration with a real DVSI USB-3000 lands in a follow-up. CI exercises the wire protocol + Vocoder plumbing via the scripted mock Transport and the software-loopback Transport (Options{LoopbackOnly: true}); both live behind the-tags dvsibuild tag. - Captures contain raw frames so a researcher can defer the decoding choice to post-processing.
DVSI backend layout (-tags dvsi)
packet.go— AMBE-3003 wire format (sync byte + length + type + payload). Always compiled — no patent surface in describing a serial wire protocol.doc.go— exportsVocoderName = "dvsi"so config validation paths can reference the key without-tags dvsilinked in.dvsi_enabled.go(//go:build dvsi) —Vocoder,Transportinterface,loopbackTransport,openUSBTransportstub, and theinit()registration intovoice.DefaultRegistry.dvsi_disabled.go(//go:build !dvsi) — empty; default builds link nothing from the DVSI codepath.dvsi_test.go(//go:build dvsi) — Vocoder interface conformance, loopback round-trip, scripted-mock wire-format verification, frame-size validation, unexpected-reply rejection.
make test-dvsi runs the tagged unit tests; the dvsi CI job runs
the same target on Ubuntu.
Voice calibration plumbing
The calibration harness ships end-to-end:
internal/voice/calibrate— RMS- ratio + best-alignment cross-correlation comparison against a reference WAV from DSD-FME / OP25.internal/voice/imbe/testdata/andinternal/voice/ambe2/testdata/— fixture drop zones with READMEs documenting the file layout the calibrate tests expect.docs/voice-calibration.md— operator- facing capture-and-validate recipe.cmd/voice-calibrate— CLI wrapper aroundcalibrate.Compareso a one-off check doesn’t require writing a test.
Knox / call-alert extension hook
AMBE+2 tone frames with b1 ∈ [144, 163] are vendor-specific knox / call-alert pairs. The public spec doesn’t document them; without registration, the decoder routes those frames through silence.
Single-entry registration
Operators with a per-vendor reference can register one
(freqA, freqB) pair at a time via
ambe2.SetKnoxTone (typically
from a per-vendor sub-package init()):
import "github.com/MattCheramie/GopherTrunk/internal/voice/ambe2"
func init() {
// Hypothetical Motorola Trbo call alert (frequencies illustrative).
_ = ambe2.SetKnoxTone(150, 1100, 1750)
}
Preset bundles
For curated tables (a service-manual extract, an open-source
receiver’s vendor table), use
ambe2.RegisterPreset instead
of repeated SetKnoxTone calls. The preset name shows up in
ambe2.ListPresets() for operator diagnostics:
import "github.com/MattCheramie/GopherTrunk/internal/voice/ambe2"
func init() {
// Hypothetical Vendor X call-alert table. Replace with values
// sourced from a verifiable reference (DSDcc, DSD-FME, vendor
// service manual) — the public AMBE+2 spec does not document
// these frequencies.
_ = ambe2.RegisterPreset(ambe2.KnoxPreset{
Name: "vendor-x-call-alerts",
Entries: map[int][2]float64{
150: {1100, 1750},
151: {1200, 1800},
},
})
}
Registered indices synthesise through the same summed-sinewave dual-tone path as DTMF — phase-continuous across consecutive tone frames, AGC-scaled, click-free.
Sourcing vendor frequencies
The in-tree code ships no vendor presets because the AMBE+2 public
spec does not document the [144, 163] frequency range and the
maintainers have not had operator-confirmed reference data to copy
from. Contributors with a verifiable source (cite the file + commit
or document section in the PR) can land a per-vendor preset under
internal/voice/ambe2/presets/<vendor>/. Open-source receivers
worth checking: szechyjs/mbelib, DSDcc, DSD-FME, OP25 — search
the tone-decode paths for the b1 index range.
TETRA ACELP (internal/voice/acelp)
TETRA full-rate voice uses an ACELP speech codec specified openly in ETSI EN 300 395-2 (13.167 kbit/s, 137-bit frames / 30 ms → 240 samples at 8 kHz). GopherTrunk ships a pure-Go, clean-room decoder.
Chain. A granted call’s traffic bursts are recovered by the
composer’s TETRA voice chain, TCH/S channel-decoded (EN 300 395-2 §5.5:
RCPC + interleave + class-2 CRC) into 137-bit speech frames, and rendered
to PCM by the ACELP decoder registered as the tetra-acelp vocoder. The
decoder is the usual ACELP pipeline: parameter unpack → LSP split-VQ
dequantisation → per-subframe adaptive (pitch) + algebraic (4-pulse)
codebooks → log-energy gain dequantisation → LSP→LPC conversion →
synthesis filter. Bit-exact fixed-point arithmetic (ITU-T-style 16/32-bit
saturating operators) underlies every block.
Slot isolation. The traffic extractor emits a burst for every TDMA
timeslot on the carrier and tags each with its TDMA timeslot (1..4),
numbered from the synchronisation burst that anchors the 255-dibit slot
grid. The composer’s TETRA chain keeps only bursts on its granted timeslot,
so up to four concurrent calls on one carrier — one per slot, each on its
own cc:same-carrier:N tap — decode into four independent recordings. The
TCH/S class-2 CRC still gates what counts as speech (signalling bursts and
encrypted TEA1-4 traffic fail it); until the slot grid anchors, or on a
traffic-only carrier with no synchronisation burst, the chain falls back to
CRC-gated single-call isolation.
Provenance / licensing. The decoder was ported from the algorithmic
description in EN 300 395-2 and the ITU-T fixed-point operator
definitions; the fixed quantiser tables (LSP codebooks, energy codebook,
interpolation filters) are numeric constants sourced from the ETSI
reference codec (via github.com/curlyboi/libtetradec). ACELP’s core
algebraic-CELP patents (Sherbrooke/VoiceAge, filed late-1980s to
mid-1990s, 20-year term) have expired, so ACELP is far less
patent-encumbered than the DVSI/MBE family. As with IMBE/AMBE+2, the legal
responsibility for operating the codec falls on the deployer, not the
project; operators in licence-restrictive jurisdictions should evaluate
with counsel. The decoder lives behind an explicit vocoder registration
(tetra-acelp) so builds can omit it.
Validation. Each block carries unit tests validated independently of
the reference where possible (e.g. the LSP→LPC conversion is checked against
the LSP root property; log2/pow2 against math; the synthesis filter against
a float recursion). Beyond that, the decoder is bit-exact to the ETSI
EN 300 395-2 reference codec: feeding both the same 137-bit bitstream yields
identical PCM sample-for-sample (0 / 96000 mismatches over 400 frames,
including erasure/BFI frames). The TCH/S channel decode was validated the
same way against the ETSI reference channel codec — GT’s type-3 frame is
bit-identical, which is how the class-2 CRC error that had been dropping
every on-air burst was found and fixed. The ACELP check is reproducible via the
skip-guarded harness internal/voice/acelp/etsi_reference_test.go; the class-2
CRC is guarded in-tree by TestTCHClass2CRCMatchesETSI (two reference-verified
vectors). The ETSI sources/vectors themselves are copyrighted and not committed.
Future work
- Absolute-level calibration thresholds documented; reference data (operator-supplied DSD-FME / OP25 decoded WAVs) is the remaining blocker. AGC per-frame gain tweaks if real frames show systematic level offset.
- Per-vendor knox tone tables (Motorola Trbo, Hytera, generic AMBE+2) — the extension hook ships; vendor reference data is the remaining piece.
- DVSI USB-3000 / AMBE-3003 USB / FTDI transport implementation — the wire-protocol + Vocoder + interface conformance ship now; the actual USB bulk-IN / bulk-OUT plumbing follows when a chip is available for round-trip testing.
- Optional Opus / FLAC re-encoding of the recorded WAVs to shrink long-running archives.
- Plain AMBE decoder for D-STAR voice (different algorithm from AMBE+2; same DVSI patent family).
- TETRA ACELP concurrent-call decoding runs four independent TETRA receivers (one per same-carrier tap) over the same IQ; sharing one receiver’s slot-tagged bursts across the per-slot chains would cut that CPU. Per-burst talkgroup gating is also still disabled for TETRA (the boundary tracker uses grantTG 0) because TCH/S carries no in-band talkgroup identity.