Skip to main content

embedded_dsp/
audio.rs

1//! Audio analysis: the Goertzel single-frequency detector, peak/RMS envelope followers,
2//! a Mel filterbank, and MFCC feature extraction. These are DSP front-ends (e.g. for a
3//! keyword-spotting pipeline); classifiers and neural nets live in `embedded-nn`.
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::math::isqrt_u64;
10use crate::transform::cfft_f32;
11use crate::types::{q15, Status};
12
13// --- Goertzel Single-Frequency Detector ---
14
15/// A Goertzel single-frequency detector: computes the DFT magnitude at one target frequency
16/// via a simple two-pole recursive filter, without a full FFT. Ideal for detecting a known
17/// tone (e.g. DTMF, a pilot tone) from a stream of samples on constrained hardware.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct GoertzelDetector {
20    coeff: f32,
21    s_prev: f32,
22    s_prev2: f32,
23    count: u32,
24}
25
26impl GoertzelDetector {
27    /// Creates a detector tuned to `target_freq_hz` at the given `sample_rate_hz`.
28    pub fn new(target_freq_hz: f32, sample_rate_hz: f32) -> Self {
29        let w = 2.0 * core::f32::consts::PI * target_freq_hz / sample_rate_hz;
30        Self {
31            coeff: 2.0 * w.cos(),
32            s_prev: 0.0,
33            s_prev2: 0.0,
34            count: 0,
35        }
36    }
37
38    /// Feeds one input sample into the detector.
39    #[inline(always)]
40    pub fn process_sample(&mut self, x: f32) {
41        let s = x + self.coeff * self.s_prev - self.s_prev2;
42        self.s_prev2 = self.s_prev;
43        self.s_prev = s;
44        self.count += 1;
45    }
46
47    /// Returns the magnitude of the target-frequency component accumulated so far, normalized
48    /// by the number of samples processed so it approximates the input sinusoid's amplitude
49    /// regardless of block length.
50    pub fn magnitude(&self) -> f32 {
51        if self.count == 0 {
52            return 0.0;
53        }
54        let mag_sq = self.s_prev * self.s_prev + self.s_prev2 * self.s_prev2
55            - self.coeff * self.s_prev * self.s_prev2;
56        mag_sq.max(0.0).sqrt() / (self.count as f32 / 2.0)
57    }
58
59    /// Resets the detector's internal state to start a new detection block.
60    pub fn reset(&mut self) {
61        self.s_prev = 0.0;
62        self.s_prev2 = 0.0;
63        self.count = 0;
64    }
65}
66
67/// Q15 Goertzel detector: same two-pole recurrence as [`GoertzelDetector`], with
68/// Q2.14 `2 cos(ω)` and i32 delays so a typical block (`N ≲ 256`) does not wrap.
69#[derive(Debug, Clone, Copy, Default)]
70pub struct GoertzelDetectorQ15 {
71    coeff: q15,
72    s_prev: i32,
73    s_prev2: i32,
74    count: u32,
75}
76
77impl GoertzelDetectorQ15 {
78    /// Creates a detector tuned to `target_freq_hz` at the given `sample_rate_hz`.
79    pub fn new(target_freq_hz: f32, sample_rate_hz: f32) -> Self {
80        let w = 2.0 * core::f32::consts::PI * target_freq_hz / sample_rate_hz;
81        let coeff = (2.0 * w.cos() * 16384.0).clamp(-32768.0, 32767.0) as q15;
82        Self {
83            coeff,
84            s_prev: 0,
85            s_prev2: 0,
86            count: 0,
87        }
88    }
89
90    /// Feeds one Q15 input sample into the detector.
91    #[inline(always)]
92    pub fn process_sample(&mut self, x: q15) {
93        let s = x as i32
94            + ((((self.coeff as i64) * (self.s_prev as i64)) >> 14) as i32)
95            - self.s_prev2;
96        self.s_prev2 = self.s_prev;
97        self.s_prev = s;
98        self.count += 1;
99    }
100
101    /// Magnitude of the target bin, normalized by `N/2` like [`GoertzelDetector::magnitude`].
102    pub fn magnitude(&self) -> q15 {
103        if self.count == 0 {
104            return 0;
105        }
106        let s = self.s_prev as i64;
107        let s2 = self.s_prev2 as i64;
108        let c = self.coeff as i64;
109        let mag_sq = s * s + s2 * s2 - ((c * s * s2) >> 14);
110        if mag_sq <= 0 {
111            return 0;
112        }
113        let mag = isqrt_u64(mag_sq as u64);
114        let out = (mag * 2) / (self.count as u64);
115        out.min(32767) as q15
116    }
117
118    /// Resets the detector's internal state to start a new detection block.
119    pub fn reset(&mut self) {
120        self.s_prev = 0;
121        self.s_prev2 = 0;
122        self.count = 0;
123    }
124}
125
126// --- Envelope Followers ---
127
128/// Peak envelope follower with independent attack/release time constants, as used for audio
129/// dynamics processing (compressors, limiters, VU-style level meters).
130#[derive(Debug, Clone, Copy, Default)]
131pub struct PeakEnvelopeFollower {
132    attack_coeff: f32,
133    release_coeff: f32,
134    envelope: f32,
135}
136
137impl PeakEnvelopeFollower {
138    /// `attack_samples` / `release_samples`: the time constant, in samples, for the envelope
139    /// to rise / fall `1 - 1/e` (~63%) of the way to a step change in input level.
140    pub fn new(attack_samples: f32, release_samples: f32) -> Self {
141        Self {
142            attack_coeff: 1.0 - single_pole_decay_from_time_constant(attack_samples),
143            release_coeff: 1.0 - single_pole_decay_from_time_constant(release_samples),
144            envelope: 0.0,
145        }
146    }
147
148    /// Processes one input sample and returns the updated envelope value.
149    #[inline(always)]
150    pub fn process(&mut self, x: f32) -> f32 {
151        let rectified = x.abs();
152        let coeff = if rectified > self.envelope {
153            self.attack_coeff
154        } else {
155            self.release_coeff
156        };
157        self.envelope += coeff * (rectified - self.envelope);
158        self.envelope
159    }
160
161    /// Resets the envelope to zero.
162    pub fn reset(&mut self) {
163        self.envelope = 0.0;
164    }
165}
166
167/// RMS envelope follower: a single-pole exponential moving average of instantaneous power,
168/// reported as an RMS level.
169#[derive(Debug, Clone, Copy, Default)]
170pub struct RmsEnvelopeFollower {
171    coeff: f32,
172    mean_sq: f32,
173}
174
175impl RmsEnvelopeFollower {
176    /// `time_constant_samples`: the time constant, in samples, of the underlying power
177    /// averaging filter.
178    pub fn new(time_constant_samples: f32) -> Self {
179        Self {
180            coeff: 1.0 - single_pole_decay_from_time_constant(time_constant_samples),
181            mean_sq: 0.0,
182        }
183    }
184
185    /// Processes one input sample and returns the updated RMS envelope value.
186    #[inline(always)]
187    pub fn process(&mut self, x: f32) -> f32 {
188        self.mean_sq += self.coeff * (x * x - self.mean_sq);
189        self.mean_sq.max(0.0).sqrt()
190    }
191
192    /// Resets the running mean-square to zero.
193    pub fn reset(&mut self) {
194        self.mean_sq = 0.0;
195    }
196}
197
198/// Q15 peak envelope follower (same attack/release recurrence as [`PeakEnvelopeFollower`]).
199#[derive(Debug, Clone, Copy, Default)]
200pub struct PeakEnvelopeFollowerQ15 {
201    attack_coeff: q15,
202    release_coeff: q15,
203    envelope: q15,
204}
205
206impl PeakEnvelopeFollowerQ15 {
207    pub fn new(attack_samples: f32, release_samples: f32) -> Self {
208        let attack = 1.0 - single_pole_decay_from_time_constant(attack_samples);
209        let release = 1.0 - single_pole_decay_from_time_constant(release_samples);
210        Self {
211            attack_coeff: (attack * 32767.0).clamp(0.0, 32767.0) as q15,
212            release_coeff: (release * 32767.0).clamp(0.0, 32767.0) as q15,
213            envelope: 0,
214        }
215    }
216
217    #[inline(always)]
218    pub fn process(&mut self, x: q15) -> q15 {
219        let rectified = x.unsigned_abs() as i32;
220        let env = self.envelope as i32;
221        let coeff = if rectified > env {
222            self.attack_coeff
223        } else {
224            self.release_coeff
225        } as i32;
226        let y = env + ((coeff * (rectified - env)) >> 15);
227        self.envelope = y.clamp(0, 32767) as q15;
228        self.envelope
229    }
230
231    pub fn reset(&mut self) {
232        self.envelope = 0;
233    }
234}
235
236/// Q15 RMS envelope follower.
237#[derive(Debug, Clone, Copy, Default)]
238pub struct RmsEnvelopeFollowerQ15 {
239    coeff: q15,
240    mean_sq: q15,
241}
242
243impl RmsEnvelopeFollowerQ15 {
244    pub fn new(time_constant_samples: f32) -> Self {
245        let c = 1.0 - single_pole_decay_from_time_constant(time_constant_samples);
246        Self {
247            coeff: (c * 32767.0).clamp(0.0, 32767.0) as q15,
248            mean_sq: 0,
249        }
250    }
251
252    #[inline(always)]
253    pub fn process(&mut self, x: q15) -> q15 {
254        let inst = ((x as i32 * x as i32) >> 15).clamp(0, 32767);
255        let ms = self.mean_sq as i32;
256        let y = ms + ((self.coeff as i32 * (inst - ms)) >> 15);
257        self.mean_sq = y.clamp(0, 32767) as q15;
258        let mag = isqrt_u64((self.mean_sq as u64) << 15);
259        mag.min(32767) as q15
260    }
261
262    pub fn reset(&mut self) {
263        self.mean_sq = 0;
264    }
265}
266
267// --- Mel Filterbank & MFCC ---
268
269/// Converts a frequency in Hz to the Mel scale: `2595 * log10(1 + hz / 700)`.
270pub fn hz_to_mel(hz: f32) -> f32 {
271    2595.0 * (1.0 + hz / 700.0).log10()
272}
273
274/// Converts a Mel-scale value back to Hz: `700 * (10^(mel / 2595) - 1)`.
275pub fn mel_to_hz(mel: f32) -> f32 {
276    700.0 * ((10.0f32).powf(mel / 2595.0) - 1.0)
277}
278
279/// Applies a triangular Mel filterbank to a one-sided power (or magnitude-squared) spectrum,
280/// producing one energy value per Mel band — the standard first step of MFCC / speech feature
281/// extraction.
282///
283/// `power_spectrum`: one-sided spectrum of length `fft_size / 2 + 1` (DC through Nyquist).
284/// `fft_size`: the FFT length the spectrum was computed with.
285/// `sample_rate_hz`: sampling rate in Hz.
286/// `low_freq_hz` / `high_freq_hz`: frequency range to cover with Mel bands (`0..=sample_rate/2`).
287/// `mel_energies`: destination for the output; its length sets the number of Mel filters `M`
288/// (`1..=64`).
289pub fn mel_filterbank_f32(
290    power_spectrum: &[f32],
291    fft_size: usize,
292    sample_rate_hz: f32,
293    low_freq_hz: f32,
294    high_freq_hz: f32,
295    mel_energies: &mut [f32],
296) -> Status {
297    let num_filters = mel_energies.len();
298    if num_filters == 0 || num_filters > 64 {
299        return Status::ArgumentError;
300    }
301    let num_bins = fft_size / 2 + 1;
302    if power_spectrum.len() < num_bins {
303        return Status::LengthError;
304    }
305
306    let mel_low = hz_to_mel(low_freq_hz);
307    let mel_high = hz_to_mel(high_freq_hz);
308
309    let mut bin_points = [0usize; 66];
310    for (i, bp) in bin_points.iter_mut().enumerate().take(num_filters + 2) {
311        let mel = mel_low + (mel_high - mel_low) * (i as f32) / (num_filters + 1) as f32;
312        let hz = mel_to_hz(mel);
313        let bin = (hz * fft_size as f32 / sample_rate_hz) as usize;
314        *bp = bin.min(num_bins - 1);
315    }
316
317    for (m, out) in mel_energies.iter_mut().enumerate() {
318        let left = bin_points[m];
319        let center = bin_points[m + 1];
320        let right = bin_points[m + 2];
321
322        let mut energy = 0.0f32;
323        if center > left {
324            let span = (center - left) as f32;
325            for bin in left..center {
326                energy += ((bin - left) as f32 / span) * power_spectrum[bin];
327            }
328        }
329        if right > center {
330            let span = (right - center) as f32;
331            for bin in center..=right {
332                energy += ((right - bin) as f32 / span) * power_spectrum[bin];
333            }
334        }
335        *out = energy;
336    }
337
338    Status::Success
339}
340
341/// Computes MFCC (Mel-Frequency Cepstral Coefficient) features from a single real-valued
342/// audio frame: FFT power spectrum, Mel filterbank, log compression, and a DCT-II to
343/// decorrelate the log-Mel-energies into cepstral coefficients. This is the standard
344/// speech/audio feature-extraction pipeline.
345///
346/// `frame`: `fft_size` real audio samples (already windowed by the caller, e.g. with
347/// [`crate::window::hamming_f32`] + [`crate::window::apply_window_f32`]); `fft_size` must be
348/// a power of 2, `<= 512`.
349/// `mel_energies_scratch`: scratch buffer for the intermediate Mel-filterbank output; its
350/// length sets the number of Mel filters used internally (`1..=64`).
351/// `mfcc_out`: destination for the resulting cepstral coefficients; its length sets the number
352/// of coefficients returned (typically 12-13), and must be `<= mel_energies_scratch.len()`.
353pub fn mfcc_f32(
354    frame: &[f32],
355    sample_rate_hz: f32,
356    low_freq_hz: f32,
357    high_freq_hz: f32,
358    mel_energies_scratch: &mut [f32],
359    mfcc_out: &mut [f32],
360) -> Status {
361    let fft_size = frame.len();
362    if fft_size < 2 || (fft_size & (fft_size - 1)) != 0 || 2 * fft_size > 1024 {
363        return Status::ArgumentError;
364    }
365    if mfcc_out.len() > mel_energies_scratch.len() {
366        return Status::ArgumentError;
367    }
368
369    let mut c_data = [0.0f32; 1024];
370    for (i, &x) in frame.iter().enumerate() {
371        c_data[2 * i] = x;
372        c_data[2 * i + 1] = 0.0;
373    }
374    cfft_f32(&mut c_data[..2 * fft_size], fft_size, 0, 1);
375
376    let num_bins = fft_size / 2 + 1;
377    let mut power_spectrum = [0.0f32; 513];
378    for k in 0..num_bins {
379        let re = c_data[2 * k];
380        let im = c_data[2 * k + 1];
381        power_spectrum[k] = re * re + im * im;
382    }
383
384    let status = mel_filterbank_f32(
385        &power_spectrum[..num_bins],
386        fft_size,
387        sample_rate_hz,
388        low_freq_hz,
389        high_freq_hz,
390        mel_energies_scratch,
391    );
392    if status != Status::Success {
393        return status;
394    }
395
396    for e in mel_energies_scratch.iter_mut() {
397        *e = e.max(1e-10).ln();
398    }
399
400    let num_mel = mel_energies_scratch.len() as f32;
401    for (k, out) in mfcc_out.iter_mut().enumerate() {
402        let mut sum = 0.0f32;
403        for (m, &log_e) in mel_energies_scratch.iter().enumerate() {
404            let angle = core::f32::consts::PI * k as f32 * (m as f32 + 0.5) / num_mel;
405            sum += log_e * angle.cos();
406        }
407        *out = sum;
408    }
409
410    Status::Success
411}