Skip to main content

math_audio_dsp/
binaural_matrix.rs

1//! Binaural transfer-matrix utilities.
2//!
3//! The routines here deliberately avoid roomEQ-specific concepts such as
4//! speaker roles, head-position names, or artifact formats. Callers provide
5//! frequency-bin matrices and receive regularized inverse filters.
6
7use nalgebra::DMatrix;
8use num_complex::Complex64;
9use rustfft::FftPlanner;
10use std::f64::consts::PI;
11
12/// Frequency-domain transfer matrix for one listener/head position.
13///
14/// `values` is row-major with shape `num_ears x num_speakers`.
15/// For binaural room correction `num_ears` is normally 2.
16#[derive(Debug, Clone, PartialEq)]
17pub struct TransferMatrixBin {
18    pub num_ears: usize,
19    pub num_speakers: usize,
20    pub values: Vec<Complex64>,
21}
22
23impl TransferMatrixBin {
24    pub fn new(num_ears: usize, num_speakers: usize, values: Vec<Complex64>) -> Self {
25        assert_eq!(values.len(), num_ears * num_speakers);
26        Self {
27            num_ears,
28            num_speakers,
29            values,
30        }
31    }
32
33    fn as_matrix(&self) -> DMatrix<Complex64> {
34        DMatrix::from_row_slice(self.num_ears, self.num_speakers, &self.values)
35    }
36}
37
38/// Result of one frequency-bin regularized inverse solve.
39#[derive(Debug, Clone, PartialEq)]
40pub struct MatrixInverseBin {
41    /// Row-major correction matrix with shape `num_speakers x num_ears`.
42    pub values: Vec<Complex64>,
43    /// Estimated condition number of the primary transfer matrix.
44    pub condition_number: f64,
45    /// Mean squared reconstruction error over all supplied positions.
46    pub reconstruction_error: f64,
47    /// Worst-position mean squared reconstruction error.
48    pub worst_position_error: f64,
49}
50
51/// Solve a regularized binaural inverse for one frequency bin.
52///
53/// Minimizes `sum_p ||H_p F - target||^2 + beta ||F||^2`.
54/// `target` is row-major with shape `num_ears x num_ears`; pass identity for
55/// ordinary cross-talk cancellation.
56pub fn solve_regularized_inverse_bin(
57    positions: &[TransferMatrixBin],
58    target: &[Complex64],
59    beta: f64,
60    max_gain_db: Option<f64>,
61) -> Result<MatrixInverseBin, String> {
62    let weights = vec![1.0; positions.len()];
63    solve_weighted_regularized_inverse_bin(positions, &weights, target, beta, max_gain_db)
64}
65
66/// Solve a weighted regularized binaural inverse for one frequency bin.
67///
68/// Each position contributes `weights[p] * ||H_p F - target||^2`.
69pub fn solve_weighted_regularized_inverse_bin(
70    positions: &[TransferMatrixBin],
71    weights: &[f64],
72    target: &[Complex64],
73    beta: f64,
74    max_gain_db: Option<f64>,
75) -> Result<MatrixInverseBin, String> {
76    let first = positions
77        .first()
78        .ok_or_else(|| "at least one transfer matrix position is required".to_string())?;
79    if weights.len() != positions.len() {
80        return Err(format!(
81            "weights len {} != positions len {}",
82            weights.len(),
83            positions.len()
84        ));
85    }
86    if target.len() != first.num_ears * first.num_ears {
87        return Err(format!(
88            "target has {} entries, expected {}",
89            target.len(),
90            first.num_ears * first.num_ears
91        ));
92    }
93    if beta < 0.0 || !beta.is_finite() {
94        return Err("beta must be finite and non-negative".to_string());
95    }
96
97    for (idx, matrix) in positions.iter().enumerate() {
98        if !weights[idx].is_finite() || weights[idx] < 0.0 {
99            return Err("weights must be finite and non-negative".to_string());
100        }
101        if matrix.num_ears != first.num_ears || matrix.num_speakers != first.num_speakers {
102            return Err("all transfer matrices must have the same shape".to_string());
103        }
104    }
105
106    let speakers = first.num_speakers;
107    let ears = first.num_ears;
108    let target_matrix = DMatrix::from_row_slice(ears, ears, target);
109    let mut normal = DMatrix::<Complex64>::zeros(speakers, speakers);
110    let mut rhs = DMatrix::<Complex64>::zeros(speakers, ears);
111
112    for (matrix, weight) in positions.iter().zip(weights) {
113        let h = matrix.as_matrix();
114        let h_h = h.adjoint();
115        let w = Complex64::new(*weight, 0.0);
116        normal += (&h_h * &h) * w;
117        rhs += (h_h * &target_matrix) * w;
118    }
119
120    for idx in 0..speakers {
121        normal[(idx, idx)] += Complex64::new(beta, 0.0);
122    }
123
124    let mut inverse = normal
125        .try_inverse()
126        .ok_or_else(|| "regularized normal matrix is singular".to_string())?
127        * rhs;
128
129    if let Some(max_gain_db) = max_gain_db {
130        let max_gain = 10.0_f64.powf(max_gain_db / 20.0);
131        for value in inverse.iter_mut() {
132            let mag = value.norm();
133            if mag > max_gain && mag > 0.0 {
134                *value *= max_gain / mag;
135            }
136        }
137    }
138
139    let mut position_errors = Vec::with_capacity(positions.len());
140    for matrix in positions {
141        let delivered = matrix.as_matrix() * &inverse;
142        let mut position_error = 0.0;
143        let mut count = 0usize;
144        for row in 0..ears {
145            for col in 0..ears {
146                position_error += (delivered[(row, col)] - target_matrix[(row, col)]).norm_sqr();
147                count += 1;
148            }
149        }
150        position_errors.push(if count == 0 {
151            0.0
152        } else {
153            position_error / count as f64
154        });
155    }
156
157    let mut values = Vec::with_capacity(speakers * ears);
158    for row in 0..speakers {
159        for col in 0..ears {
160            values.push(inverse[(row, col)]);
161        }
162    }
163
164    let reconstruction_error = if position_errors.is_empty() {
165        0.0
166    } else {
167        position_errors.iter().sum::<f64>() / position_errors.len() as f64
168    };
169    let worst_position_error = position_errors.iter().copied().fold(0.0, f64::max);
170
171    Ok(MatrixInverseBin {
172        values,
173        condition_number: condition_number(first),
174        reconstruction_error,
175        worst_position_error,
176    })
177}
178
179/// Approximate a minimax binaural inverse with iterative worst-position reweighting.
180///
181/// The exact minimax problem is convex but requires a constrained optimizer. This
182/// routine keeps the closed-form weighted Tikhonov solve and repeatedly increases
183/// weights for positions whose reconstruction error is close to the current
184/// worst case. It returns the lowest worst-position solution encountered.
185pub fn solve_minimax_regularized_inverse_bin(
186    positions: &[TransferMatrixBin],
187    target: &[Complex64],
188    beta: f64,
189    max_gain_db: Option<f64>,
190    iterations: usize,
191) -> Result<MatrixInverseBin, String> {
192    if positions.is_empty() {
193        return Err("at least one transfer matrix position is required".to_string());
194    }
195    let iterations = iterations.max(1);
196    let mut weights = vec![1.0; positions.len()];
197    let mut best =
198        solve_weighted_regularized_inverse_bin(positions, &weights, target, beta, max_gain_db)?;
199
200    for _ in 1..iterations {
201        let errors = position_errors(positions, &best.values, target)?;
202        let worst = errors.iter().copied().fold(0.0, f64::max).max(1e-18);
203        for (weight, error) in weights.iter_mut().zip(errors) {
204            let ratio = (error / worst).clamp(0.0, 1.0);
205            *weight = (*weight * (1.0 + 2.0 * ratio * ratio)).clamp(1e-6, 1e6);
206        }
207        let candidate =
208            solve_weighted_regularized_inverse_bin(positions, &weights, target, beta, max_gain_db)?;
209        if candidate.worst_position_error < best.worst_position_error {
210            best = candidate;
211        }
212    }
213
214    Ok(best)
215}
216
217/// Per-position reconstruction errors for a row-major correction matrix.
218pub fn position_errors(
219    positions: &[TransferMatrixBin],
220    correction: &[Complex64],
221    target: &[Complex64],
222) -> Result<Vec<f64>, String> {
223    let first = positions
224        .first()
225        .ok_or_else(|| "at least one transfer matrix position is required".to_string())?;
226    if correction.len() != first.num_speakers * first.num_ears {
227        return Err(format!(
228            "correction has {} entries, expected {}",
229            correction.len(),
230            first.num_speakers * first.num_ears
231        ));
232    }
233    if target.len() != first.num_ears * first.num_ears {
234        return Err(format!(
235            "target has {} entries, expected {}",
236            target.len(),
237            first.num_ears * first.num_ears
238        ));
239    }
240    let f = DMatrix::from_row_slice(first.num_speakers, first.num_ears, correction);
241    let target_matrix = DMatrix::from_row_slice(first.num_ears, first.num_ears, target);
242    let mut errors = Vec::with_capacity(positions.len());
243    for matrix in positions {
244        if matrix.num_ears != first.num_ears || matrix.num_speakers != first.num_speakers {
245            return Err("all transfer matrices must have the same shape".to_string());
246        }
247        let delivered = matrix.as_matrix() * &f;
248        let mut error = 0.0;
249        let mut count = 0usize;
250        for row in 0..first.num_ears {
251            for col in 0..first.num_ears {
252                error += (delivered[(row, col)] - target_matrix[(row, col)]).norm_sqr();
253                count += 1;
254            }
255        }
256        errors.push(if count == 0 {
257            0.0
258        } else {
259            error / count as f64
260        });
261    }
262    Ok(errors)
263}
264
265/// Estimate the condition number using singular values.
266pub fn condition_number(matrix: &TransferMatrixBin) -> f64 {
267    let svd = matrix.as_matrix().svd(false, false);
268    let mut min_sv = f64::INFINITY;
269    let mut max_sv = 0.0;
270    for sv in svd.singular_values.iter().copied() {
271        if sv > max_sv {
272            max_sv = sv;
273        }
274        if sv > 0.0 && sv < min_sv {
275            min_sv = sv;
276        }
277    }
278    if min_sv.is_finite() && min_sv > 0.0 {
279        max_sv / min_sv
280    } else {
281        f64::INFINITY
282    }
283}
284
285/// Convert an RFFT half-spectrum into a real FIR.
286///
287/// `half_spectrum.len()` must be `fft_size / 2 + 1`. A non-zero
288/// `bulk_delay_samples` multiplies the spectrum by `e^-jwd` before IFFT.
289pub fn half_spectrum_to_fir(
290    half_spectrum: &[Complex64],
291    fir_taps: usize,
292    bulk_delay_samples: f64,
293) -> Result<Vec<f64>, String> {
294    if half_spectrum.len() < 2 {
295        return Err("half spectrum must contain at least DC and Nyquist".to_string());
296    }
297    if fir_taps == 0 {
298        return Err("fir_taps must be greater than zero".to_string());
299    }
300    let fft_size = (half_spectrum.len() - 1) * 2;
301    if fir_taps > fft_size {
302        return Err(format!(
303            "fir_taps ({}) cannot exceed implied fft_size ({})",
304            fir_taps, fft_size
305        ));
306    }
307
308    let mut spectrum = vec![Complex64::new(0.0, 0.0); fft_size];
309    for (bin, value) in half_spectrum.iter().enumerate() {
310        let phase = -2.0 * PI * bin as f64 * bulk_delay_samples / fft_size as f64;
311        spectrum[bin] = *value * Complex64::from_polar(1.0, phase);
312    }
313    for bin in 1..(half_spectrum.len() - 1) {
314        spectrum[fft_size - bin] = spectrum[bin].conj();
315    }
316
317    let mut planner = FftPlanner::<f64>::new();
318    let fft = planner.plan_fft_inverse(fft_size);
319    fft.process(&mut spectrum);
320
321    Ok(spectrum
322        .into_iter()
323        .take(fir_taps)
324        .map(|value| value.re / fft_size as f64)
325        .collect())
326}
327
328/// Deconvolve a recorded sweep into a real impulse response.
329pub fn deconvolve_sweep_to_ir(
330    recording: &[f64],
331    reference: &[f64],
332    fft_size: usize,
333) -> Result<Vec<f64>, String> {
334    if recording.is_empty() || reference.is_empty() {
335        return Err("recording and reference must be non-empty".to_string());
336    }
337    if fft_size < recording.len().max(reference.len()).next_power_of_two() {
338        return Err("fft_size is too small for recording/reference".to_string());
339    }
340    let mut y = vec![Complex64::new(0.0, 0.0); fft_size];
341    let mut x = vec![Complex64::new(0.0, 0.0); fft_size];
342    for (idx, value) in recording.iter().enumerate() {
343        y[idx] = Complex64::new(*value, 0.0);
344    }
345    for (idx, value) in reference.iter().enumerate() {
346        x[idx] = Complex64::new(*value, 0.0);
347    }
348    let mut planner = FftPlanner::<f64>::new();
349    let fft = planner.plan_fft_forward(fft_size);
350    fft.process(&mut y);
351    fft.process(&mut x);
352    let peak = x.iter().map(|v| v.norm()).fold(0.0, f64::max).max(1e-20);
353    let eps_sq = (peak * 1e-3).powi(2);
354    for idx in 0..fft_size {
355        let denom = x[idx].norm_sqr() + eps_sq;
356        y[idx] = y[idx] * x[idx].conj() / denom;
357    }
358    let ifft = planner.plan_fft_inverse(fft_size);
359    ifft.process(&mut y);
360    Ok(y.into_iter().map(|v| v.re / fft_size as f64).collect())
361}
362
363/// Find the largest absolute sample in an impulse response.
364pub fn direct_peak_sample(ir: &[f64]) -> usize {
365    ir.iter()
366        .enumerate()
367        .max_by(|a, b| {
368            a.1.abs()
369                .partial_cmp(&b.1.abs())
370                .unwrap_or(std::cmp::Ordering::Equal)
371        })
372        .map(|(idx, _)| idx)
373        .unwrap_or(0)
374}
375
376/// Circularly shift an IR so `reference_peak` becomes sample zero.
377pub fn align_ir_to_reference_peak(ir: &[f64], reference_peak: usize) -> Vec<f64> {
378    if ir.is_empty() {
379        return Vec::new();
380    }
381    let shift = reference_peak % ir.len();
382    let mut out = Vec::with_capacity(ir.len());
383    out.extend_from_slice(&ir[shift..]);
384    out.extend_from_slice(&ir[..shift]);
385    out
386}
387
388/// Suppress log-sweep harmonic distortion residue tails in a circularly
389/// deconvolved IR.
390///
391/// After log-sweep deconvolution and direct-sound alignment, harmonic residues
392/// appear at negative times, i.e. near the end of the circular IR. This zeros
393/// short windows around the expected harmonic offsets.
394pub fn suppress_log_sweep_harmonic_residues(
395    ir: &mut [f64],
396    sample_rate: f64,
397    sweep_duration_s: f64,
398    sweep_start_hz: f64,
399    sweep_end_hz: f64,
400    max_harmonic: usize,
401    window_ms: f64,
402) {
403    if ir.is_empty()
404        || sample_rate <= 0.0
405        || sweep_duration_s <= 0.0
406        || sweep_start_hz <= 0.0
407        || sweep_end_hz <= sweep_start_hz
408        || max_harmonic < 2
409    {
410        return;
411    }
412    let direct = direct_peak_sample(ir) as isize;
413    let log_ratio = (sweep_end_hz / sweep_start_hz).ln();
414    let half = ((window_ms.max(0.0) / 1000.0) * sample_rate).round() as isize;
415    let len = ir.len() as isize;
416    for harmonic in 2..=max_harmonic {
417        let offset_s = sweep_duration_s * (harmonic as f64).ln() / log_ratio;
418        let center = direct - (offset_s * sample_rate).round() as isize;
419        for delta in -half..=half {
420            let idx = (center + delta).rem_euclid(len) as usize;
421            ir[idx] = 0.0;
422        }
423    }
424}
425
426/// Direct-window an IR and FFT it into a half-spectrum.
427pub fn direct_windowed_half_spectrum(
428    ir: &[f64],
429    sample_rate: f64,
430    fft_size: usize,
431    start_ms: f64,
432    length_ms: f64,
433    fade_ms: f64,
434) -> Result<Vec<Complex64>, String> {
435    if fft_size == 0 || ir.is_empty() {
436        return Err("ir and fft_size must be non-empty".to_string());
437    }
438    let mut windowed = vec![0.0; fft_size];
439    let copy_len = ir.len().min(fft_size);
440    windowed[..copy_len].copy_from_slice(&ir[..copy_len]);
441    apply_direct_window(&mut windowed, sample_rate, start_ms, length_ms, fade_ms);
442    Ok(real_fft_half_spectrum(&windowed, fft_size))
443}
444
445/// Window an IR relative to its direct-sound peak and FFT it into a half-spectrum.
446///
447/// Samples keep their original time index in the FFT buffer. That preserves the
448/// acoustic arrival phase while avoiding absolute sample-zero windows that miss
449/// delayed speaker arrivals.
450pub fn direct_peak_windowed_half_spectrum(
451    ir: &[f64],
452    sample_rate: f64,
453    fft_size: usize,
454    start_ms: f64,
455    length_ms: f64,
456    fade_ms: f64,
457) -> Result<Vec<Complex64>, String> {
458    if fft_size == 0 || ir.is_empty() {
459        return Err("ir and fft_size must be non-empty".to_string());
460    }
461    let direct_sample = direct_peak_sample(ir);
462    let mut windowed = vec![0.0; fft_size];
463    let copy_len = ir.len().min(fft_size);
464    let start = direct_sample as isize + ((start_ms / 1000.0) * sample_rate).round() as isize;
465    let length = ((length_ms / 1000.0) * sample_rate).round().max(1.0) as isize;
466    let fade = ((fade_ms / 1000.0) * sample_rate).round().max(0.0) as isize;
467    let end = start + length;
468
469    for idx in 0..copy_len {
470        let idx_i = idx as isize;
471        if idx_i < start || idx_i >= end {
472            continue;
473        }
474        let mut value = ir[idx];
475        if fade > 0 && idx_i >= end - fade {
476            let n = end - idx_i;
477            let phase = PI * n as f64 / fade as f64;
478            value *= 0.5 - 0.5 * phase.cos();
479        }
480        windowed[idx] = value;
481    }
482
483    Ok(real_fft_half_spectrum(&windowed, fft_size))
484}
485
486/// Frequency-dependent complex windowed spectrum.
487pub fn fdw_complex_half_spectrum(
488    ir: &[f64],
489    sample_rate: f64,
490    fft_size: usize,
491    direct_sample: usize,
492    cycles: f64,
493    min_window_ms: f64,
494    max_window_ms: f64,
495) -> Result<Vec<Complex64>, String> {
496    if ir.is_empty() || fft_size < 2 {
497        return Err("ir must be non-empty and fft_size >= 2".to_string());
498    }
499    if sample_rate <= 0.0 || !sample_rate.is_finite() {
500        return Err("sample_rate must be positive and finite".to_string());
501    }
502    let num_bins = fft_size / 2 + 1;
503    let mut out = Vec::with_capacity(num_bins);
504    for bin in 0..num_bins {
505        let freq = bin as f64 * sample_rate / fft_size as f64;
506        if bin == 0 {
507            out.push(Complex64::new(ir.iter().sum::<f64>(), 0.0));
508            continue;
509        }
510        let window_ms = ((cycles / freq) * 1000.0).clamp(min_window_ms, max_window_ms);
511        let half = ((window_ms / 1000.0) * sample_rate * 0.5).round().max(1.0) as isize;
512        let center = direct_sample.min(ir.len() - 1) as isize;
513        let mut sum = Complex64::new(0.0, 0.0);
514        for delta in -half..=half {
515            let idx = center + delta;
516            if idx < 0 || idx >= ir.len() as isize {
517                continue;
518            }
519            let t = idx as f64 / sample_rate;
520            let phase = -2.0 * PI * freq * t;
521            let x = delta as f64 / half as f64;
522            let w = 0.5 + 0.5 * (PI * x).cos();
523            sum += Complex64::from_polar(ir[idx as usize] * w, phase);
524        }
525        out.push(sum);
526    }
527    Ok(out)
528}
529
530fn apply_direct_window(
531    samples: &mut [f64],
532    sample_rate: f64,
533    start_ms: f64,
534    length_ms: f64,
535    fade_ms: f64,
536) {
537    let start = ((start_ms / 1000.0) * sample_rate).round().max(0.0) as usize;
538    let length = ((length_ms / 1000.0) * sample_rate).round().max(1.0) as usize;
539    let fade = ((fade_ms / 1000.0) * sample_rate).round().max(0.0) as usize;
540    let end = (start + length).min(samples.len());
541    for (idx, sample) in samples.iter_mut().enumerate() {
542        if idx < start || idx >= end {
543            *sample = 0.0;
544            continue;
545        }
546        if fade > 0 && idx >= end.saturating_sub(fade) {
547            let n = end - idx;
548            let phase = PI * n as f64 / fade as f64;
549            *sample *= 0.5 - 0.5 * phase.cos();
550        }
551    }
552}
553
554fn real_fft_half_spectrum(input: &[f64], fft_size: usize) -> Vec<Complex64> {
555    let mut buffer = vec![Complex64::new(0.0, 0.0); fft_size];
556    let copy_len = input.len().min(fft_size);
557    for idx in 0..copy_len {
558        buffer[idx] = Complex64::new(input[idx], 0.0);
559    }
560    let mut planner = FftPlanner::<f64>::new();
561    let fft = planner.plan_fft_forward(fft_size);
562    fft.process(&mut buffer);
563    buffer.truncate(fft_size / 2 + 1);
564    buffer
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn inverse_solves_identity_for_well_conditioned_2x2() {
573        let h = TransferMatrixBin::new(
574            2,
575            2,
576            vec![
577                Complex64::new(1.0, 0.0),
578                Complex64::new(0.2, 0.0),
579                Complex64::new(0.15, 0.0),
580                Complex64::new(0.9, 0.0),
581            ],
582        );
583        let target = vec![
584            Complex64::new(1.0, 0.0),
585            Complex64::new(0.0, 0.0),
586            Complex64::new(0.0, 0.0),
587            Complex64::new(1.0, 0.0),
588        ];
589
590        let solved =
591            solve_regularized_inverse_bin(std::slice::from_ref(&h), &target, 1e-9, None).unwrap();
592        let f = DMatrix::from_row_slice(2, 2, &solved.values);
593        let delivered = h.as_matrix() * f;
594
595        assert!((delivered[(0, 0)].re - 1.0).abs() < 1e-6);
596        assert!(delivered[(0, 1)].norm() < 1e-6);
597        assert!(delivered[(1, 0)].norm() < 1e-6);
598        assert!((delivered[(1, 1)].re - 1.0).abs() < 1e-6);
599    }
600
601    #[test]
602    fn inverse_limits_large_gains() {
603        let h = TransferMatrixBin::new(
604            2,
605            2,
606            vec![
607                Complex64::new(0.001, 0.0),
608                Complex64::new(0.0, 0.0),
609                Complex64::new(0.0, 0.0),
610                Complex64::new(0.001, 0.0),
611            ],
612        );
613        let target = vec![
614            Complex64::new(1.0, 0.0),
615            Complex64::new(0.0, 0.0),
616            Complex64::new(0.0, 0.0),
617            Complex64::new(1.0, 0.0),
618        ];
619
620        let solved = solve_regularized_inverse_bin(&[h], &target, 1e-12, Some(6.0)).unwrap();
621        let max_mag = solved.values.iter().map(|v| v.norm()).fold(0.0, f64::max);
622        assert!(max_mag <= 10.0_f64.powf(6.0 / 20.0) + 1e-9);
623    }
624
625    #[test]
626    fn half_spectrum_identity_yields_delayed_impulse() {
627        let spectrum = vec![Complex64::new(1.0, 0.0); 9];
628        let fir = half_spectrum_to_fir(&spectrum, 16, 4.0).unwrap();
629        let peak = fir
630            .iter()
631            .enumerate()
632            .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
633            .map(|(idx, _)| idx)
634            .unwrap();
635        assert_eq!(peak, 4);
636        assert!((fir[peak] - 1.0).abs() < 1e-9);
637    }
638
639    #[test]
640    fn minimax_reduces_or_matches_worst_position_error() {
641        let target = vec![
642            Complex64::new(1.0, 0.0),
643            Complex64::new(0.0, 0.0),
644            Complex64::new(0.0, 0.0),
645            Complex64::new(1.0, 0.0),
646        ];
647        let positions = vec![
648            TransferMatrixBin::new(
649                2,
650                2,
651                vec![
652                    Complex64::new(1.0, 0.0),
653                    Complex64::new(0.2, 0.0),
654                    Complex64::new(0.2, 0.0),
655                    Complex64::new(1.0, 0.0),
656                ],
657            ),
658            TransferMatrixBin::new(
659                2,
660                2,
661                vec![
662                    Complex64::new(0.7, 0.0),
663                    Complex64::new(0.45, 0.0),
664                    Complex64::new(0.35, 0.0),
665                    Complex64::new(0.8, 0.0),
666                ],
667            ),
668        ];
669        let average = solve_regularized_inverse_bin(&positions, &target, 0.01, Some(12.0)).unwrap();
670        let minimax =
671            solve_minimax_regularized_inverse_bin(&positions, &target, 0.01, Some(12.0), 8)
672                .unwrap();
673        assert!(minimax.worst_position_error <= average.worst_position_error + 1e-9);
674    }
675
676    #[test]
677    fn deconvolution_alignment_and_harmonic_suppression_are_stable() {
678        let mut reference = vec![0.0; 64];
679        reference[0] = 1.0;
680        let mut recording = vec![0.0; 64];
681        recording[7] = 0.5;
682        let ir = deconvolve_sweep_to_ir(&recording, &reference, 64).unwrap();
683        assert_eq!(direct_peak_sample(&ir), 7);
684        let aligned = align_ir_to_reference_peak(&ir, 7);
685        assert_eq!(direct_peak_sample(&aligned), 0);
686
687        let mut residue = vec![1.0; 128];
688        suppress_log_sweep_harmonic_residues(&mut residue, 48_000.0, 1.0, 20.0, 20_000.0, 3, 1.0);
689        assert!(residue.contains(&0.0));
690    }
691
692    #[test]
693    fn harmonic_suppression_tracks_delayed_direct_peak() {
694        let sample_rate = 1_000.0_f64;
695        let duration = 1.0_f64;
696        let start_hz = 10.0_f64;
697        let end_hz = 1_000.0_f64;
698        let harmonic = 2usize;
699        let len = 512usize;
700        let direct = 123usize;
701        let offset = (duration * (harmonic as f64).ln() / (end_hz / start_hz).ln() * sample_rate)
702            .round() as usize;
703        let residue = (direct + len - offset % len) % len;
704
705        let mut ir = vec![0.0; len];
706        ir[direct] = 1.0;
707        ir[residue] = 0.5;
708        suppress_log_sweep_harmonic_residues(
709            &mut ir,
710            sample_rate,
711            duration,
712            start_hz,
713            end_hz,
714            harmonic,
715            0.0,
716        );
717
718        assert_eq!(ir[residue], 0.0);
719        assert_eq!(ir[direct], 1.0);
720    }
721
722    #[test]
723    fn fdw_complex_half_spectrum_returns_fft_bins() {
724        let mut ir = vec![0.0; 128];
725        ir[8] = 1.0;
726        let spectrum = fdw_complex_half_spectrum(&ir, 48_000.0, 128, 8, 8.0, 3.0, 30.0).unwrap();
727        assert_eq!(spectrum.len(), 65);
728        assert!(spectrum[1].norm() > 0.0);
729    }
730}