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, Q2F14, 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/// 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: Q2F14,
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 = Q2F14::saturating_from_num(2.0 * w.cos());
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.to_bits() as i32
94            + ((((self.coeff.to_bits() 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 q15::ZERO;
105        }
106        let s = self.s_prev as i64;
107        let s2 = self.s_prev2 as i64;
108        let c = self.coeff.to_bits() as i64;
109        let mag_sq = s * s + s2 * s2 - ((c * s * s2) >> 14);
110        if mag_sq <= 0 {
111            return q15::ZERO;
112        }
113        let mag = isqrt_u64(mag_sq as u64);
114        let out = (mag * 2) / (self.count as u64);
115        q15::from_bits(out.min(32767) as i16)
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: q15::saturating_from_num(attack),
212            release_coeff: q15::saturating_from_num(release),
213            envelope: q15::ZERO,
214        }
215    }
216
217    #[inline(always)]
218    pub fn process(&mut self, x: q15) -> q15 {
219        let rectified = x.to_bits().unsigned_abs() as i32;
220        let env = self.envelope.to_bits() as i32;
221        let coeff = if rectified > env {
222            self.attack_coeff
223        } else {
224            self.release_coeff
225        }
226        .to_bits() as i32;
227        let y = env + ((coeff * (rectified - env)) >> 15);
228        self.envelope = q15::from_bits(y.clamp(0, 32767) as i16);
229        self.envelope
230    }
231
232    pub fn reset(&mut self) {
233        self.envelope = q15::ZERO;
234    }
235}
236
237/// Q15 RMS envelope follower.
238#[derive(Debug, Clone, Copy, Default)]
239pub struct RmsEnvelopeFollowerQ15 {
240    coeff: q15,
241    mean_sq: q15,
242}
243
244impl RmsEnvelopeFollowerQ15 {
245    pub fn new(time_constant_samples: f32) -> Self {
246        let c = 1.0 - single_pole_decay_from_time_constant(time_constant_samples);
247        Self {
248            coeff: q15::saturating_from_num(c),
249            mean_sq: q15::ZERO,
250        }
251    }
252
253    #[inline(always)]
254    pub fn process(&mut self, x: q15) -> q15 {
255        let inst = ((x.to_bits() as i32 * x.to_bits() as i32) >> 15).clamp(0, 32767);
256        let ms = self.mean_sq.to_bits() as i32;
257        let y = ms + ((self.coeff.to_bits() as i32 * (inst - ms)) >> 15);
258        self.mean_sq = q15::from_bits(y.clamp(0, 32767) as i16);
259        let mag = isqrt_u64((self.mean_sq.to_bits() as u64) << 15);
260        q15::from_bits(mag.min(32767) as i16)
261    }
262
263    pub fn reset(&mut self) {
264        self.mean_sq = q15::ZERO;
265    }
266}
267
268// --- Mel Filterbank & MFCC ---
269
270/// Converts a frequency in Hz to the Mel scale: `2595 * log10(1 + hz / 700)`.
271pub fn hz_to_mel(hz: f32) -> f32 {
272    2595.0 * (1.0 + hz / 700.0).log10()
273}
274
275/// Converts a Mel-scale value back to Hz: `700 * (10^(mel / 2595) - 1)`.
276pub fn mel_to_hz(mel: f32) -> f32 {
277    700.0 * ((10.0f32).powf(mel / 2595.0) - 1.0)
278}
279
280/// Applies a triangular Mel filterbank to a one-sided power (or magnitude-squared) spectrum,
281/// producing one energy value per Mel band — the standard first step of MFCC / speech feature
282/// extraction.
283///
284/// `power_spectrum`: one-sided spectrum of length `fft_size / 2 + 1` (DC through Nyquist).
285/// `fft_size`: the FFT length the spectrum was computed with.
286/// `sample_rate_hz`: sampling rate in Hz.
287/// `low_freq_hz` / `high_freq_hz`: frequency range to cover with Mel bands (`0..=sample_rate/2`).
288/// `mel_energies`: destination for the output; its length sets the number of Mel filters `M`
289/// (`1..=64`).
290pub fn mel_filterbank_f32(
291    power_spectrum: &[f32],
292    fft_size: usize,
293    sample_rate_hz: f32,
294    low_freq_hz: f32,
295    high_freq_hz: f32,
296    mel_energies: &mut [f32],
297) -> Status {
298    let num_filters = mel_energies.len();
299    if num_filters == 0 || num_filters > 64 {
300        return Status::ArgumentError;
301    }
302    let num_bins = fft_size / 2 + 1;
303    if power_spectrum.len() < num_bins {
304        return Status::LengthError;
305    }
306
307    let mel_low = hz_to_mel(low_freq_hz);
308    let mel_high = hz_to_mel(high_freq_hz);
309
310    let mut bin_points = [0usize; 66];
311    for (i, bp) in bin_points.iter_mut().enumerate().take(num_filters + 2) {
312        let mel = mel_low + (mel_high - mel_low) * (i as f32) / (num_filters + 1) as f32;
313        let hz = mel_to_hz(mel);
314        let bin = (hz * fft_size as f32 / sample_rate_hz) as usize;
315        *bp = bin.min(num_bins - 1);
316    }
317
318    for (m, out) in mel_energies.iter_mut().enumerate() {
319        let left = bin_points[m];
320        let center = bin_points[m + 1];
321        let right = bin_points[m + 2];
322
323        let mut energy = 0.0f32;
324        if center > left {
325            let span = (center - left) as f32;
326            for bin in left..center {
327                energy += ((bin - left) as f32 / span) * power_spectrum[bin];
328            }
329        }
330        if right > center {
331            let span = (right - center) as f32;
332            for bin in center..=right {
333                energy += ((right - bin) as f32 / span) * power_spectrum[bin];
334            }
335        }
336        *out = energy;
337    }
338
339    Status::Success
340}
341
342/// Computes MFCC (Mel-Frequency Cepstral Coefficient) features from a single real-valued
343/// audio frame: FFT power spectrum, Mel filterbank, log compression, and a DCT-II to
344/// decorrelate the log-Mel-energies into cepstral coefficients. This is the standard
345/// speech/audio feature-extraction pipeline.
346///
347/// `frame`: `fft_size` real audio samples (already windowed by the caller, e.g. with
348/// [`crate::window::hamming_f32`] + [`crate::window::apply_window_f32`]); `fft_size` must be
349/// a power of 2, `<= 512`.
350/// `mel_energies_scratch`: scratch buffer for the intermediate Mel-filterbank output; its
351/// length sets the number of Mel filters used internally (`1..=64`).
352/// `mfcc_out`: destination for the resulting cepstral coefficients; its length sets the number
353/// of coefficients returned (typically 12-13), and must be `<= mel_energies_scratch.len()`.
354pub fn mfcc_f32(
355    frame: &[f32],
356    sample_rate_hz: f32,
357    low_freq_hz: f32,
358    high_freq_hz: f32,
359    mel_energies_scratch: &mut [f32],
360    mfcc_out: &mut [f32],
361) -> Status {
362    let fft_size = frame.len();
363    if fft_size < 2 || (fft_size & (fft_size - 1)) != 0 || 2 * fft_size > 1024 {
364        return Status::ArgumentError;
365    }
366    if mfcc_out.len() > mel_energies_scratch.len() {
367        return Status::ArgumentError;
368    }
369
370    let mut c_data = [0.0f32; 1024];
371    for (i, &x) in frame.iter().enumerate() {
372        c_data[2 * i] = x;
373        c_data[2 * i + 1] = 0.0;
374    }
375    cfft_f32(&mut c_data[..2 * fft_size], fft_size, 0, 1);
376
377    let num_bins = fft_size / 2 + 1;
378    let mut power_spectrum = [0.0f32; 513];
379    for k in 0..num_bins {
380        let re = c_data[2 * k];
381        let im = c_data[2 * k + 1];
382        power_spectrum[k] = re * re + im * im;
383    }
384
385    let status = mel_filterbank_f32(
386        &power_spectrum[..num_bins],
387        fft_size,
388        sample_rate_hz,
389        low_freq_hz,
390        high_freq_hz,
391        mel_energies_scratch,
392    );
393    if status != Status::Success {
394        return status;
395    }
396
397    for e in mel_energies_scratch.iter_mut() {
398        *e = e.max(1e-10).ln();
399    }
400
401    let num_mel = mel_energies_scratch.len() as f32;
402    for (k, out) in mfcc_out.iter_mut().enumerate() {
403        let mut sum = 0.0f32;
404        for (m, &log_e) in mel_energies_scratch.iter().enumerate() {
405            let angle = core::f32::consts::PI * k as f32 * (m as f32 + 0.5) / num_mel;
406            sum += log_e * angle.cos();
407        }
408        *out = sum;
409    }
410
411    Status::Success
412}
413
414// ─────────────────────────────────────────────────────────────────────────────
415// Generalized Filterbank & Fixed-Point Feature Extraction
416// ─────────────────────────────────────────────────────────────────────────────
417
418/// Generalized triangular filterbank applicable to any spectral scale (Linear, Octave, Constant-Q, Bark, Mel).
419///
420/// Integrates `power_spectrum` against triangular weighting windows defined by parallel slices
421/// of `left_bins`, `center_bins`, and `right_bins`.
422pub fn generalized_triangular_filterbank(
423    power_spectrum: &[f32],
424    left_bins: &[usize],
425    center_bins: &[usize],
426    right_bins: &[usize],
427    energies_out: &mut [f32],
428) -> Status {
429    let num_filters = energies_out.len();
430    if left_bins.len() < num_filters
431        || center_bins.len() < num_filters
432        || right_bins.len() < num_filters
433        || num_filters == 0
434    {
435        return Status::LengthError;
436    }
437
438    for (i, energy) in energies_out.iter_mut().enumerate() {
439        let left = left_bins[i];
440        let center = center_bins[i];
441        let right = right_bins[i];
442
443        if left > center || center > right || right >= power_spectrum.len() {
444            return Status::ArgumentError;
445        }
446
447        let mut sum = 0.0f32;
448        if center > left {
449            let span = (center - left) as f32;
450            for bin in left..=center {
451                let weight = (bin - left) as f32 / span;
452                sum += weight * power_spectrum[bin];
453            }
454        }
455        if right > center {
456            let span = (right - center) as f32;
457            for bin in (center + 1)..=right {
458                let weight = (right - bin) as f32 / span;
459                sum += weight * power_spectrum[bin];
460            }
461        }
462        *energy = sum;
463    }
464
465    Status::Success
466}
467
468/// Fast integer base-2 logarithm approximation using leading zeros.
469///
470/// Input is a positive Q15 number (`(0, 32767]`).
471/// Returns `log2(x)` scaled to Q8.7 format.
472#[inline]
473pub fn fast_log2_q15(x: q15) -> Q8F7 {
474    if x <= q15::ZERO {
475        return Q8F7::MIN;
476    }
477    let lz = (x.to_bits() as u16).leading_zeros() as i32;
478    // Integer part of log2 is 15 - lz
479    let int_part = 14 - lz;
480    // Fractional part via linear interpolation of remainder bits
481    let shifted = (x.to_bits() as i32) << lz;
482    let frac = (shifted & 0x7FFF) >> 8; // top 7 bits of fraction
483    let log_val = (int_part << 7) + frac;
484    Q8F7::from_bits((log_val - (15 << 7)).clamp(i16::MIN as i32, i16::MAX as i32) as i16)
485}
486
487/// Simple Voice Activity Detector (VAD) in pure Q15 integer arithmetic.
488///
489/// Combines Short-Time Energy (STE) and Zero-Crossing Rate (ZCR) thresholds to classify frames
490/// as speech/activity vs background noise.
491#[derive(Debug, Clone, Copy)]
492pub struct VadDetectorQ15 {
493    energy_threshold: i32,
494    zcr_threshold: u16,
495}
496
497impl VadDetectorQ15 {
498    /// Create a new VAD detector with energy and zero-crossing rate thresholds.
499    pub const fn new(energy_threshold: i32, zcr_threshold: u16) -> Self {
500        Self {
501            energy_threshold,
502            zcr_threshold,
503        }
504    }
505
506    /// Classify frame as active (`true`) or silence/noise (`false`).
507    pub fn is_active(&self, frame: &[q15]) -> bool {
508        if frame.is_empty() {
509            return false;
510        }
511
512        let mut energy_acc: i64 = 0;
513        let mut zcr_count: u16 = 0;
514
515        for i in 0..frame.len() {
516            let sample = frame[i].to_bits() as i64;
517            energy_acc += (sample * sample) >> 15;
518
519            if i > 0 {
520                let prev = frame[i - 1];
521                let cur = frame[i];
522                if (prev >= 0 && cur < 0) || (prev < 0 && cur >= 0) {
523                    zcr_count += 1;
524                }
525            }
526        }
527
528        let avg_energy = (energy_acc / frame.len() as i64) as i32;
529        avg_energy >= self.energy_threshold && zcr_count >= self.zcr_threshold
530    }
531}