Design

August 11, 2026 · View on GitHub

The Model

A vocoder is commonly described as the thing that turns bits into speech. That framing hides the seams that matter.

What exists in a digital voice codec is three independent translations, each with its own domain, its own literature, and its own evolutionary pressures:

  1. A wire format — a bit layout, protected by FEC, that survives the radio channel. A standardized container. The only thing that cannot change without breaking interoperability.

  2. A parameter model — a compact mathematical description of speech that rides inside that container. For the MBE family this is (ω₀, V/UV, M_l): pitch, voicing decisions per harmonic, spectral amplitudes. Stable since Griffin & Lim, 1988.

  3. A codec — the analysis and synthesis algorithms that move between PCM audio and the parameter model. Free to evolve. Four generations have shipped behind the same parameter model and, in the P25 case, the same wire format.

Collapsing these three into one concept named "IMBE" is the common shortcut, and it is not harmless: it hides the fact that parametric rate conversion — the one operation that lives entirely inside layer 2 — is possible at all. Software is shaped by the mental model of the people who write it.

The Axes

The three layers imply three independent axes. Every vocoder operation is a choice on each:

      ┌────────────────┐       ┌────────────────┐       ┌────────────────┐
      │  Wire format   │       │  Parameter     │       │  PCM audio     │
      │                │       │  model         │       │                │
      │  full rate     │←wire→ │                │←codec→│                │
      │  half rate     │ pack  │   MbeParams    │  ana/ │  8 kHz         │
      │  (future: DMR, │       │                │  syn  │  16-bit        │
      │   NXDN, …)     │       │                │       │                │
      └────────────────┘       └────────────────┘       └────────────────┘


                            rate conversion lives here.
                              no codec. no PCM. no synthesis.

Wire format is fixed by interoperability. Codec generation is a quality choice. Parameter model is the common ground that makes the other two independent.

The Center

The center of gravity is the parameter model, not the wire and not the PCM. This is the architectural commitment that makes rate conversion obvious:

   bits @ rate_A  →  parse  →  MbeParams  →  requantize  →  pack  →  bits @ rate_B

No synthesis. No re-analysis. No PCM. A single journey through parameter space. This is the crate's headline capability, not an afterthought bolted onto a decoder.

Code Layout

One crate. The manifest is at the repository root; src/ is the whole library, and the two members under conformance/ are development-only and never published.

src/
  lib.rs             — the module map and the crate's doc entry point.
  vocoder.rs         — the `Vocoder` façade. One handle is one channel.
                       Owns per-channel codec state; the recommended API.
  mbe_params/        — the interchange type. the center.
  engine/            — the codec core. PRIVATE. see below.
  fullrate/          — P25 Phase 1 FDMA: 144-bit wire (BABA-A §1–§12).
  halfrate/          — P25 Phase 2 TDMA: 72-bit wire (BABA-A §13–§17 +
                       Annexes L–T).
  rate_conversion/   — parameter-domain bits-to-bits. peer of the wire and
                       codec layers, not a sub-concern of either.
  fec.rs             — shared FEC primitives (Golay(23,12), Hamming(15,11)).
  bits.rs            — shared bit-packing primitives. private: its only type
                       is the literal the generated priority tables are
                       built from.
  enhancement.rs     — optional post-decode filter chain. off by default.
  generated/         — the spec Annex tables, read verbatim, never
                       reconstructed from a formula.
conformance/
  roundtrip/         — corpus-driven conformance harnesses and metrics.
  no-std-guard/      — compile-time guard over the engine. see below.

Three concerns, three top-level axes, one interchange type.

The codec-generation axis has no sibling modules: the engine covers the two codecs the two P25 wires need, and both are one fixed-point implementation selected by a mode flag. The axis is real; it is just not populated past two points.

Why the engine is private

src/engine/ is the audio half of the crate: fixed-point analysis and synthesis, allocation-free, no_std-clean, bit-exact with the reference software vocoder. lib.rs declares it mod engine, not pub mod — the only thing from it that reaches the public API is the re-exported FrameStatus.

