Skip to main content

embedded_dsp/
audio.rs

1//! Audio & TinyML feature extraction: the Goertzel single-frequency detector, peak/RMS
2//! envelope followers, a Mel filterbank, and MFCC feature extraction — the standard
3//! preprocessing pipeline for embedded speech/audio keyword-spotting and TinyML models.
4
5#[allow(unused_imports)]
6use crate::filter_design::single_pole_decay_from_time_constant;
7#[allow(unused_imports)]
8use crate::math::FloatMath;
9use crate::transform::cfft_f32;
10use crate::types::Status;
11
12// --- Goertzel Single-Frequency Detector ---
13
14/// A Goertzel single-frequency detector: computes the DFT magnitude at one target frequency
15/// via a simple two-pole recursive filter, without a full FFT. Ideal for detecting a known
16/// tone (e.g. DTMF, a pilot tone) from a stream of samples on constrained hardware.
17#[derive(Debug, Clone, Copy, Default)]
18pub struct GoertzelDetector {
19    coeff: f32,
20    s_prev: f32,
21    s_prev2: f32,
22    count: u32,
23}
24
25impl GoertzelDetector {
26    /// Creates a detector tuned to `target_freq_hz` at the given `sample_rate_hz`.
27    pub fn new(target_freq_hz: f32, sample_rate_hz: f32) -> Self {
28        let w = 2.0 * core::f32::consts::PI * target_freq_hz / sample_rate_hz;
29        Self {
30            coeff: 2.0 * w.cos(),
31            s_prev: 0.0,
32            s_prev2: 0.0,
33            count: 0,
34        }
35    }
36
37    /// Feeds one input sample into the detector.
38    #[inline(always)]
39    pub fn process_sample(&mut self, x: f32) {
40        let s = x + self.coeff * self.s_prev - self.s_prev2;
41        self.s_prev2 = self.s_prev;
42        self.s_prev = s;
43        self.count += 1;
44    }
45
46    /// Returns the magnitude of the target-frequency component accumulated so far, normalized
47    /// by the number of samples processed so it approximates the input sinusoid's amplitude
48    /// regardless of block length.
49    pub fn magnitude(&self) -> f32 {
50        if self.count == 0 {
51            return 0.0;
52        }
53        let mag_sq = self.s_prev * self.s_prev + self.s_prev2 * self.s_prev2
54            - self.coeff * self.s_prev * self.s_prev2;
55        mag_sq.max(0.0).sqrt() / (self.count as f32 / 2.0)
56    }
57
58    /// Resets the detector's internal state to start a new detection block.
59    pub fn reset(&mut self) {
60        self.s_prev = 0.0;
61        self.s_prev2 = 0.0;
62        self.count = 0;
63    }
64}
65
66// --- Envelope Followers ---
67
68/// Peak envelope follower with independent attack/release time constants, as used for audio
69/// dynamics processing (compressors, limiters, VU-style level meters).
70#[derive(Debug, Clone, Copy, Default)]
71pub struct PeakEnvelopeFollower {
72    attack_coeff: f32,
73    release_coeff: f32,
74    envelope: f32,
75}
76
77impl PeakEnvelopeFollower {
78    /// `attack_samples` / `release_samples`: the time constant, in samples, for the envelope
79    /// to rise / fall `1 - 1/e` (~63%) of the way to a step change in input level.
80    pub fn new(attack_samples: f32, release_samples: f32) -> Self {
81        Self {
82            attack_coeff: 1.0 - single_pole_decay_from_time_constant(attack_samples),
83            release_coeff: 1.0 - single_pole_decay_from_time_constant(release_samples),
84            envelope: 0.0,
85        }
86    }
87
88    /// Processes one input sample and returns the updated envelope value.
89    #[inline(always)]
90    pub fn process(&mut self, x: f32) -> f32 {
91        let rectified = x.abs();
92        let coeff = if rectified > self.envelope {
93            self.attack_coeff
94        } else {
95            self.release_coeff
96        };
97        self.envelope += coeff * (rectified - self.envelope);
98        self.envelope
99    }
100
101    /// Resets the envelope to zero.
102    pub fn reset(&mut self) {
103        self.envelope = 0.0;
104    }
105}
106
107/// RMS envelope follower: a single-pole exponential moving average of instantaneous power,
108/// reported as an RMS level.
109#[derive(Debug, Clone, Copy, Default)]
110pub struct RmsEnvelopeFollower {
111    coeff: f32,
112    mean_sq: f32,
113}
114
115impl RmsEnvelopeFollower {
116    /// `time_constant_samples`: the time constant, in samples, of the underlying power
117    /// averaging filter.
118    pub fn new(time_constant_samples: f32) -> Self {
119        Self {
120            coeff: 1.0 - single_pole_decay_from_time_constant(time_constant_samples),
121            mean_sq: 0.0,
122        }
123    }
124
125    /// Processes one input sample and returns the updated RMS envelope value.
126    #[inline(always)]
127    pub fn process(&mut self, x: f32) -> f32 {
128        self.mean_sq += self.coeff * (x * x - self.mean_sq);
129        self.mean_sq.max(0.0).sqrt()
130    }
131
132    /// Resets the running mean-square to zero.
133    pub fn reset(&mut self) {
134        self.mean_sq = 0.0;
135    }
136}
137
138// --- Mel Filterbank & MFCC ---
139
140/// Converts a frequency in Hz to the Mel scale: `2595 * log10(1 + hz / 700)`.
141pub fn hz_to_mel(hz: f32) -> f32 {
142    2595.0 * (1.0 + hz / 700.0).log10()
143}
144
145/// Converts a Mel-scale value back to Hz: `700 * (10^(mel / 2595) - 1)`.
146pub fn mel_to_hz(mel: f32) -> f32 {
147    700.0 * ((10.0f32).powf(mel / 2595.0) - 1.0)
148}
149
150/// Applies a triangular Mel filterbank to a one-sided power (or magnitude-squared) spectrum,
151/// producing one energy value per Mel band — the standard first step of MFCC / speech feature
152/// extraction.
153///
154/// `power_spectrum`: one-sided spectrum of length `fft_size / 2 + 1` (DC through Nyquist).
155/// `fft_size`: the FFT length the spectrum was computed with.
156/// `sample_rate_hz`: sampling rate in Hz.
157/// `low_freq_hz` / `high_freq_hz`: frequency range to cover with Mel bands (`0..=sample_rate/2`).
158/// `mel_energies`: destination for the output; its length sets the number of Mel filters `M`
159/// (`1..=64`).
160pub fn mel_filterbank_f32(
161    power_spectrum: &[f32],
162    fft_size: usize,
163    sample_rate_hz: f32,
164    low_freq_hz: f32,
165    high_freq_hz: f32,
166    mel_energies: &mut [f32],
167) -> Status {
168    let num_filters = mel_energies.len();
169    if num_filters == 0 || num_filters > 64 {
170        return Status::ArgumentError;
171    }
172    let num_bins = fft_size / 2 + 1;
173    if power_spectrum.len() < num_bins {
174        return Status::LengthError;
175    }
176
177    let mel_low = hz_to_mel(low_freq_hz);
178    let mel_high = hz_to_mel(high_freq_hz);
179
180    let mut bin_points = [0usize; 66];
181    for (i, bp) in bin_points.iter_mut().enumerate().take(num_filters + 2) {
182        let mel = mel_low + (mel_high - mel_low) * (i as f32) / (num_filters + 1) as f32;
183        let hz = mel_to_hz(mel);
184        let bin = (hz * fft_size as f32 / sample_rate_hz) as usize;
185        *bp = bin.min(num_bins - 1);
186    }
187
188    for (m, out) in mel_energies.iter_mut().enumerate() {
189        let left = bin_points[m];
190        let center = bin_points[m + 1];
191        let right = bin_points[m + 2];
192
193        let mut energy = 0.0f32;
194        if center > left {
195            let span = (center - left) as f32;
196            for bin in left..center {
197                energy += ((bin - left) as f32 / span) * power_spectrum[bin];
198            }
199        }
200        if right > center {
201            let span = (right - center) as f32;
202            for bin in center..=right {
203                energy += ((right - bin) as f32 / span) * power_spectrum[bin];
204            }
205        }
206        *out = energy;
207    }
208
209    Status::Success
210}
211
212/// Computes MFCC (Mel-Frequency Cepstral Coefficient) features from a single real-valued
213/// audio frame: FFT power spectrum, Mel filterbank, log compression, and a DCT-II to
214/// decorrelate the log-Mel-energies into cepstral coefficients. This is the standard
215/// speech/audio TinyML feature-extraction pipeline.
216///
217/// `frame`: `fft_size` real audio samples (already windowed by the caller, e.g. with
218/// [`crate::window::hamming_f32`] + [`crate::window::apply_window_f32`]); `fft_size` must be
219/// a power of 2, `<= 512`.
220/// `mel_energies_scratch`: scratch buffer for the intermediate Mel-filterbank output; its
221/// length sets the number of Mel filters used internally (`1..=64`).
222/// `mfcc_out`: destination for the resulting cepstral coefficients; its length sets the number
223/// of coefficients returned (typically 12-13), and must be `<= mel_energies_scratch.len()`.
224pub fn mfcc_f32(
225    frame: &[f32],
226    sample_rate_hz: f32,
227    low_freq_hz: f32,
228    high_freq_hz: f32,
229    mel_energies_scratch: &mut [f32],
230    mfcc_out: &mut [f32],
231) -> Status {
232    let fft_size = frame.len();
233    if fft_size < 2 || (fft_size & (fft_size - 1)) != 0 || 2 * fft_size > 1024 {
234        return Status::ArgumentError;
235    }
236    if mfcc_out.len() > mel_energies_scratch.len() {
237        return Status::ArgumentError;
238    }
239
240    let mut c_data = [0.0f32; 1024];
241    for (i, &x) in frame.iter().enumerate() {
242        c_data[2 * i] = x;
243        c_data[2 * i + 1] = 0.0;
244    }
245    cfft_f32(&mut c_data[..2 * fft_size], fft_size, 0, 1);
246
247    let num_bins = fft_size / 2 + 1;
248    let mut power_spectrum = [0.0f32; 513];
249    for k in 0..num_bins {
250        let re = c_data[2 * k];
251        let im = c_data[2 * k + 1];
252        power_spectrum[k] = re * re + im * im;
253    }
254
255    let status = mel_filterbank_f32(
256        &power_spectrum[..num_bins],
257        fft_size,
258        sample_rate_hz,
259        low_freq_hz,
260        high_freq_hz,
261        mel_energies_scratch,
262    );
263    if status != Status::Success {
264        return status;
265    }
266
267    for e in mel_energies_scratch.iter_mut() {
268        *e = e.max(1e-10).ln();
269    }
270
271    let num_mel = mel_energies_scratch.len() as f32;
272    for (k, out) in mfcc_out.iter_mut().enumerate() {
273        let mut sum = 0.0f32;
274        for (m, &log_e) in mel_energies_scratch.iter().enumerate() {
275            let angle = core::f32::consts::PI * k as f32 * (m as f32 + 0.5) / num_mel;
276            sum += log_e * angle.cos();
277        }
278        *out = sum;
279    }
280
281    Status::Success
282}