1use nalgebra::DMatrix;
8use num_complex::Complex64;
9use rustfft::FftPlanner;
10use std::f64::consts::PI;
11
12#[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#[derive(Debug, Clone, PartialEq)]
40pub struct MatrixInverseBin {
41 pub values: Vec<Complex64>,
43 pub condition_number: f64,
45 pub reconstruction_error: f64,
47 pub worst_position_error: f64,
49}
50
51pub 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
66pub 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
179pub 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
217pub 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
265pub 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
285pub 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
328pub 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
363pub 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
376pub 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
388pub 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
426pub 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
445pub 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
486pub 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}