The reasons are structural, not stylistic:

  • It is not an interoperability surface. Its function signatures are shaped by the reference implementation's internal call structure — i16 bit arrays, fixed-size scratch buffers, packed state words. Exporting them would freeze that shape into this crate's semver contract, where it would constrain the port rather than serve a consumer.
  • It is the wrong seam for callers. The engine performs no error correction and has no bad-frame input. A consumer holding an engine handle would still have to reimplement framing, FEC, de-interleaving and erasure marking to feed it. Vocoder is the seam that carries a whole channel; the wire modules are the seam for frame-by-frame access.
  • It is patent-encumbered and reverse-engineered from a compiled image of the reference software vocoder, while the wire layers are spec-derived. Keeping the boundary a module boundary keeps that provenance split legible in the source tree instead of only in the documentation.

The engine is also deliberately more complete than it is reachable: entry points the reference exposes are kept even where nothing in this crate calls them, so dead_code is allowed module-wide rather than pruning the port down to its current callers.

Why conformance/no-std-guard exists

The engine's no_std, allocation-free, core-only property is a property of the code, and nothing in this crate's own build enforces it: blip25-vocoder is a std crate, so an accidental std:: path or alloc use inside src/engine/ would compile silently.

conformance/no-std-guard is a tiny non-published crate whose src/lib.rs is #![no_std] and mounts src/engine/mod.rs by #[path] as its own root module. Nothing consumes the engine there — the point is that it compiles. Built for a bare-metal target such as thumbv7em-none-eabi, where std does not exist at all, the guard cannot be satisfied by accident.

That is the whole reason the workspace has members: they are instruments, not layers. conformance/roundtrip holds the corpus-driven harnesses, which skip when their corpus is absent.

Wire layer naming policy

These modules are named by rate (fullrate = full rate at 7,200 bps; halfrate = half rate at 3,600 bps) because they model the codec channel frame — the rate-defined Golay/Hamming/PN/interleave layer sitting directly on the parameter bits. Protocol-specific over-the-air framing (burst layout, scrambling, sync) lives above this crate, in each protocol's CAI/air-interface layer, not here.

Rate on the Vocoder façade is fixed for a handle's lifetime. Codec state — predictor history, the previous frame held for concealment, the enhancement filters — is quantized on the grid of the rate that produced it, so carrying it across a rate change would mean reinterpreting one rate's history under another's grid.

The DMR / NXDN reuse seam

