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}