Integration
August 11, 2026 · View on GitHub
How a consumer project integrates blip25-vocoder. The running example is
p25-decoder (P25 Phase 1 + Phase 2), because that is the first real
consumer; the same boundaries apply to a DMR, NXDN/IDAS, or D-STAR consumer.
The Boundary Principle
blip25-vocoder is voice bits in, audio out — and the reverse. Everything
about the radio system that carries those bits belongs to the consumer. The
crate has no knowledge of:
- RF, symbol timing, demodulation
- burst structure (LDU, TDMA superframe, ISCH, FACCH, DUID)
- system context (WACN, SYSID, NAC)
- scrambling keyed by system context (P25 Phase 2 LFSR)
- call lifecycle, grants, talkgroups, link-control words
- transport (SSTP, WAV files, audio sinks)
It knows three things:
- Wire format — how to turn the channel bits of one 20 ms voice frame into parameters: the 144-bit full-rate frame, the 72-bit half-rate frame, and the info-only forms of each with the FEC layer stripped.
- Parameter model — the common
MbeParamsinterchange type (ω₀, V/UV, M_l). - Codec — analysis and synthesis between
MbeParamsand 8 kHz PCM. One fixed-point core, with the codec generation selected by a mode flag.
Rate conversion is parameter-domain bits-to-bits and is a peer of the codec and wire layers, not a sub-concern of either.
The codec core lives in a private module of this crate. It is not a separate crate, not a dependency, and not something a consumer can depend on directly — everything reachable from outside goes through the public modules below.
Public API Surface
Most consumers should use the Vocoder
façade below — it owns the per-rate state and wraps everything here behind
one handle. The layered free-function API it is built on (rustdoc has the
exact signatures) is:
// Wire layer — channel bits → Frame (FEC) → MbeParams (dequantize, carries
// per-stream decoder state). Two steps, one per rate module.
fullrate::frame::decode_frame(&[u8; 72]) -> Frame // full-rate IMBE, 7200 bps, 72 dibits
fullrate::frame::decode_frame_soft(&[i8; SOFT_BITS]) -> Frame
fullrate::dequantize::dequantize(&frame.info, &mut DecoderState) -> Result<MbeParams>
halfrate::frame::decode_frame(&[u8; DIBITS_PER_FRAME]) -> Frame // half-rate AMBE+2, 3600 bps, 36 dibits
halfrate::frame::decode_frame_soft(&[i8; SOFT_BITS]) -> Frame
halfrate::dequantize::dequantize(&frame.info, &mut DecoderState) -> Result<MbeParams>
// Same modules encode: `encode_frame` for the P25 wire, `encode_code_vectors`
// (half-rate) for the shared FEC core without P25's interleave.
// Codec layer — the core owns its own cross-frame state in both directions,
// so PCM <-> bits has no free-function form. It goes through the facade:
// `Vocoder::encode_pcm` / `decode_bits`, or `LiveEncoder` / `LiveDecoder`.
// Parameter layer — the common interchange type
mbe_params::MbeParams
// Rate conversion — parameter-domain bits → bits, no PCM
rate_conversion::{FullToHalfConverter, HalfToFullConverter} // ::new(); .convert(&[u8; N]) -> Result<[u8; M]>
// Shared FEC primitives (consumers may use directly)
fec::{golay_23_12_decode, golay_24_12_decode, hamming_15_11_decode,
golay_23_12_decode_soft, golay_24_12_decode_soft, hamming_15_11_decode_soft, FecDecoded}
decode_frame is authoritative for its wire. Consumers should not
re-implement interleave patterns, FEC polynomial application, or priority
bit ordering — those belong to this crate.
The Vocoder handle
The recommended entry point for in-process Rust callers is the
Vocoder handle in blip25_vocoder::vocoder. It owns all per-rate state
(analysis, decoder, synthesis) internally and presents a uniform
encode/decode/reset surface across rates selected at runtime via
Rate. It builds on the wire + codec + parameter layers above —
no functionality the low-level modules don't have, just consolidation
behind a single per-channel handle.
use blip25_vocoder::vocoder::{Rate, Vocoder};
// One channel direction = one Vocoder. Two channels for full-duplex.
let mut tx = Vocoder::new(Rate::FullRate7200x4400); // or Rate::HalfRate3600x2450
let bits = tx.encode_pcm(&pcm_frame)?; // 18-byte FEC frame (or 9 for Phase 2)
let mut rx = Vocoder::new(Rate::FullRate7200x4400);
let pcm = rx.decode_bits(&bits)?; // 160 samples i16
// Streaming variants for whole-buffer encode/decode:
let bits: Vec<Vec<u8>> = tx.encode_stream(&pcm).collect::<Result<_, _>>()?;
let frames: Vec<Vec<i16>> = rx.decode_stream(&all_bits).collect::<Result<_, _>>()?;
// Diagnostics:
let stats = tx.last_stats(); // FrameStats { analysis: Some(...), decode: None }
println!("emitted {:?}, params = {:?}", stats.analysis.as_ref().unwrap().disposition, stats.analysis.as_ref().unwrap().params);
// Return the channel to its power-on state:
tx.reset();
Vocoder is enum-dispatched (no dyn overhead) and exhaustively covers each
Rate variant. Transcoder is the same idea for bits-to-bits rate
conversion: a direction fixed at construction, one input wire frame in, one
output wire frame out, with the codec core never involved.
Threading and real time
- One channel, one thread.
VocoderisSend, and every entry point takes&mut self, so a handle can move between threads but only ever serves one channel from one place at a time. A full-duplex call is two handles, and N simultaneous calls are N handles — there is no internal multiplex and no global state, so per-call handles scale linearly. - 20 ms of audio per frame, always. 160 i16 samples at 8 kHz, for every
rate. The wire side is 18, 11, 9 or 7 bytes depending on
Rate. - Encoding carries one frame of algorithmic delay, matching the reference
vocoder's own contract. The frame returned by the first
encode_pcmafter construction orresetdescribes the encoder's silent pre-roll, and the last 20 ms of audio stays inside the core untilflush_encodepushes it out. Callflush_encodeonce at end of stream, or useLiveEncoder::flush, which does it for you. - Chunked I/O uses
LiveEncoder/LiveDecoder. Both are push-driven: feed them buffers of arbitrary length, get back whole frames plus a residue counter (pending_samples/pending_bytes). Per-frame errors come back asErrentries in the returnedVecand the buffer still drains, so one bad frame does not stall the stream. Real-time senders should useLiveEncoderrather thanencode/encode_stream, which assume the whole buffer is already in hand. - Allocation. The codec core is allocation-free; the facade allocates the
returned
Vecper frame for API convenience. A caller on a strict real-time budget can drop to the wire + info-vector API and keep its own buffers.
FrameStatus and erasure marking
Concealment quality is only as good as the channel information the receiver hands down. Two paths carry it.
When this crate's FEC layer is in the path, decode_bits derives the
status from what the Golay/Hamming decoders had to correct. For a frame that
never arrived, feed Rate::erasure_frame() — a wire frame of the right length
whose pitch index sits deliberately outside the valid range (b0 ∈ [120, 127]
for the half-rate wire, b0 > 207 for full-rate), so no single bit error can
turn it back into a decodable pitch. The core holds the previous good frame and
fades it out over successive erasures; FrameDisposition::is_concealed reports
when that is happening.
When your FEC lives upstream, you know things the codec cannot see, and you pass them explicitly. On an encrypted link the codec never sees a wire frame at all: encryption applies to the parameter bits and the FEC wraps the ciphertext, so decryption has to happen between the FEC layer and the codec. A receiver in that position holds info vectors, not bytes, and is the only party that knows how hard its FEC had to work.
use blip25_vocoder::vocoder::{FrameStatus, Rate, Vocoder};
// Transmit: audio -> parameter bits -> (your encryption) -> (your FEC).
let mut tx = Vocoder::new(Rate::HalfRate3600x2450);
let info = tx.encode_info(&pcm_frame)?; // 4 vectors (8 for IMBE)
// Receive: (your FEC) -> (your decryption) -> parameter bits -> audio.
// Pass what your FEC measured; it drives concealment inside the codec.
let mut rx = Vocoder::new(Rate::HalfRate3600x2450);
let pcm = rx.decode_info(&info, FrameStatus::new(corrected_errors, false))?;
The three status values to feed:
| Situation | Status |
|---|---|
| Frame arrived, FEC corrected n bit errors | FrameStatus::new(n, false) |
| Frame arrived clean | FrameStatus::CLEAN |
| Frame lost, undecodable, or never arrived | FrameStatus::LOST |
decode_bits_with_status takes the same value when you hold wire bytes but
your own layer measured the channel.
Call the decode entry point for every frame slot in order, including ones
with no usable data — pass FrameStatus::LOST there, with whatever payload you
have (it is not read). The codec carries state in both the spectral envelope
and the harmonic phase, so a skipped frame desynchronises everything after it.
What Vocoder deliberately does NOT model
These belong to the radio around the codec, not to the codec:
- Audio mode (sample rate, format, gain).
Vocoderonly consumes/produces 8 kHz mono i16; resampling and gain control are radio-side. - Transport framing and flow control. An in-process call needs none.
- Multi-channel multiplex.
Vocoderis one channel; allocate as many handles as you need. - Audio-domain DSP (AGC, noise removal). The optional post-decode
enhancement chain (
set_enhancement) is the seed of this, but the crate does not otherwise condition audio — that is radio-side.
Tone frames, by contrast, are modeled on both sides: half-rate decode dispatches a tone payload to Annex-T synthesis, and the encoder emits a tone frame itself when its own detector fires.
Wire-format support matrix
| Rate | Wire bytes | Codec | End-to-end | Carriers |
|---|---|---|---|---|
FullRate7200x4400 | 18 | IMBE Gen 1 | ✅ | P25 Phase 1 FDMA voice |
FullRate4400x4400 | 11 | IMBE Gen 1 | ✅ | IMBE codec, FEC layer stripped (88 prioritized info bits packed MSB-first). For lossless transports or diagnostic / cross-decoder tooling |
HalfRate3600x2450 | 9 | AMBE+2 Gen 3 | ✅ | P25 Phase 2 TDMA voice. The 72-bit code-vector frame beneath it — widths {24,23,11,14} over info widths {12,12,11,14} — is shared with DMR Tier II/III and NXDN, which differ only in interleave; reach it via halfrate::frame::encode_code_vectors / decode_code_vectors |
HalfRate2450x2450 | 7 | AMBE+2 Gen 3 | ✅ | AMBE+2 half-rate codec, FEC layer stripped (49 info bits + 7 pad bits), natural / AMBE_d order. The 7-octet payload P25 encrypts (TIA-102.AAAD-B §1.2), and the layout mbelib / DSD / MMDVM / an IDAS-NXDN wire use |
For carriers whose post-FEC info layout differs (older DMR, AMBE+
Gen 2 NXDN, D-STAR's specific bit layout), drop down a layer to
the wire layer directly: halfrate::frame / fullrate::frame turn post-FEC
info vectors into MbeParams via their dequantize modules, and the same
modules quantize back. That seam is carrier-agnostic — anything that can
produce the post-FEC info vectors can drive it.
Foreign protocols: DMR, NXDN, IDAS
The 49→72-bit voice-frame FEC is the codec's, not any protocol's: the same
extended Golay(24,12) on c₀, Golay(23,12) plus û₀-seeded PN on c₁, and
uncoded c₂ / c₃ carry P25 Phase 2, DMR Tier II/III and NXDN alike. What
differs is the interleave that lays those 72 bits onto the air, and the burst
framing around it. So the split is:
| you supply | this crate supplies |
|---|---|
| burst framing, sync, signalling | — |
| your protocol's (de)interleave | — |
| — | the shared FEC core (halfrate::frame::encode_code_vectors / decode_code_vectors) |
| — | the vocoder (Vocoder::encode_info / decode_info) |
halfrate::frame::decode_frame / encode_frame exist too, but they prepend
P25 Phase 2's Annex-S interleave — they are the P25 adapter, not the shared
path. A foreign protocol wants the *_code_vectors pair, whose four code
vectors are 24, 23, 11 and 14 bits wide (CODE_WIDTHS), LSB-aligned in their
u32s, highest-indexed bit transmitted first. Your interleave takes over from
there.
If your receiver already produces per-bit confidence, decode_code_vectors_soft
takes a SoftCodeVectors (sign is the hard decision, magnitude the confidence)
and is worth roughly 2 dB over hard slicing.
examples/foreign_protocol.rs is the worked end-to-end version of this path,
including the bit-packing helpers and the soft-decision variant.
What Stays in the Consumer (p25-decoder example)
These responsibilities are radio-side. They never migrate into this crate.
| Responsibility | Where it lives in the consumer | Why it stays |
|---|---|---|
| LDU → nine 144-bit IMBE frames | LDU parser | Burst geometry; fixed offsets inside a 1728-bit LDU |
| Status-symbol strip, link control | LDU parser, link-control decoder | P25 signaling layered with voice, not codec |
| TDMA burst classification | burst / DUID / ISCH / FACCH / ESS parsers | Burst protocol parsing |
| LFSR descrambling (Phase 2) | TDMA descrambler | Keyed by WACN/SYSID/NAC; runs before 72-bit frames exist |
| Call lifecycle, per-call WAV, SSTP | call/stream orchestration | Project-specific orchestration around the codec |
| Hardware-vocoder client (e.g. ThumbDV over TCP) | its own client crate | Real-hardware peer, useful for A/B against the codec |
The LFSR case is the clearest test of the boundary: a hardware vocoder chip
does not know about WACN/NAC either. The host feeds it already-descrambled
72-bit frames. Same contract for blip25-vocoder.
Consumer Shim
A consumer that already has its own frame and parameter types writes a small
adapter between them and MbeParams, one per codec:
- a full-rate adapter wrapping
fullrate::frame::Frame+MbeParamsinto the consumer's own frame shape, or callingMbeParamsdirectly. - a half-rate adapter, the same for
halfrate::frame::Frame, plusFrameKindrouting (voice vs. tone vs. erasure).
Everything downstream of the adapter — call lifecycle, per-call WAV writers, stream emitters — needs no knowledge of this crate.
Design Decisions at the Boundary
Recorded so future protocol consumers do not re-derive them.
Tone frames
Detection is wire-layer (a specific bit pattern in the 72-bit half-rate frame). Synthesis is codec-layer (dual-sinusoid DTMF / ringback).
halfrate::dequantize::classify_frame(&[u16; 4]) -> FrameKindclassifies a frame asVoice,Tone, orErasure;parse_tone_frameextracts theToneFrameFieldsandtone_to_mbe_params(id, amplitude)converts a detected tone into synthesizableMbeParams.- Rendering a tone to audio is the codec's own job: a tone payload decoded
through
Vocoder::decode_bitsreportsFrameDisposition::is_toneand synthesizes the tone itself.
Spectral enhancement
The enhancement and phase-regeneration algorithms (US5701390, US8595002,
US8315860) are codec-internal quality logic, not wire format. They live inside
the private codec core. They do not live in mbe_params/ and they do not live
in any wire submodule. (This is separate from enhancement, the optional
audio-domain post-filter chain, which is off by default.)
A wire submodule's only contract with the codec is bits ↔ MbeParams.
Pairing the fullrate wire with the half-rate codec path is therefore a
valid combination (the SCBA-mask deployment pattern); the wire layer
makes that combination expressible.
Soft-decision FEC
blip25_vocoder::fec exposes soft-decision Golay and Hamming so that consumers
which need them for non-vocoder purposes (e.g., P25 TSBK decode) can pull them
from a single place rather than carrying a second implementation.
Loudness calibration reference
blip25-vocoder targets reference PCM parity per BABA-A. The only valid
calibration reference is the canonical reference test-vector PCM — the
half-rate r33 and full-rate tv vector sets under the (non-redistributable)
reference-material corpus.
Other P25 open-source decoders (SDRTrunk / JMBE / OP25) are NOT
valid references. Their PCM output contains post-synthesis gain
(typically ~8× for SDRTrunk) that is layered on top of the BABA-A
synthesis pipeline. Calibrating γ_w or any other codec constant
against those outputs would require values far outside the spec's
plausible range and would break conformance with the reference vocoder.
Against the canonical half-rate reference PCM on the alert, clean, cp0,
cp1 and cp31 vectors, output RMS is 0.999× to 1.067× of the reference.
That is the correct target; loudness parity with SDRTrunk is not.
Consumers requiring SDRTrunk-parity loudness for operational UX have two options:
- Apply post-vocoder gain at the application layer (multiply the i16 PCM by the desired factor before handing to the WAV writer).
- Set
ClassicalConfig::output_gain_dbon the [enhancement] chain —+9.0matches SDRTrunk's empirical post-decode gain. The gain saturates at i16 clip and runs after all other enhancement stages, so it composes cleanly with the HPF + peaking defaults.
Either is an application-layer choice — the codec itself stays calibrated against reference parity per BABA-A.
Tones vs speech: random-phase reconstruction artifact
A pure-sine round-trip through the spec-faithful full-rate path does not reproduce a pure sine. Per BABA-A Eq. 141, voiced harmonics are synthesized with a fresh random phase per frame; the inter-frame phase interpolation creates a frequency-modulated approximation of the input tone. Measured over a 100-frame round-trip at 349 Hz:
| Input amp | Δ peak | Δ RMS |
|---|---|---|
| 16384 | -0.86 dB | -4.91 dB |
| 8192 | -0.52 dB | -5.19 dB |
| 4096 | -0.26 dB | -5.00 dB |
| 2048 | -0.65 dB | -5.14 dB |
Peak amplitude is preserved; RMS is consistently ~5 dB lower because the reconstructed waveform has higher crest factor than a pure sine. This is structural to MBE-class codecs — removing it would require beyond-spec phase tracking. On real speech, RMS round-trip is within ~0.3 dB; the random-phase RMS drop only manifests on monotone / near-monotone inputs.
At half rate the Annex T tone-frame path bypasses MBE synthesis entirely and reconstructs deterministic sines from a lookup table — round-trip on a sine is unity (±0.3 dB), and the encoder emits those tone frames on its own for consumers carrying DTMF / Knox / single-tone content. Full rate has no equivalent fast path; tones over Phase 1 always exhibit the random-phase RMS drop.
Parameter type unification
A consumer holding separate per-codec parameter structs with overlapping fields
adapts them to the single MbeParams type — one struct covering both codecs,
not a trait. Type aliases on the consumer side are a fine transitional step;
the target is direct use of MbeParams so that one set of parameters crosses
every rate boundary.
Test Fixture Allocation
Fixtures move with the code that tests them.
To conformance/:
- Hex-encoded wire frames (
ambe_dump_*.txt, full-rate frame dumps) - Soft-decision test vectors (
*.soft8) - Reference PCM (
*.pcm,*.wav) for end-to-end synthesis checks - Reference TIA-102 test vectors, when present
Stays in the consumer:
- Raw IQ captures (RF-layer)
- Symbol dumps, burst sync fixtures
- LFSR-descramble verification (system-context dependent)
- Anything that exercises code which is not part of the codec
What the boundary costs a consumer
Adopting this crate moves the codec out of the consumer and leaves the radio around it:
- Out: parameters, frames, quantizer tables, synthesis, enhancement, FEC.
- Stays, untouched: LDU / burst extraction, the descrambling LFSR, call lifecycle, stream emitters, WAV writing.
- New: one adapter file per codec, translating the consumer's own parameter
types to and from
MbeParams. - No protocol code changes. Only re-imports and adapter calls.
The change is surgical, not invasive. The mental model holds:
blip25-vocoder is the codec; the consumer is the radio around it.