Skip to main content

resonant_analysis/
mfcc.rs

1//! Mel-frequency cepstral coefficients (MFCCs).
2//!
3//! MFCCs are a compact representation of the spectral envelope, widely used in
4//! speech and music analysis. This module computes per-frame MFCCs from raw
5//! audio via STFT → mel filterbank → log energy → DCT-II, and provides delta
6//! and delta-delta (acceleration) coefficients.
7
8extern crate alloc;
9
10use alloc::vec;
11use alloc::vec::Vec;
12
13use resonant_core::signal::Signal;
14use resonant_core::window;
15use resonant_fft::dct;
16use resonant_fft::stft::Stft;
17use resonant_fft::SignalFreqExt;
18
19use crate::error::AnalysisError;
20use crate::mel::{apply_mel_filterbank, build_mel_filterbank, log_mel_energy};
21
22/// A single frame of MFCC coefficients.
23///
24/// # Examples
25///
26/// ```
27/// use resonant_analysis::mfcc::MfccFrame;
28///
29/// let frame = MfccFrame { coefficients: vec![1.0, 0.5, -0.3] };
30/// assert_eq!(frame.coefficients.len(), 3);
31/// ```
32#[derive(Debug, Clone, PartialEq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub struct MfccFrame {
35    /// The MFCC coefficients for this frame.
36    pub coefficients: Vec<f32>,
37}
38
39/// MFCC extractor with configurable parameters.
40///
41/// # Examples
42///
43/// ```
44/// use resonant_analysis::mfcc::MfccExtractor;
45///
46/// let extractor = MfccExtractor::new(44100.0);
47/// let samples = vec![0.0_f32; 4096];
48/// let frames = extractor.extract(&samples).unwrap();
49/// // Each frame has 13 coefficients by default
50/// for f in &frames {
51///     assert_eq!(f.coefficients.len(), 13);
52/// }
53/// ```
54#[derive(Debug, Clone)]
55pub struct MfccExtractor {
56    sample_rate: f32,
57    num_coefficients: usize,
58    num_mel_bands: usize,
59    window_size: usize,
60    hop_size: usize,
61}
62
63impl MfccExtractor {
64    /// Creates an extractor with default parameters.
65    ///
66    /// Defaults: 13 coefficients, 26 mel bands, window 1024, hop 512.
67    #[must_use]
68    pub fn new(sample_rate: f32) -> Self {
69        Self {
70            sample_rate,
71            num_coefficients: 13,
72            num_mel_bands: 26,
73            window_size: 1024,
74            hop_size: 512,
75        }
76    }
77
78    /// Sets the number of MFCC coefficients to keep per frame.
79    #[must_use]
80    pub fn with_num_coefficients(mut self, n: usize) -> Self {
81        self.num_coefficients = n;
82        self
83    }
84
85    /// Sets the number of mel filterbank bands.
86    #[must_use]
87    pub fn with_num_mel_bands(mut self, n: usize) -> Self {
88        self.num_mel_bands = n;
89        self
90    }
91
92    /// Sets the STFT window size (should be a power of two).
93    #[must_use]
94    pub fn with_window_size(mut self, size: usize) -> Self {
95        self.window_size = size;
96        self
97    }
98
99    /// Sets the STFT hop size.
100    #[must_use]
101    pub fn with_hop_size(mut self, hop: usize) -> Self {
102        self.hop_size = hop;
103        self
104    }
105
106    /// Extracts MFCC frames from mono audio samples.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`AnalysisError::EmptyInput`] if `samples` is empty, or
111    /// [`AnalysisError::InvalidParameter`] if parameters are inconsistent.
112    pub fn extract(&self, samples: &[f32]) -> Result<Vec<MfccFrame>, AnalysisError> {
113        if samples.is_empty() {
114            return Err(AnalysisError::EmptyInput);
115        }
116        if self.num_coefficients == 0 || self.num_mel_bands == 0 {
117            return Err(AnalysisError::InvalidParameter {
118                name: "num_coefficients/num_mel_bands",
119                reason: "must be positive",
120            });
121        }
122        if self.num_coefficients > self.num_mel_bands {
123            return Err(AnalysisError::InvalidParameter {
124                name: "num_coefficients",
125                reason: "must not exceed num_mel_bands",
126            });
127        }
128
129        // If signal is shorter than one window, return a single zero frame
130        if samples.len() < self.window_size {
131            return Ok(vec![MfccFrame {
132                coefficients: vec![0.0; self.num_coefficients],
133            }]);
134        }
135
136        let signal = Signal::from_samples(samples.to_vec());
137        let stft = Stft::builder(self.window_size, self.hop_size)
138            .window_fn(window::hann)
139            .build();
140
141        let stft_frames = stft.analyze(&signal)?;
142        if stft_frames.is_empty() {
143            return Ok(vec![MfccFrame {
144                coefficients: vec![0.0; self.num_coefficients],
145            }]);
146        }
147
148        // Build mel filterbank (FFT bins → mel bands)
149        let fft_size = self.window_size;
150        let filterbank = build_mel_filterbank(self.num_mel_bands, fft_size, self.sample_rate);
151
152        let mut frames = Vec::with_capacity(stft_frames.len());
153
154        for stft_frame in &stft_frames {
155            let magnitudes = stft_frame.magnitude();
156            let mel_energies = apply_mel_filterbank(&magnitudes, &filterbank);
157            let log_mel = log_mel_energy(&mel_energies);
158
159            // DCT-II of log mel energies, then keep first num_coefficients
160            let mut dct_out = vec![0.0_f32; self.num_mel_bands];
161            dct::dct_ii(&log_mel, &mut dct_out)?;
162
163            let coefficients = dct_out[..self.num_coefficients].to_vec();
164            frames.push(MfccFrame { coefficients });
165        }
166
167        Ok(frames)
168    }
169
170    /// Computes delta (first derivative) coefficients from MFCC frames.
171    ///
172    /// Uses a regression window of `width` frames on each side.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`AnalysisError::EmptyInput`] if `frames` is empty, or
177    /// [`AnalysisError::InvalidParameter`] if `width` is zero.
178    pub fn deltas(frames: &[MfccFrame], width: usize) -> Result<Vec<MfccFrame>, AnalysisError> {
179        if frames.is_empty() {
180            return Err(AnalysisError::EmptyInput);
181        }
182        if width == 0 {
183            return Err(AnalysisError::InvalidParameter {
184                name: "width",
185                reason: "must be positive",
186            });
187        }
188
189        let n = frames.len();
190        let num_coeffs = frames[0].coefficients.len();
191        let mut result = Vec::with_capacity(n);
192
193        // Denominator: 2 * Σ(w² for w in 1..=width)
194        let denom: f32 = 2.0 * (1..=width).map(|w| (w * w) as f32).sum::<f32>();
195
196        for t in 0..n {
197            let mut coefficients = vec![0.0_f32; num_coeffs];
198            if denom > f32::EPSILON {
199                for w in 1..=width {
200                    // Clamp indices to valid range (edge padding)
201                    let prev = t.saturating_sub(w);
202                    let next = (t + w).min(n - 1);
203                    for (c, coeff) in coefficients.iter_mut().enumerate() {
204                        *coeff += w as f32
205                            * (frames[next].coefficients[c] - frames[prev].coefficients[c]);
206                    }
207                }
208                for c in &mut coefficients {
209                    *c /= denom;
210                }
211            }
212            result.push(MfccFrame { coefficients });
213        }
214
215        Ok(result)
216    }
217
218    /// Computes delta-delta (second derivative / acceleration) coefficients.
219    ///
220    /// Equivalent to applying [`deltas`](Self::deltas) twice.
221    ///
222    /// # Errors
223    ///
224    /// Same as [`deltas`](Self::deltas).
225    pub fn delta_deltas(
226        frames: &[MfccFrame],
227        width: usize,
228    ) -> Result<Vec<MfccFrame>, AnalysisError> {
229        let d = Self::deltas(frames, width)?;
230        Self::deltas(&d, width)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use core::f32::consts::PI;
238
239    const SR: f32 = 44100.0;
240
241    #[test]
242    fn silence_near_zero_mfccs() {
243        let samples = vec![0.0_f32; 4096];
244        let extractor = MfccExtractor::new(SR);
245        let frames = extractor.extract(&samples).unwrap();
246        for frame in &frames {
247            assert_eq!(frame.coefficients.len(), 13);
248            // All coefficients should be based on the log floor, roughly constant
249            // The key point: no NaN or Inf
250            for &c in &frame.coefficients {
251                assert!(c.is_finite(), "non-finite MFCC: {c}");
252            }
253        }
254    }
255
256    #[test]
257    fn extract_basic_sine() {
258        let samples: Vec<f32> = (0..8192)
259            .map(|i| (2.0 * PI * 440.0 * i as f32 / SR).sin())
260            .collect();
261        let extractor = MfccExtractor::new(SR);
262        let frames = extractor.extract(&samples).unwrap();
263        assert!(!frames.is_empty());
264        for frame in &frames {
265            assert_eq!(frame.coefficients.len(), 13);
266            for &c in &frame.coefficients {
267                assert!(c.is_finite(), "non-finite MFCC: {c}");
268            }
269        }
270    }
271
272    #[test]
273    fn empty_input_error() {
274        let extractor = MfccExtractor::new(SR);
275        assert_eq!(extractor.extract(&[]), Err(AnalysisError::EmptyInput));
276    }
277
278    #[test]
279    fn zero_coefficients_error() {
280        let extractor = MfccExtractor::new(SR).with_num_coefficients(0);
281        let result = extractor.extract(&[1.0; 2048]);
282        assert!(matches!(
283            result,
284            Err(AnalysisError::InvalidParameter { .. })
285        ));
286    }
287
288    #[test]
289    fn coefficients_exceed_bands_error() {
290        let extractor = MfccExtractor::new(SR)
291            .with_num_coefficients(30)
292            .with_num_mel_bands(13);
293        let result = extractor.extract(&[1.0; 2048]);
294        assert!(matches!(
295            result,
296            Err(AnalysisError::InvalidParameter { .. })
297        ));
298    }
299
300    #[test]
301    fn short_signal_returns_zero_frame() {
302        let extractor = MfccExtractor::new(SR).with_window_size(1024);
303        let frames = extractor.extract(&[1.0; 512]).unwrap();
304        assert_eq!(frames.len(), 1);
305        assert!(frames[0].coefficients.iter().all(|&c| c == 0.0));
306    }
307
308    #[test]
309    fn delta_of_constant_is_zero() {
310        let frames: Vec<MfccFrame> = (0..10)
311            .map(|_| MfccFrame {
312                coefficients: vec![1.0, 2.0, 3.0],
313            })
314            .collect();
315        let deltas = MfccExtractor::deltas(&frames, 2).unwrap();
316        assert_eq!(deltas.len(), 10);
317        for d in &deltas {
318            for &c in &d.coefficients {
319                assert!(c.abs() < 1e-6, "delta of constant should be zero, got {c}");
320            }
321        }
322    }
323
324    #[test]
325    fn delta_of_linear_ramp() {
326        // If coefficients increase linearly frame-to-frame, delta should be constant
327        let frames: Vec<MfccFrame> = (0..10)
328            .map(|i| MfccFrame {
329                coefficients: vec![i as f32],
330            })
331            .collect();
332        let deltas = MfccExtractor::deltas(&frames, 1).unwrap();
333        // Interior frames (not edge-padded) should have delta ≈ 1.0
334        for d in &deltas[1..9] {
335            assert!(
336                (d.coefficients[0] - 1.0).abs() < 1e-4,
337                "expected delta ~1.0, got {}",
338                d.coefficients[0]
339            );
340        }
341    }
342
343    #[test]
344    fn delta_empty_error() {
345        let result = MfccExtractor::deltas(&[], 2);
346        assert_eq!(result, Err(AnalysisError::EmptyInput));
347    }
348
349    #[test]
350    fn delta_zero_width_error() {
351        let frames = vec![MfccFrame {
352            coefficients: vec![1.0],
353        }];
354        let result = MfccExtractor::deltas(&frames, 0);
355        assert!(matches!(
356            result,
357            Err(AnalysisError::InvalidParameter { .. })
358        ));
359    }
360
361    #[test]
362    fn delta_delta_of_constant_is_zero() {
363        let frames: Vec<MfccFrame> = (0..10)
364            .map(|_| MfccFrame {
365                coefficients: vec![5.0, 3.0],
366            })
367            .collect();
368        let dd = MfccExtractor::delta_deltas(&frames, 2).unwrap();
369        for d in &dd {
370            for &c in &d.coefficients {
371                assert!(c.abs() < 1e-6, "delta-delta of constant should be zero");
372            }
373        }
374    }
375
376    #[test]
377    fn no_nan_or_inf_in_output() {
378        // Pseudo-random signal
379        let samples: Vec<f32> = (0..8192)
380            .map(|i| (i as f32 * 7.3).sin() * (i as f32 * 13.7).cos())
381            .collect();
382        let extractor = MfccExtractor::new(SR);
383        let frames = extractor.extract(&samples).unwrap();
384        for frame in &frames {
385            for &c in &frame.coefficients {
386                assert!(c.is_finite(), "non-finite MFCC in noisy signal: {c}");
387            }
388        }
389    }
390
391    #[test]
392    fn builder_methods() {
393        let e = MfccExtractor::new(SR)
394            .with_num_coefficients(20)
395            .with_num_mel_bands(40)
396            .with_window_size(2048)
397            .with_hop_size(1024);
398        assert_eq!(e.num_coefficients, 20);
399        assert_eq!(e.num_mel_bands, 40);
400        assert_eq!(e.window_size, 2048);
401        assert_eq!(e.hop_size, 1024);
402    }
403
404    #[test]
405    fn custom_num_coefficients() {
406        let samples: Vec<f32> = (0..4096)
407            .map(|i| (2.0 * PI * 440.0 * i as f32 / SR).sin())
408            .collect();
409        let extractor = MfccExtractor::new(SR).with_num_coefficients(20);
410        let frames = extractor.extract(&samples).unwrap();
411        for frame in &frames {
412            assert_eq!(frame.coefficients.len(), 20);
413        }
414    }
415
416    #[cfg(feature = "serde")]
417    #[test]
418    fn mfcc_frame_serde_roundtrip() {
419        let frame = MfccFrame {
420            coefficients: vec![1.0, -0.5, 0.3, 0.0],
421        };
422        let json =
423            serde_json::to_string(&frame).unwrap_or_else(|e| panic!("serialize MfccFrame: {e}"));
424        let back: MfccFrame =
425            serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialize MfccFrame: {e}"));
426        assert_eq!(frame, back);
427    }
428}