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, Q8F7, 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/// Q2.14 fixed-point type for coefficients that can range up to `±2.0`
68/// (e.g. `2 cos(ω)`), which does not fit `q15`'s `[-1.0, 1.0)` range.
69type Q2F14 = fixed::FixedI16<fixed::types::extra::U14>;
70
71/// Q15 Goertzel detector: same two-pole recurrence as [`GoertzelDetector`], with
72/// Q2.14 `2 cos(ω)` and i32 delays so a typical block (`N ≲ 256`) does not wrap.
73#[derive(Debug, Clone, Copy, Default)]
74pub struct GoertzelDetectorQ15 {
75    coeff: Q2F14,
76    s_prev: i32,
77    s_prev2: i32,
78    count: u32,
79}
80
81impl GoertzelDetectorQ15 {
82    /// Creates a detector tuned to `target_freq_hz` at the given `sample_rate_hz`.
83    pub fn new(target_freq_hz: f32, sample_rate_hz: f32) -> Self {
84        let w = 2.0 * core::f32::consts::PI * target_freq_hz / sample_rate_hz;
85        let coeff = Q2F14::saturating_from_num(2.0 * w.cos());
86        Self {
87            coeff,
88            s_prev: 0,
89            s_prev2: 0,
90            count: 0,
91        }
92    }
93
94    /// Feeds one Q15 input sample into the detector.
95    #[inline(always)]
96    pub fn process_sample(&mut self, x: q15) {
97        let s = x.to_bits() as i32
98            + ((((self.coeff.to_bits() as i64) * (self.s_prev as i64)) >> 14) as i32)
99            - self.s_prev2;
100        self.s_prev2 = self.s_prev;
101        self.s_prev = s;
102        self.count += 1;
103    }
104
105    /// Magnitude of the target bin, normalized by `N/2` like [`GoertzelDetector::magnitude`].
106    pub fn magnitude(&self) -> q15 {
107        if self.count == 0 {
108            return q15::ZERO;
109        }
110        let s = self.s_prev as i64;
111        let s2 = self.s_prev2 as i64;
112        let c = self.coeff.to_bits() as i64;
113        let mag_sq = s * s + s2 * s2 - ((c * s * s2) >> 14);
114        if mag_sq <= 0 {
115            return q15::ZERO;
116        }
117        let mag = isqrt_u64(mag_sq as u64);
118        let out = (mag * 2) / (self.count as u64);
119        q15::from_bits(out.min(32767) as i16)
120    }
121
122    /// Resets the detector's internal state to start a new detection block.
123    pub fn reset(&mut self) {
124        self.s_prev = 0;
125        self.s_prev2 = 0;
126        self.count = 0;
127    }
128}
129
130// --- Envelope Followers ---
131
132/// Peak envelope follower with independent attack/release time constants, as used for audio
133/// dynamics processing (compressors, limiters, VU-style level meters).
134#[derive(Debug, Clone, Copy, Default)]
135pub struct PeakEnvelopeFollower {
136    attack_coeff: f32,
137    release_coeff: f32,
138    envelope: f32,
139}
140
141impl PeakEnvelopeFollower {
142    /// `attack_samples` / `release_samples`: the time constant, in samples, for the envelope
143    /// to rise / fall `1 - 1/e` (~63%) of the way to a step change in input level.
144    pub fn new(attack_samples: f32, release_samples: f32) -> Self {
145        Self {
146            attack_coeff: 1.0 - single_pole_decay_from_time_constant(attack_samples),
147            release_coeff: 1.0 - single_pole_decay_from_time_constant(release_samples),
148            envelope: 0.0,
149        }
150    }
151
152    /// Processes one input sample and returns the updated envelope value.
153    #[inline(always)]
154    pub fn process(&mut self, x: f32) -> f32 {
155        let rectified = x.abs();
156        let coeff = if rectified > self.envelope {
157            self.attack_coeff
158        } else {
159            self.release_coeff
160        };
161        self.envelope += coeff * (rectified - self.envelope);
162        self.envelope
163    }
164
165    /// Resets the envelope to zero.
166    pub fn reset(&mut self) {
167        self.envelope = 0.0;
168    }
169}
170
171/// RMS envelope follower: a single-pole exponential moving average of instantaneous power,
172/// reported as an RMS level.
173#[derive(Debug, Clone, Copy, Default)]
174pub struct RmsEnvelopeFollower {
175    coeff: f32,
176    mean_sq: f32,
177}
178
179impl RmsEnvelopeFollower {
180    /// `time_constant_samples`: the time constant, in samples, of the underlying power
181    /// averaging filter.
182    pub fn new(time_constant_samples: f32) -> Self {
183        Self {
184            coeff: 1.0 - single_pole_decay_from_time_constant(time_constant_samples),
185            mean_sq: 0.0,
186        }
187    }
188
189    /// Processes one input sample and returns the updated RMS envelope value.
190    #[inline(always)]
191    pub fn process(&mut self, x: f32) -> f32 {
192        self.mean_sq += self.coeff * (x * x - self.mean_sq);
193        self.mean_sq.max(0.0).sqrt()
194    }
195
196    /// Resets the running mean-square to zero.
197    pub fn reset(&mut self) {
198        self.mean_sq = 0.0;
199    }
200}
201
202/// Q15 peak envelope follower (same attack/release recurrence as [`PeakEnvelopeFollower`]).
203#[derive(Debug, Clone, Copy, Default)]
204pub struct PeakEnvelopeFollowerQ15 {
205    attack_coeff: q15,
206    release_coeff: q15,
207    envelope: q15,
208}
209
210impl PeakEnvelopeFollowerQ15 {
211    pub fn new(attack_samples: f32, release_samples: f32) -> Self {
212        let attack = 1.0 - single_pole_decay_from_time_constant(attack_samples);
213        let release = 1.0 - single_pole_decay_from_time_constant(release_samples);
214        Self {
215            attack_coeff: q15::saturating_from_num(attack),
216            release_coeff: q15::saturating_from_num(release),
217            envelope: q15::ZERO,
218        }
219    }
220
221    #[inline(always)]
222    pub fn process(&mut self, x: q15) -> q15 {
223        let rectified = x.to_bits().unsigned_abs() as i32;
224        let env = self.envelope.to_bits() as i32;
225        let coeff = if rectified > env {
226            self.attack_coeff
227        } else {
228            self.release_coeff
229        }
230        .to_bits() as i32;
231        let y = env + ((coeff * (rectified - env)) >> 15);
232        self.envelope = q15::from_bits(y.clamp(0, 32767) as i16);
233        self.envelope
234    }
235
236    pub fn reset(&mut self) {
237        self.envelope = q15::ZERO;
238    }
239}
240
241/// Q15 RMS envelope follower.
242#[derive(Debug, Clone, Copy, Default)]
243pub struct RmsEnvelopeFollowerQ15 {
244    coeff: q15,
245    mean_sq: q15,
246}
247
248impl RmsEnvelopeFollowerQ15 {
249    pub fn new(time_constant_samples: f32) -> Self {
250        let c = 1.0 - single_pole_decay_from_time_constant(time_constant_samples);
251        Self {
252            coeff: q15::saturating_from_num(c),
253            mean_sq: q15::ZERO,
254        }
255    }
256
257    #[inline(always)]
258    pub fn process(&mut self, x: q15) -> q15 {
259        let inst = ((x.to_bits() as i32 * x.to_bits() as i32) >> 15).clamp(0, 32767);
260        let ms = self.mean_sq.to_bits() as i32;
261        let y = ms + ((self.coeff.to_bits() as i32 * (inst - ms)) >> 15);
262        self.mean_sq = q15::from_bits(y.clamp(0, 32767) as i16);
263        let mag = isqrt_u64((self.mean_sq.to_bits() as u64) << 15);
264        q15::from_bits(mag.min(32767) as i16)
265    }
266
267    pub fn reset(&mut self) {
268        self.mean_sq = q15::ZERO;
269    }
270}
271
272// --- Mel Filterbank & MFCC ---
273
274/// Converts a frequency in Hz to the Mel scale: `2595 * log10(1 + hz / 700)`.
275pub fn hz_to_mel(hz: f32) -> f32 {
276    2595.0 * (1.0 + hz / 700.0).log10()
277}
278
279/// Converts a Mel-scale value back to Hz: `700 * (10^(mel / 2595) - 1)`.
280pub fn mel_to_hz(mel: f32) -> f32 {
281    700.0 * ((10.0f32).powf(mel / 2595.0) - 1.0)
282}
283
284/// Applies a triangular Mel filterbank to a one-sided power (or magnitude-squared) spectrum,
285/// producing one energy value per Mel band — the standard first step of MFCC / speech feature
286/// extraction.
287///
288/// `power_spectrum`: one-sided spectrum of length `fft_size / 2 + 1` (DC through Nyquist).
289/// `fft_size`: the FFT length the spectrum was computed with.
290/// `sample_rate_hz`: sampling rate in Hz.
291/// `low_freq_hz` / `high_freq_hz`: frequency range to cover with Mel bands (`0..=sample_rate/2`).
292/// `mel_energies`: destination for the output; its length sets the number of Mel filters `M`
293/// (`1..=64`).
294pub fn mel_filterbank_f32(
295    power_spectrum: &[f32],
296    fft_size: usize,
297    sample_rate_hz: f32,
298    low_freq_hz: f32,
299    high_freq_hz: f32,
300    mel_energies: &mut [f32],
301) -> Status {
302    let num_filters = mel_energies.len();
303    if num_filters == 0 || num_filters > 64 {
304        return Status::ArgumentError;
305    }
306    let num_bins = fft_size / 2 + 1;
307    if power_spectrum.len() < num_bins {
308        return Status::LengthError;
309    }
310
311    let mel_low = hz_to_mel(low_freq_hz);
312    let mel_high = hz_to_mel(high_freq_hz);
313
314    let mut bin_points = [0usize; 66];
315    for (i, bp) in bin_points.iter_mut().enumerate().take(num_filters + 2) {
316        let mel = mel_low + (mel_high - mel_low) * (i as f32) / (num_filters + 1) as f32;
317        let hz = mel_to_hz(mel);
318        let bin = (hz * fft_size as f32 / sample_rate_hz) as usize;
319        *bp = bin.min(num_bins - 1);
320    }
321
322    for (m, out) in mel_energies.iter_mut().enumerate() {
323        let left = bin_points[m];
324        let center = bin_points[m + 1];
325        let right = bin_points[m + 2];
326
327        let mut energy = 0.0f32;
328        if center > left {
329            let span = (center - left) as f32;
330            for bin in left..center {
331                energy += ((bin - left) as f32 / span) * power_spectrum[bin];
332            }
333        }
334        if right > center {
335            let span = (right - center) as f32;
336            for bin in center..=right {
337                energy += ((right - bin) as f32 / span) * power_spectrum[bin];
338            }
339        }
340        *out = energy;
341    }
342
343    Status::Success
344}
345
346/// Computes MFCC (Mel-Frequency Cepstral Coefficient) features from a single real-valued
347/// audio frame: FFT power spectrum, Mel filterbank, log compression, and a DCT-II to
348/// decorrelate the log-Mel-energies into cepstral coefficients. This is the standard
349/// speech/audio feature-extraction pipeline.
350///
351/// `frame`: `fft_size` real audio samples (already windowed by the caller, e.g. with
352/// [`crate::window::hamming_f32`] + [`crate::window::apply_window_f32`]); `fft_size` must be
353/// a power of 2, `<= 512`.
354/// `mel_energies_scratch`: scratch buffer for the intermediate Mel-filterbank output; its
355/// length sets the number of Mel filters used internally (`1..=64`).
356/// `mfcc_out`: destination for the resulting cepstral coefficients; its length sets the number
357/// of coefficients returned (typically 12-13), and must be `<= mel_energies_scratch.len()`.
358pub fn mfcc_f32(
359    frame: &[f32],
360    sample_rate_hz: f32,
361    low_freq_hz: f32,
362    high_freq_hz: f32,
363    mel_energies_scratch: &mut [f32],
364    mfcc_out: &mut [f32],
365) -> Status {
366    let fft_size = frame.len();
367    if fft_size < 2 || (fft_size & (fft_size - 1)) != 0 || 2 * fft_size > 1024 {
368        return Status::ArgumentError;
369    }
370    if mfcc_out.len() > mel_energies_scratch.len() {
371        return Status::ArgumentError;
372    }
373
374    let mut c_data = [0.0f32; 1024];
375    for (i, &x) in frame.iter().enumerate() {
376        c_data[2 * i] = x;
377        c_data[2 * i + 1] = 0.0;
378    }
379    cfft_f32(&mut c_data[..2 * fft_size], fft_size, 0, 1);
380
381    let num_bins = fft_size / 2 + 1;
382    let mut power_spectrum = [0.0f32; 513];
383    for k in 0..num_bins {
384        let re = c_data[2 * k];
385        let im = c_data[2 * k + 1];
386        power_spectrum[k] = re * re + im * im;
387    }
388
389    let status = mel_filterbank_f32(
390        &power_spectrum[..num_bins],
391        fft_size,
392        sample_rate_hz,
393        low_freq_hz,
394        high_freq_hz,
395        mel_energies_scratch,
396    );
397    if status != Status::Success {
398        return status;
399    }
400
401    for e in mel_energies_scratch.iter_mut() {
402        *e = e.max(1e-10).ln();
403    }
404
405    let num_mel = mel_energies_scratch.len() as f32;
406    for (k, out) in mfcc_out.iter_mut().enumerate() {
407        let mut sum = 0.0f32;
408        for (m, &log_e) in mel_energies_scratch.iter().enumerate() {
409            let angle = core::f32::consts::PI * k as f32 * (m as f32 + 0.5) / num_mel;
410            sum += log_e * angle.cos();
411        }
412        *out = sum;
413    }
414
415    Status::Success
416}
417
418// ─────────────────────────────────────────────────────────────────────────────
419// Generalized Filterbank & Fixed-Point Feature Extraction
420// ─────────────────────────────────────────────────────────────────────────────
421
422/// Generalized triangular filterbank applicable to any spectral scale (Linear, Octave, Constant-Q, Bark, Mel).
423///
424/// Integrates `power_spectrum` against triangular weighting windows defined by parallel slices
425/// of `left_bins`, `center_bins`, and `right_bins`.
426pub fn generalized_triangular_filterbank(
427    power_spectrum: &[f32],
428    left_bins: &[usize],
429    center_bins: &[usize],
430    right_bins: &[usize],
431    energies_out: &mut [f32],
432) -> Status {
433    let num_filters = energies_out.len();
434    if left_bins.len() < num_filters
435        || center_bins.len() < num_filters
436        || right_bins.len() < num_filters
437        || num_filters == 0
438    {
439        return Status::LengthError;
440    }
441
442    for (i, energy) in energies_out.iter_mut().enumerate() {
443        let left = left_bins[i];
444        let center = center_bins[i];
445        let right = right_bins[i];
446
447        if left > center || center > right || right >= power_spectrum.len() {
448            return Status::ArgumentError;
449        }
450
451        let mut sum = 0.0f32;
452        if center > left {
453            let span = (center - left) as f32;
454            for bin in left..=center {
455                let weight = (bin - left) as f32 / span;
456                sum += weight * power_spectrum[bin];
457            }
458        }
459        if right > center {
460            let span = (right - center) as f32;
461            for bin in (center + 1)..=right {
462                let weight = (right - bin) as f32 / span;
463                sum += weight * power_spectrum[bin];
464            }
465        }
466        *energy = sum;
467    }
468
469    Status::Success
470}
471
472/// Fast integer base-2 logarithm approximation using leading zeros.
473///
474/// Input is a positive Q15 number (`(0, 32767]`).
475/// Returns `log2(x)` scaled to Q8.7 format.
476#[inline]
477pub fn fast_log2_q15(x: q15) -> Q8F7 {
478    if x <= q15::ZERO {
479        return Q8F7::MIN;
480    }
481    let lz = (x.to_bits() as u16).leading_zeros() as i32;
482    // Integer part of log2 is 15 - lz
483    let int_part = 14 - lz;
484    // Fractional part via linear interpolation of remainder bits
485    let shifted = (x.to_bits() as i32) << lz;
486    let frac = (shifted & 0x7FFF) >> 8; // top 7 bits of fraction
487    let log_val = (int_part << 7) + frac;
488    Q8F7::from_bits((log_val - (15 << 7)).clamp(i16::MIN as i32, i16::MAX as i32) as i16)
489}
490
491/// Simple Voice Activity Detector (VAD) in pure Q15 integer arithmetic.
492///
493/// Combines Short-Time Energy (STE) and Zero-Crossing Rate (ZCR) thresholds to classify frames
494/// as speech/activity vs background noise.
495#[derive(Debug, Clone, Copy)]
496pub struct VadDetectorQ15 {
497    energy_threshold: i32,
498    zcr_threshold: u16,
499}
500
501impl VadDetectorQ15 {
502    /// Create a new VAD detector with energy and zero-crossing rate thresholds.
503    pub const fn new(energy_threshold: i32, zcr_threshold: u16) -> Self {
504        Self {
505            energy_threshold,
506            zcr_threshold,
507        }
508    }
509
510    /// Classify frame as active (`true`) or silence/noise (`false`).
511    pub fn is_active(&self, frame: &[q15]) -> bool {
512        if frame.is_empty() {
513            return false;
514        }
515
516        let mut energy_acc: i64 = 0;
517        let mut zcr_count: u16 = 0;
518
519        for i in 0..frame.len() {
520            let sample = frame[i].to_bits() as i64;
521            energy_acc += (sample * sample) >> 15;
522
523            if i > 0 {
524                let prev = frame[i - 1];
525                let cur = frame[i];
526                if (prev >= 0 && cur < 0) || (prev < 0 && cur >= 0) {
527                    zcr_count += 1;
528                }
529            }
530        }
531
532        let avg_energy = (energy_acc / frame.len() as i64) as i32;
533        avg_energy >= self.energy_threshold && zcr_count >= self.zcr_threshold
534    }
535}