mbelib_rs/lib.rs
1// SPDX-FileCopyrightText: 2026 Swift Raccoon
2// SPDX-License-Identifier: GPL-2.0-or-later
3//
4// See ../LICENSE for full attribution including upstream copyrights from
5// szechyjs's mbelib and DSD projects (both originally ISC-licensed,
6// redistributed here under GPL-2.0-or-later as permitted by ISC) and
7// JMBE-compatible algorithm ports adapted from arancormonk/mbelib-neo
8// (also GPL-2.0-or-later).
9
10//! Pure Rust AMBE 3600×2400 voice codec decoder for D-STAR digital radio.
11//!
12//! The AMBE (Advanced Multi-Band Excitation) 3600×2400 codec compresses
13//! speech at 3600 bits/second with 2400 bits of voice data and 1200 bits
14//! of forward error correction (FEC). It is the mandatory voice codec
15//! for the JARL D-STAR digital radio standard, used in all D-STAR
16//! transceivers and reflectors worldwide.
17//!
18//! Each voice frame is 9 bytes (72 bits), transmitted at 50 frames per
19//! second (20 ms per frame). The codec models speech as a sum of
20//! harmonically related sinusoids, with each band independently
21//! classified as voiced or unvoiced.
22//!
23//! # Usage
24//!
25//! ```
26//! use mbelib_rs::AmbeDecoder;
27//!
28//! // Create one decoder per voice stream — it carries inter-frame state
29//! // needed for delta decoding and phase-continuous synthesis.
30//! let mut decoder = AmbeDecoder::new();
31//!
32//! // Feed 9-byte AMBE frames from D-STAR VoiceFrame.ambe field.
33//! let ambe_frame: [u8; 9] = [0; 9];
34//! let pcm: [i16; 160] = decoder.decode_frame(&ambe_frame);
35//!
36//! // Output: 160 samples at 8 kHz, 16-bit signed PCM (20 ms of audio).
37//! assert_eq!(pcm.len(), 160);
38//! ```
39//!
40//! # Decode Pipeline
41//!
42//! Each frame passes through these stages:
43//!
44//! 1. **Bit unpacking** — 72-bit frame → 4 FEC codeword bitplanes
45//! 2. **Error correction** — Golay(23,12) on C0 and C1 (3-error
46//! correction). AMBE 3600×2400 does not apply Hamming to C3; those
47//! 14 bits are copied verbatim into the parameter vector.
48//! 3. **Demodulation** — LFSR descrambling of C1 using C0 seed
49//! 4. **Parameter extraction** — 49 decoded bits → fundamental frequency,
50//! harmonic count, voiced/unvoiced decisions, spectral magnitudes.
51//! Frames with b0 in the erasure range (120..=123) or tone range
52//! (126..=127) trigger the same repeat/conceal path as Golay-C0
53//! failures, since D-STAR does not use codec-level tone signaling.
54//! 5. **Spectral enhancement** — adaptive amplitude weighting for clarity
55//! 6. **Adaptive smoothing** — JMBE algorithms #111-116, gracefully
56//! damps spurious magnitudes/voicing decisions on noisy frames
57//! 7. **Frame muting check** — comfort noise on excessive errors or
58//! sustained repeat frames (JMBE-compatible)
59//! 8. **Synthesis** — voiced bands per-band cosine oscillators (with
60//! JMBE phase/amplitude interpolation for low harmonics) plus a
61//! single FFT-based unvoiced pass (JMBE algorithms #117-126)
62//! 9. **Output conversion** — float PCM → i16 with SIMD-vectorized
63//! gain and clamping
64
65mod adaptive;
66mod decode;
67mod ecc;
68#[cfg(feature = "encoder")]
69mod encode;
70mod enhance;
71mod error;
72mod math;
73mod params;
74mod synthesize;
75mod tables;
76mod unpack;
77mod unvoiced_fft;
78
79pub use error::DecodeError;
80
81/// Inspection helper for golden-vector validation.
82///
83/// Runs just the unpack → ECC → parameter-extract pipeline for a
84/// single frame and returns `(b[0..9], w0, L, ambe_d)` as the decoder
85/// sees them. Used by the validation harness in
86/// `examples/decode_ambe_stream.rs` to diff against mbelib's decoded
87/// `(b, w0, L, ambe_d)` for identical wire bytes.
88///
89/// The full `ambe_d` vector (49 bits, one byte per bit, 0 or 1) is
90/// returned alongside the extracted parameter fields so downstream
91/// tooling can localize a divergence to "ECC disagrees" (`ambe_d`
92/// bits differ) vs "parameter extraction disagrees" (`ambe_d` bits
93/// match but `b[]` differs).
94///
95/// This is deliberately stateless — each call constructs fresh
96/// `MbeParams` — so the output depends only on the input bytes and
97/// can be compared frame-for-frame against another implementation.
98#[must_use]
99pub fn decode_trace(ambe: &[u8; 9]) -> ([usize; 9], f32, usize, [u8; 49]) {
100 let mut ambe_fr = [0u8; AMBE_FRAME_BITS];
101 let mut ambe_d = [0u8; AMBE_DATA_BITS];
102 unpack::unpack_frame(ambe, &mut ambe_fr);
103 let _ = ecc::ecc_c0(&mut ambe_fr);
104 unpack::demodulate_c1(&mut ambe_fr);
105 let _ = ecc::ecc_data(&ambe_fr, &mut ambe_d);
106
107 let bit = |i: usize| usize::from(*ambe_d.get(i).unwrap_or(&0));
108 let b0 = (bit(0) << 6)
109 | (bit(1) << 5)
110 | (bit(2) << 4)
111 | (bit(3) << 3)
112 | (bit(4) << 2)
113 | (bit(5) << 1)
114 | bit(48);
115 let b1 = (bit(38) << 3) | (bit(39) << 2) | (bit(40) << 1) | bit(41);
116 let b2 =
117 (bit(6) << 5) | (bit(7) << 4) | (bit(8) << 3) | (bit(9) << 2) | (bit(42) << 1) | bit(43);
118 let b3 = (bit(10) << 8)
119 | (bit(11) << 7)
120 | (bit(12) << 6)
121 | (bit(13) << 5)
122 | (bit(14) << 4)
123 | (bit(15) << 3)
124 | (bit(16) << 2)
125 | (bit(44) << 1)
126 | bit(45);
127 let b4 = (bit(17) << 6)
128 | (bit(18) << 5)
129 | (bit(19) << 4)
130 | (bit(20) << 3)
131 | (bit(21) << 2)
132 | (bit(46) << 1)
133 | bit(47);
134 let b5 = (bit(22) << 3) | (bit(23) << 2) | (bit(25) << 1) | bit(26);
135 let b6 = (bit(27) << 3) | (bit(28) << 2) | (bit(29) << 1) | bit(30);
136 let b7 = (bit(31) << 3) | (bit(32) << 2) | (bit(33) << 1) | bit(34);
137 let b8 = (bit(35) << 3) | (bit(36) << 2) | (bit(37) << 1);
138
139 let b = [b0, b1, b2, b3, b4, b5, b6, b7, b8];
140 let f0 = *tables::W0_TABLE.get(b0).unwrap_or(&0.0);
141 let w0 = f0 * std::f32::consts::TAU;
142 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
143 let big_l = *tables::L_TABLE.get(b0).unwrap_or(&0.0) as usize;
144 (b, w0, big_l, ambe_d)
145}
146
147#[cfg(feature = "encoder")]
148pub use encode::{
149 AmbeEncoder, EncoderBuffers, FftPlan, MAX_BANDS, MAX_HARMONICS, PitchEstimate, PitchTracker,
150 SpectralAmplitudes, VuvDecisions, VuvState, analyze_frame, compute_e_p, detect_vuv,
151 detect_vuv_and_sa, extract_spectral_amplitudes, pack_frame, validation,
152};
153
154/// Kenwood-specific constants for A/B testing the encoder, gated
155/// behind the `kenwood-tables` feature.
156///
157/// The encoder pipeline does NOT consume these by default — the
158/// module is a catalogue, not a swap. Swap points are introduced
159/// deliberately, one at a time, with each change measurable against
160/// hardware-in-the-loop captures.
161#[cfg(feature = "kenwood-tables")]
162pub use encode::kenwood;
163
164use ecc::AMBE_DATA_BITS;
165use params::MbeParams;
166use synthesize::FRAME_SAMPLES;
167use unpack::AMBE_FRAME_BITS;
168use wide::{f32x4, i32x4};
169
170/// Output audio gain applied during float-to-i16 conversion.
171const GAIN: f32 = 7.0;
172
173/// Maximum absolute sample value after gain (clamp threshold). Matches
174/// mbelib-neo's JMBE-parity soft-clip at 95% of i16 max.
175const CLAMP_MAX: f32 = 32_767.0 * 0.95;
176
177/// Total bits per AMBE 3600x2400 frame (used to compute error rate).
178const FRAME_BITS: f32 = 72.0;
179
180/// Stateful AMBE 3600×2400 voice frame decoder.
181///
182/// The AMBE codec uses inter-frame prediction: each frame's gain and
183/// spectral magnitudes are delta-coded against the previous frame.
184/// This decoder maintains three parameter snapshots to support that:
185///
186/// - **`cur`** — parameters decoded from the current frame
187/// - **`prev`** — previous frame's parameters (before enhancement),
188/// used as the prediction reference for delta decoding
189/// - **`prev_enhanced`** — previous frame's parameters (after spectral
190/// enhancement), used as the cross-fade source during synthesis
191///
192/// # Invariants
193///
194/// - Create one `AmbeDecoder` per voice stream (per D-STAR `StreamId`).
195/// - Feed frames sequentially in receive order.
196/// - Discard the decoder when the stream ends (`VoiceEnd` event).
197/// - The decoder is deterministic: same input sequence always produces
198/// the same output.
199#[derive(Debug, Clone)]
200pub struct AmbeDecoder {
201 /// Parameters decoded from the current frame.
202 cur: MbeParams,
203 /// Previous frame's raw parameters (prediction reference for delta
204 /// decoding of gain and spectral magnitudes).
205 prev: MbeParams,
206 /// Previous frame's enhanced parameters (cross-fade source during
207 /// harmonic synthesis, ensuring smooth transitions between frames).
208 prev_enhanced: MbeParams,
209 /// Per-stream RNG state for comfort noise output during muting.
210 comfort_noise_state: u64,
211}
212
213impl AmbeDecoder {
214 /// Creates a new decoder with zeroed initial state.
215 ///
216 /// The first decoded frame will use silence as its prediction
217 /// reference, which may produce a brief transient. This matches
218 /// the behavior of hardware DVSI vocoders.
219 #[must_use]
220 pub const fn new() -> Self {
221 Self {
222 cur: MbeParams::new(),
223 prev: MbeParams::new(),
224 prev_enhanced: MbeParams::new(),
225 comfort_noise_state: adaptive::COMFORT_NOISE_INIT_SEED,
226 }
227 }
228
229 /// Decodes a single 9-byte AMBE frame into 160 PCM samples.
230 ///
231 /// Returns 160 signed 16-bit samples at 8000 Hz (20 ms of audio).
232 /// A gain factor of 7.0 is applied and samples are clamped to
233 /// `±32767 × 0.95` to match JMBE soft-clipping semantics.
234 ///
235 /// If the frame contains excessive bit errors (more than the FEC
236 /// can correct) or the decoder has hit the maximum repeat count,
237 /// comfort noise is output instead of synthesized speech.
238 #[must_use]
239 pub fn decode_frame(&mut self, ambe: &[u8; 9]) -> [i16; FRAME_SAMPLES] {
240 let mut ambe_fr = [0u8; AMBE_FRAME_BITS];
241 let mut ambe_d = [0u8; AMBE_DATA_BITS];
242
243 // Unpack + ECC + demod pipeline.
244 unpack::unpack_frame(ambe, &mut ambe_fr);
245 let c0_errors = ecc::ecc_c0(&mut ambe_fr);
246 unpack::demodulate_c1(&mut ambe_fr);
247 let other_errors = ecc::ecc_data(&ambe_fr, &mut ambe_d);
248
249 // Error concealment: three conditions trigger "reuse previous
250 // frame's parameters + increment repeat_count" for the current
251 // frame. `repeat_count` accumulates across consecutive bad
252 // frames; sustained failures (≥3) trigger muting downstream.
253 //
254 // 1. C0-uncorrectable (Golay(23,12) exceeded its 3-error
255 // correction capacity). b0 and the other C0 data bits are
256 // untrustworthy, so decode_params shouldn't even run.
257 // 2. decode_params returned `Erasure`: b0 in 120..=123 is the
258 // AMBE codec's explicit "unrecoverable frame" signal.
259 // 3. decode_params returned `Tone`: b0 in 126..=127 signals a
260 // codec-level tone. D-STAR doesn't use codec tones (DTMF
261 // goes over slow-data), so we treat this as erasure too.
262 let mut reuse_prev = c0_errors > adaptive::GOLAY_C0_CAPACITY;
263 if !reuse_prev {
264 reuse_prev = decode::decode_params(&ambe_d, &mut self.cur, &self.prev)
265 != decode::FrameStatus::Voice;
266 }
267
268 if reuse_prev {
269 let prev_repeat = self.prev_enhanced.repeat_count;
270 self.cur.copy_from(&self.prev_enhanced);
271 self.cur.repeat_count = prev_repeat + 1;
272 } else {
273 self.cur.repeat_count = 0;
274 }
275
276 // Update error tracking for adaptive smoothing and muting.
277 // AMBE 3600x2400 has 72 raw bits.
278 #[expect(
279 clippy::cast_possible_wrap,
280 reason = "error counts are at most a few dozen; fit in i32"
281 )]
282 {
283 self.cur.error_count_total = (c0_errors + other_errors) as i32;
284 }
285 #[expect(
286 clippy::cast_precision_loss,
287 reason = "error counts are at most a few dozen; no precision loss in f32"
288 )]
289 {
290 self.cur.error_rate = self.cur.error_count_total as f32 / FRAME_BITS;
291 }
292
293 // Snapshot raw parameters as prediction reference for next frame.
294 self.prev.copy_from(&self.cur);
295
296 // Compute pre-enhancement RM0 (algorithm #111 input).
297 let pre_enhance_rm0 = (1..=self.cur.l)
298 .map(|l| {
299 let m = self.cur.ml.get(l).copied().unwrap_or(0.0);
300 m * m
301 })
302 .sum::<f32>();
303
304 // Spectral amplitude enhancement.
305 enhance::spectral_amp_enhance(&mut self.cur);
306
307 // Adaptive smoothing (JMBE algorithms #111-116).
308 adaptive::apply_adaptive_smoothing(
309 &mut self.cur,
310 &self.prev_enhanced,
311 Some(pre_enhance_rm0),
312 );
313
314 // Muting: output comfort noise instead of synthesized speech
315 // when the FEC-reported error rate exceeds the AMBE threshold.
316 // Preserves model state for next-frame recovery.
317 let muted = adaptive::requires_muting(&self.cur);
318
319 let mut pcm_f = [0.0_f32; FRAME_SAMPLES];
320 if muted {
321 adaptive::synthesize_comfort_noise(&mut pcm_f, &mut self.comfort_noise_state);
322 } else {
323 synthesize::synthesize_speech(&mut pcm_f, &mut self.cur, &mut self.prev_enhanced);
324 }
325
326 // Save enhanced parameters as cross-fade source for next frame.
327 self.prev_enhanced.copy_from(&self.cur);
328
329 float_to_i16(&pcm_f)
330 }
331}
332
333impl Default for AmbeDecoder {
334 fn default() -> Self {
335 Self::new()
336 }
337}
338
339/// Converts 160 float PCM samples to 16-bit signed integers using
340/// SIMD-vectorized gain + clamp + round.
341///
342/// Processes 4 samples per loop iteration via `wide::f32x4`. The
343/// `round_int` step uses round-to-nearest-even (vs the C reference's
344/// truncation), which produces marginally better fidelity at the cost
345/// of being one ulp different on samples exactly on a half-integer.
346fn float_to_i16(input: &[f32; FRAME_SAMPLES]) -> [i16; FRAME_SAMPLES] {
347 let mut output = [0_i16; FRAME_SAMPLES];
348
349 let gain_v = f32x4::splat(GAIN);
350 let max_v = f32x4::splat(CLAMP_MAX);
351 let min_v = f32x4::splat(-CLAMP_MAX);
352
353 // FRAME_SAMPLES (160) is divisible by 4, no scalar tail needed.
354 let mut i = 0;
355 while i + 4 <= FRAME_SAMPLES {
356 let chunk = f32x4::new([
357 input.get(i).copied().unwrap_or(0.0),
358 input.get(i + 1).copied().unwrap_or(0.0),
359 input.get(i + 2).copied().unwrap_or(0.0),
360 input.get(i + 3).copied().unwrap_or(0.0),
361 ]);
362 let scaled = chunk * gain_v;
363 let clamped = scaled.fast_min(max_v).fast_max(min_v);
364 let rounded: i32x4 = clamped.round_int();
365 let arr: [i32; 4] = rounded.into();
366 for (j, &v) in arr.iter().enumerate() {
367 #[expect(
368 clippy::cast_possible_truncation,
369 reason = "v is in i16 range due to clamp above"
370 )]
371 if let Some(slot) = output.get_mut(i + j) {
372 *slot = v as i16;
373 }
374 }
375 i += 4;
376 }
377
378 output
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 /// Float→i16 produces results bit-identical (or within 1 ULP) to
386 /// the scalar reference implementation.
387 #[test]
388 fn float_to_i16_matches_scalar() {
389 let mut input = [0.0_f32; FRAME_SAMPLES];
390 for (i, slot) in input.iter_mut().enumerate() {
391 #[expect(
392 clippy::cast_precision_loss,
393 reason = "i is at most 159; no precision loss"
394 )]
395 {
396 *slot = ((i as f32 / 80.0) - 1.0) * 5000.0;
397 }
398 }
399
400 let simd_out = float_to_i16(&input);
401
402 for (n, &got) in simd_out.iter().enumerate() {
403 let expected = (input[n] * GAIN).clamp(-CLAMP_MAX, CLAMP_MAX).round();
404 #[expect(
405 clippy::cast_possible_truncation,
406 reason = "expected is in i16 range due to clamp"
407 )]
408 let expected_i16 = expected as i16;
409 let diff = (i32::from(got) - i32::from(expected_i16)).abs();
410 assert!(
411 diff <= 1,
412 "sample {n}: got {got}, expected {expected_i16} (input={})",
413 input[n]
414 );
415 }
416 }
417
418 /// Float→i16 properly clamps values outside the valid range.
419 #[test]
420 fn float_to_i16_clamps_extremes() {
421 let mut input = [0.0_f32; FRAME_SAMPLES];
422 input[0] = 1_000_000.0;
423 input[1] = -1_000_000.0;
424 input[2] = 0.0;
425 input[3] = -0.0;
426
427 let out = float_to_i16(&input);
428 // CLAMP_MAX is 31128.65, so clamped × 7 then round → ≤ 31129.
429 assert!(
430 (31_125..=31_130).contains(&out[0]),
431 "max should clamp near 31128, got {}",
432 out[0]
433 );
434 assert!(
435 (-31_130..=-31_125).contains(&out[1]),
436 "min should clamp near -31128, got {}",
437 out[1]
438 );
439 assert_eq!(out[2], 0);
440 assert_eq!(out[3], 0);
441 }
442}