The reuse claim splits cleanly into two layers, and the seam is drawn in code:

  • Codec FEC core — SHARED bit-for-bit. The 49→72-bit half-rate FEC — [24,12] extended Golay on c₀, [23,12] Golay on c₁, the û₀-seeded 24-value LCG PN scramble of c₁, uncoded c₂/c₃, and the û₀..û₃ = [12,12,11,14] bit prioritization — is defined by the codec at its 3600/2450 operating point, not by any protocol. P25 Phase 2, DMR and NXDN (4800/"EHR") all carry exactly this codec channel frame. halfrate exposes the core as decode_code_vectors / decode_code_vectors_soft (4 code vectors → Frame), reusable as-is by a future DMR/NXDN module.
  • Interleave — P25-Phase-2-SPECIFIC, NOT shared. The map from OTA dibits to the four code vectors (halfrate's Annex S, 72 bits ↔ 36 dibits) is part of P25's air interface. DMR and NXDN each define their own distinct voice-bit interleave, burst layout and embedded signalling, so decode_frame / decode_frame_soft — which prepend Annex S — are P25-only adapters. A dmr_voice/ / nxdn_voice/ sibling supplies its protocol's (soft-)deinterleave to land bits in [u32; 4] / SoftCodeVectors, then calls the shared core above.

So the reuse point is the post-deinterleave code-vector frame, not the OTA-dibit entry. The codec layer is shared across protocols regardless. Wire formats are therefore not strictly P25-specific: protocol burst layout lives above this crate.

The deeper boundary is the bare parameter frame

There are two boundaries here, at two layers, and the codec boundary is the deeper one:

  • The bare 49-bit parameter frame (MbeParams / a Frame), FEC stripped. This is the true codec interface — FEC-agnostic, protocol-agnostic, and the same for P25 Phase 2, DMR and NXDN.
  • The code vectors (SoftCodeVectors) — that same frame wrapped in one specific deterministic channel-coding profile (Golay/PN). It is the input to a particular FEC decoder, not the codec itself.

The FEC layer is just the deterministic map between them. Three things follow, and they shape what belongs in this crate versus above it:

  1. Encryption sits at the parameter-frame boundary, not below it. P25/DMR/NXDN encrypt the parameter bits, with FEC wrapping the ciphertext, so a keystream XOR has a home only on the 49-bit frame. A radio therefore runs its vocoder at the bare-frame interface and does FEC and encryption in the host; the codec never holds a key. This crate exposes the 49-bit frame as a first-class seam so a consumer can inject the XOR between FEC-decode and synthesis.
  2. The channel code is a profile, not the codec. Golay/PN is one deterministic code chosen for the half-rate use case; the bare frame is the open substrate beneath it. A future protocol may wrap the same 49-bit frame in a different code (LDPC, none, custom UEP) — which is why bit prioritization (halfrate::priority) is exported as an FEC-independent fact.
  3. This crate keeps its own Golay/PN by design. As a standalone library it cannot depend on a consumer's FEC crate, and it needs the math to model interop at the code-vector boundary. A P25/DMR/NXDN CAI layer above will hold its own copy — one FEC, since the voice-frame FEC is identical across the three, plus per-protocol interleave. Both copies are generic coding theory, not P25 IP, and stay bit-exact against a shared test vector.

Wire generation and codec generation are independent

BABA-A is a consolidation of two independent vocoder specs: the original 1998 BABA (full rate) and the BABA-1 addendum (half rate). The two wires are governed by the same document but they are not the same vocoder. P25 Phase 1 fire-channel deployments in particular sometimes pair the fullrate wire with the half-rate codec for SCBA-mask noise immunity — a valid combination because the wire layer's only contract with the codec is bits ↔ MbeParams.

Rate conversion is the other consequence. rate_conversion holds its own cross-rate predictor state, distinct from both the source decoder's and the target encoder's intra-rate predictors, because the two ends of the conversion evolve with their own peers rather than with each other.

Where the code comes from

The provenance split is a boundary in the source tree, and it is load-bearing:

  • Wire layers, FEC, bit priority, quantizer grids — spec-derived from the published TIA-102.BABA / BABA-A standards. The Annex tables in src/generated/ are transcribed verbatim from the normative text.
  • src/engine/ — recovered by reverse engineering a compiled image of the reference software vocoder. That includes its constant tables (src/engine/tables.rs, each annotated with the address it came from so it can be re-checked against the original). This is derivation from a binary, not from anyone's source.

See ATTRIBUTION.md for per-component provenance and PATENT_NOTICE.md for what it reads on. The engine is patent-encumbered; the wire layers are not the encumbered part, and keeping the two separable is part of why the boundary is where it is.

Correctness and quality are different concerns

Correctness is conformance — every bit in the wire, every coefficient in the FEC, every entry in the quantizer, and bit-exactness of the engine against the reference vocoder. It is measured by tests: unit tests against spec examples for the wire layers, and captured-frame replay for the engine.

Quality is perceptual audio fidelity, judged by ear against reference audio rather than by a fidelity score. Waveform SNR does not score a parametric codec, and it does not score rate conversion at all. The two live in different places: correctness in tests/, quality in the listening and distortion harnesses under conformance/roundtrip.

Building requires no proprietary material

The published crate contains no reference-vendor material, no reference vectors, no recorded hardware output. Anyone can clone, cargo test, and see green. Conformance against the reference vectors lives in the conformance/ workspace members, which are never published to crates.io and which skip when the corpus is absent.

The encode and decode features gate the public API surface, not the core: a decode-only build drops the encode entry points while the engine itself stays whole. With serde off the crate has no runtime dependencies at all.

What this shape buys

An implementation where the shape of the code reflects the shape of the domain. Where wire, parameter and codec are three separate concerns because they are three separate concerns. Where rate conversion is a short, clear function because it actually is a short, clear function. Where a new MBE generation is a new directory, not a rewrite.

The specs have been published for decades. The patents on the parameter model have expired. The test vectors are free to download. What the domain asks for is a clean decomposition.