mbelib_rs/encode/
state.rs

1// SPDX-FileCopyrightText: 2009 Pavel Yazev (OP25 imbe_vocoder)
2// SPDX-FileCopyrightText: 2016 Max H. Parke KA1RBI (OP25 ambe_encoder)
3// SPDX-FileCopyrightText: 2026 Swift Raccoon
4// SPDX-License-Identifier: GPL-2.0-or-later OR GPL-3.0-or-later
5//
6// Algorithmic port from Pavel Yazev's `imbe_vocoder` (OP25, 2009,
7// GPLv3). All frame sizes and buffer layouts match that reference.
8
9//! Encoder state buffers and canonical frame dimensions.
10//!
11//! The IMBE/AMBE encoder carries per-stream state across frames —
12//! primarily the 301-sample pitch-estimation history (one past frame
13//! plus current) and the 21-tap LPF memory. This module owns those
14//! buffers plus the canonical size constants every downstream module
15//! depends on.
16
17/// One 20 ms frame at 8 kHz = 160 samples.
18pub(crate) const FRAME: usize = 160;
19
20/// Pitch-estimation history buffer length (samples).
21///
22/// Per Yazev (OP25 `imbe.h`): 301 = frame + past frame + 21-tap LPF
23/// look-behind, sized so every pitch-period candidate from 20 to 123
24/// samples fits entirely inside the buffer.
25pub(crate) const PITCH_EST_BUF_SIZE: usize = 301;
26
27/// Number of taps in the pitch-estimation lowpass FIR filter.
28pub(crate) const PE_LPF_ORD: usize = 21;
29
30/// FFT length (samples). The encoder does a single 256-point complex
31/// FFT per frame for pitch refinement and spectral analysis.
32pub(crate) const FFT_LENGTH: usize = 256;
33
34/// Pitch-refinement half-window length (samples).
35///
36/// Indices 146..256 read the window ascending, and 1..111 read it
37/// descending — total 220-sample overlap that sits centered on the
38/// current 160-sample frame inside the 256-point FFT.
39pub(crate) const WR_HALF_LEN: usize = 111;
40
41/// Per-stream encoder working buffers.
42///
43/// One instance per concurrent voice stream. Holds the sliding
44/// pitch-estimation history, the LPF delay line, and the DC-removal
45/// high-pass filter state. All are zero-initialized; the first frame
46/// processed through a fresh `EncoderBuffers` will be quieter than
47/// steady-state output while the history fills, but intelligibility
48/// is unaffected (the same transient behavior the reference encoder
49/// exhibits).
50#[derive(Debug)]
51pub struct EncoderBuffers {
52    /// Pitch estimation sample history (LPF output, wideband-removed).
53    pub(crate) pitch_est_buf: [f32; PITCH_EST_BUF_SIZE],
54    /// Pitch refinement / spectral analysis sample history
55    /// (DC-removed, no LPF).
56    pub(crate) pitch_ref_buf: [f32; PITCH_EST_BUF_SIZE],
57    /// Pitch-estimation LPF delay line (one sample per tap).
58    pub(crate) pe_lpf_mem: [f32; PE_LPF_ORD],
59    /// OP25 `dc_rmv` single-pole HPF integrator — default path.
60    #[cfg(not(feature = "kenwood-tables"))]
61    pub(crate) dc_rmv_mem: f32,
62    /// Kenwood 345 Hz biquad HPF delay line — replaces `dc_rmv_mem`
63    /// as the active input-conditioning state when the
64    /// `kenwood-tables` feature is enabled.
65    #[cfg(feature = "kenwood-tables")]
66    pub(crate) kenwood_hpf_mem: crate::encode::kenwood::filter::Biquad2State,
67}
68
69impl EncoderBuffers {
70    /// Fresh state; all buffers zero.
71    #[must_use]
72    pub const fn new() -> Self {
73        Self {
74            pitch_est_buf: [0.0; PITCH_EST_BUF_SIZE],
75            pitch_ref_buf: [0.0; PITCH_EST_BUF_SIZE],
76            pe_lpf_mem: [0.0; PE_LPF_ORD],
77            #[cfg(not(feature = "kenwood-tables"))]
78            dc_rmv_mem: 0.0,
79            #[cfg(feature = "kenwood-tables")]
80            kenwood_hpf_mem: crate::encode::kenwood::filter::Biquad2State::new(),
81        }
82    }
83
84    /// Slide both pitch buffers left by `FRAME` samples to make room
85    /// for a new frame at the tail. Mirrors the first loop of Yazev's
86    /// `imbe_vocoder::encode()`.
87    pub fn shift_pitch_history(&mut self) {
88        // Using copy_within to exactly replicate the in-place
89        // `buf[i] = buf[i + FRAME]` loop without allocating.
90        self.pitch_est_buf.copy_within(FRAME..PITCH_EST_BUF_SIZE, 0);
91        self.pitch_ref_buf.copy_within(FRAME..PITCH_EST_BUF_SIZE, 0);
92    }
93
94    /// Read-only access to the pitch-estimation history buffer.
95    ///
96    /// Diagnostic-only: exposed so the `validate_analysis_vs_op25`
97    /// example can feed the buffer into [`crate::PitchTracker::estimate`].
98    #[must_use]
99    #[doc(hidden)]
100    pub const fn pitch_est_buf(&self) -> &[f32; PITCH_EST_BUF_SIZE] {
101        &self.pitch_est_buf
102    }
103}
104
105impl Default for EncoderBuffers {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::{EncoderBuffers, FRAME, PE_LPF_ORD, PITCH_EST_BUF_SIZE};
114
115    /// Bit-exact zero comparison on f32 is legitimate for freshly
116    /// zero-initialized buffers — the values went through no
117    /// arithmetic that could introduce rounding.
118    fn is_zero(x: f32) -> bool {
119        x.to_bits() == 0 || x.to_bits() == (1u32 << 31)
120    }
121
122    #[test]
123    fn fresh_buffers_are_zero() {
124        let b = EncoderBuffers::new();
125        assert!(b.pitch_est_buf.iter().all(|&x| is_zero(x)));
126        assert!(b.pitch_ref_buf.iter().all(|&x| is_zero(x)));
127        assert!(b.pe_lpf_mem.iter().all(|&x| is_zero(x)));
128        #[cfg(not(feature = "kenwood-tables"))]
129        assert!(is_zero(b.dc_rmv_mem));
130    }
131
132    /// After one shift, content at position `p >= FRAME` moves to
133    /// position `p - FRAME`. Verifies the buffer slides correctly.
134    /// Note that `2 * FRAME > PITCH_EST_BUF_SIZE` so we can't verify
135    /// "the last frame lands in the second-to-last frame position"
136    /// directly — instead we check the sliding identity.
137    #[test]
138    fn shift_moves_content_by_frame() {
139        let mut b = EncoderBuffers::new();
140        // Fill with position-indexed values so we can identify where
141        // each sample ends up after the shift.
142        #[allow(clippy::cast_precision_loss)]
143        for i in 0..PITCH_EST_BUF_SIZE {
144            b.pitch_est_buf[i] = i as f32;
145            b.pitch_ref_buf[i] = i as f32;
146        }
147        b.shift_pitch_history();
148        // After shift: position `i` should now hold what was at
149        // `i + FRAME` (for `i + FRAME < PITCH_EST_BUF_SIZE`).
150        for i in 0..(PITCH_EST_BUF_SIZE - FRAME) {
151            #[allow(clippy::cast_precision_loss)]
152            let expected = (i + FRAME) as f32;
153            let got = b.pitch_est_buf[i];
154            assert!(
155                (got - expected).abs() < f32::EPSILON,
156                "pitch_est_buf[{i}] = {got}, expected {expected}",
157            );
158        }
159    }
160
161    #[test]
162    fn canonical_sizes_match_reference() {
163        // Yazev's `imbe.h` hard-codes these. Downstream DSP assumes
164        // them; regressions here would silently corrupt the frame
165        // pipeline.
166        assert_eq!(FRAME, 160);
167        assert_eq!(PITCH_EST_BUF_SIZE, 301);
168        assert_eq!(PE_LPF_ORD, 21);
169    }
170}