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:

  1. 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.
  2. Parameter model — the common MbeParams interchange type (ω₀, V/UV, M_l).
  3. Codec — analysis and synthesis between MbeParams and 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. Vocoder is Send, 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_pcm after construction or reset describes the encoder's silent pre-roll, and the last 20 ms of audio stays inside the core until flush_encode pushes it out. Call flush_encode once at end of stream, or use LiveEncoder::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 as Err entries in the returned Vec and the buffer still drains, so one bad frame does not stall the stream. Real-time senders should use LiveEncoder rather than encode / encode_stream, which assume the whole buffer is already in hand.
  • Allocation. The codec core is allocation-free; the facade allocates the returned Vec per 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:

SituationStatus
Frame arrived, FEC corrected n bit errorsFrameStatus::new(n, false)
Frame arrived cleanFrameStatus::CLEAN
Frame lost, undecodable, or never arrivedFrameStatus::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). Vocoder only 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. Vocoder is 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

RateWire bytesCodecEnd-to-endCarriers
FullRate7200x440018IMBE Gen 1P25 Phase 1 FDMA voice
FullRate4400x440011IMBE Gen 1IMBE codec, FEC layer stripped (88 prioritized info bits packed MSB-first). For lossless transports or diagnostic / cross-decoder tooling
HalfRate3600x24509AMBE+2 Gen 3P25 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
HalfRate2450x24507AMBE+2 Gen 3AMBE+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 supplythis 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.

ResponsibilityWhere it lives in the consumerWhy it stays
LDU → nine 144-bit IMBE framesLDU parserBurst geometry; fixed offsets inside a 1728-bit LDU
Status-symbol strip, link controlLDU parser, link-control decoderP25 signaling layered with voice, not codec
TDMA burst classificationburst / DUID / ISCH / FACCH / ESS parsersBurst protocol parsing
LFSR descrambling (Phase 2)TDMA descramblerKeyed by WACN/SYSID/NAC; runs before 72-bit frames exist
Call lifecycle, per-call WAV, SSTPcall/stream orchestrationProject-specific orchestration around the codec
Hardware-vocoder client (e.g. ThumbDV over TCP)its own client crateReal-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 + MbeParams into the consumer's own frame shape, or calling MbeParams directly.
  • a half-rate adapter, the same for halfrate::frame::Frame, plus FrameKind routing (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]) -> FrameKind classifies a frame as Voice, Tone, or Erasure; parse_tone_frame extracts the ToneFrameFields and tone_to_mbe_params(id, amplitude) converts a detected tone into synthesizable MbeParams.
  • Rendering a tone to audio is the codec's own job: a tone payload decoded through Vocoder::decode_bits reports FrameDisposition::is_tone and 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:

  1. Apply post-vocoder gain at the application layer (multiply the i16 PCM by the desired factor before handing to the WAV writer).
  2. Set ClassicalConfig::output_gain_db on the [enhancement] chain — +9.0 matches 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.