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}