Skip to main content

embedded_dsp/
beamforming.rs

1//! Acoustic Array Processing, Delay-and-Sum Beamforming, and GCC-PHAT TDoA Direction-of-Arrival Estimation.
2//!
3//! Designed for multi-microphone arrays, sonar arrays, and acoustic anomaly triangulation on embedded hardware.
4
5#[allow(unused_imports)]
6use crate::math::FloatMath;
7use crate::transform::cfft_f32;
8use crate::types::Status;
9
10/// Delay-and-Sum Beamformer for multi-channel microphone/sensor arrays.
11///
12/// Implements fractional sample delay interpolation via linear delay lines and weighted spatial summing.
13#[derive(Debug, Clone)]
14pub struct DelayAndSumBeamformer<const MICS: usize, const MAX_DELAY: usize> {
15    delays_samples: [f32; MICS],
16    weights: [f32; MICS],
17    delay_lines: [[f32; MAX_DELAY]; MICS],
18    write_ptrs: [usize; MICS],
19}
20
21impl<const MICS: usize, const MAX_DELAY: usize> DelayAndSumBeamformer<MICS, MAX_DELAY> {
22    /// Creates a new Delay-and-Sum Beamformer with uniform weights ($1 / M$).
23    pub fn new() -> Self {
24        let uniform_w = 1.0 / MICS as f32;
25        Self {
26            delays_samples: [0.0; MICS],
27            weights: [uniform_w; MICS],
28            delay_lines: [[0.0; MAX_DELAY]; MICS],
29            write_ptrs: [0; MICS],
30        }
31    }
32
33    /// Sets the fractional delay (in samples) for each microphone channel.
34    pub fn set_delays(&mut self, delays: &[f32; MICS]) {
35        for (i, &d) in delays.iter().enumerate() {
36            self.delays_samples[i] = d.clamp(0.0, (MAX_DELAY - 2) as f32);
37        }
38    }
39
40    /// Sets the spatial weighting / apodization factors for each channel.
41    pub fn set_weights(&mut self, weights: &[f32; MICS]) {
42        self.weights.copy_from_slice(weights);
43    }
44
45    /// Processes a single multi-channel sample vector and returns the steered beamformed output.
46    pub fn process_sample(&mut self, mic_inputs: &[f32; MICS]) -> f32 {
47        let mut output = 0.0f32;
48
49        for m in 0..MICS {
50            // Write input sample into circular delay line
51            let w_ptr = self.write_ptrs[m];
52            self.delay_lines[m][w_ptr] = mic_inputs[m];
53            self.write_ptrs[m] = (w_ptr + 1) % MAX_DELAY;
54
55            // Compute fractional read pointer
56            let delay = self.delays_samples[m];
57            let int_delay = delay as usize;
58            let frac_delay = delay - int_delay as f32;
59
60            // Two-point linear interpolation
61            let idx0 = (w_ptr + MAX_DELAY - int_delay) % MAX_DELAY;
62            let idx1 = (idx0 + MAX_DELAY - 1) % MAX_DELAY;
63
64            let s0 = self.delay_lines[m][idx0];
65            let s1 = self.delay_lines[m][idx1];
66            let delayed_sample = s0 + frac_delay * (s1 - s0);
67
68            output += delayed_sample * self.weights[m];
69        }
70
71        output
72    }
73
74    /// Resets all internal delay lines.
75    pub fn reset(&mut self) {
76        for m in 0..MICS {
77            self.delay_lines[m].fill(0.0);
78            self.write_ptrs[m] = 0;
79        }
80    }
81}
82
83impl<const MICS: usize, const MAX_DELAY: usize> Default for DelayAndSumBeamformer<MICS, MAX_DELAY> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89/// Generalized Cross-Correlation with Phase Transform (GCC-PHAT) for Time Difference of Arrival (TDoA).
90///
91/// Computes the normalized cross-correlation:
92/// $$R_{\text{PHAT}}(f) = \frac{X_1(f) X_2^*(f)}{|X_1(f) X_2^*(f)|}$$
93/// and finds the time lag $\tau \in [-\text{max\_delay}, \text{max\_delay}]$ that maximizes $r_{\text{PHAT}}(\tau)$.
94///
95/// `sig_a` and `sig_b` must have equal length $N$ (power of two $\le 512$).
96/// Returns the estimated fractional delay (in samples) between channel A and channel B.
97pub fn gcc_phat_tdoa_f32(sig_a: &[f32], sig_b: &[f32], max_delay: usize) -> Result<f32, Status> {
98    let n = sig_a.len();
99    if n != sig_b.len() || n < 4 || (n & (n - 1)) != 0 || n > 512 {
100        return Err(Status::ArgumentError);
101    }
102    if max_delay >= n / 2 {
103        return Err(Status::ArgumentError);
104    }
105
106    let mut buf_a = [0.0f32; 1024];
107    let mut buf_b = [0.0f32; 1024];
108
109    for i in 0..n {
110        buf_a[2 * i] = sig_a[i];
111        buf_a[2 * i + 1] = 0.0;
112        buf_b[2 * i] = sig_b[i];
113        buf_b[2 * i + 1] = 0.0;
114    }
115
116    // Forward FFTs
117    cfft_f32(&mut buf_a[..2 * n], n, 0, 1);
118    cfft_f32(&mut buf_b[..2 * n], n, 0, 1);
119
120    // Cross-spectrum with Phase Transform normalization: X_a * conj(X_b) / |X_a * conj(X_b)|
121    let mut xcorr_spec = [0.0f32; 1024];
122    for k in 0..n {
123        let a_re = buf_a[2 * k];
124        let a_im = buf_a[2 * k + 1];
125        let b_re = buf_b[2 * k];
126        let b_im = -buf_b[2 * k + 1]; // Conjugate
127
128        let c_re = a_re * b_re - a_im * b_im;
129        let c_im = a_re * b_im + a_im * b_re;
130
131        let mag = (c_re * c_re + c_im * c_im).sqrt().max(1e-12);
132        xcorr_spec[2 * k] = c_re / mag;
133        xcorr_spec[2 * k + 1] = c_im / mag;
134    }
135
136    // Inverse FFT to get time-domain cross-correlation
137    cfft_f32(&mut xcorr_spec[..2 * n], n, 1, 1);
138
139    // Circular shift to center lag 0 at n/2
140    let mut gcc = [0.0f32; 512];
141    for i in 0..n {
142        let target_idx = (i + n / 2) % n;
143        gcc[target_idx] = xcorr_spec[2 * i];
144    }
145
146    // Search for maximum peak in [-max_delay, +max_delay] centered around n/2
147    let center = n / 2;
148    let start_idx = center - max_delay;
149    let end_idx = center + max_delay;
150
151    let mut peak_val = f32::MIN;
152    let mut peak_idx = center;
153
154    for i in start_idx..=end_idx {
155        if gcc[i] > peak_val {
156            peak_val = gcc[i];
157            peak_idx = i;
158        }
159    }
160
161    // Parabolic sub-sample interpolation
162    let mut frac_offset = 0.0f32;
163    if peak_idx > start_idx && peak_idx < end_idx {
164        let alpha = gcc[peak_idx - 1];
165        let beta = gcc[peak_idx];
166        let gamma = gcc[peak_idx + 1];
167        let denom = 2.0 * (2.0 * beta - alpha - gamma);
168        if denom.abs() > 1e-12 {
169            frac_offset = (alpha - gamma) / denom;
170        }
171    }
172
173    let delay_samples = (peak_idx as f32 + frac_offset) - center as f32;
174    Ok(delay_samples)
175}