1use hound::WavReader;
11use math_audio_iir_fir::{Biquad, BiquadFilterType};
12use rustfft::FftPlanner;
13use rustfft::num_complex::Complex;
14use std::cell::RefCell;
15use std::f32::consts::PI;
16use std::io::Write;
17use std::path::Path;
18use std::sync::Arc;
19
20type SpectrumResult = Result<(Vec<f32>, Vec<f32>, Vec<f32>), String>;
22
23thread_local! {
24 static FFT_PLANNER: RefCell<FftPlanner<f32>> = RefCell::new(FftPlanner::new());
25}
26
27fn plan_fft_forward(size: usize) -> Arc<dyn rustfft::Fft<f32>> {
28 FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(size))
29}
30
31fn plan_fft_inverse(size: usize) -> Arc<dyn rustfft::Fft<f32>> {
32 FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_inverse(size))
33}
34
35#[derive(Debug, Clone)]
37pub struct MicrophoneCompensation {
38 pub frequencies: Vec<f32>,
40 pub spl_db: Vec<f32>,
42}
43
44impl MicrophoneCompensation {
45 pub fn apply_to_sweep(
60 &self,
61 signal: &[f32],
62 start_freq: f32,
63 end_freq: f32,
64 sample_rate: u32,
65 inverse: bool,
66 ) -> Vec<f32> {
67 let duration = signal.len() as f32 / sample_rate as f32;
68 let mut compensated = Vec::with_capacity(signal.len());
69
70 let debug_points = [0, signal.len() / 4, signal.len() / 2, 3 * signal.len() / 4];
72
73 for (i, &sample) in signal.iter().enumerate() {
74 let t = i as f32 / sample_rate as f32;
75
76 let freq = start_freq * ((t * (end_freq / start_freq).ln()) / duration).exp();
79
80 let comp_db = self.interpolate_at(freq);
82
83 let gain_db = if inverse { -comp_db } else { comp_db };
85
86 let gain = 10_f32.powf(gain_db / 20.0);
88
89 if debug_points.contains(&i) {
91 log::debug!(
92 "[apply_to_sweep] t={:.3}s, freq={:.1}Hz, comp_db={:.2}dB, gain_db={:.2}dB, gain={:.3}x",
93 t,
94 freq,
95 comp_db,
96 gain_db,
97 gain
98 );
99 }
100
101 compensated.push(sample * gain);
102 }
103
104 log::debug!(
105 "[apply_to_sweep] Processed {} samples, duration={:.2}s",
106 signal.len(),
107 duration
108 );
109 compensated
110 }
111
112 pub fn from_file(path: &Path) -> Result<Self, String> {
118 use std::fs::File;
119 use std::io::{BufRead, BufReader};
120
121 log::debug!("[MicrophoneCompensation] Loading from {:?}", path);
122
123 let file = File::open(path)
124 .map_err(|e| format!("Failed to open compensation file {:?}: {}", path, e))?;
125 let reader = BufReader::new(file);
126
127 let is_txt_file = path
129 .extension()
130 .and_then(|e| e.to_str())
131 .map(|e| e.to_lowercase() == "txt")
132 .unwrap_or(false);
133
134 if is_txt_file {
135 log::info!(
136 "[MicrophoneCompensation] Detected .txt file - assuming space/tab-separated without header"
137 );
138 }
139
140 let mut frequencies = Vec::new();
141 let mut spl_db = Vec::new();
142
143 for (line_num, line) in reader.lines().enumerate() {
144 let line = line.map_err(|e| format!("Failed to read line {}: {}", line_num + 1, e))?;
145 let line = line.trim();
146
147 if line.is_empty() || line.starts_with('#') {
149 continue;
150 }
151
152 if !is_txt_file && line.starts_with("frequency") {
154 continue;
155 }
156
157 if is_txt_file {
159 let first_char = line.chars().next().unwrap_or(' ');
160 if !first_char.is_ascii_digit() && first_char != '-' && first_char != '+' {
161 log::info!(
162 "[MicrophoneCompensation] Skipping non-numeric line {}: '{}'",
163 line_num + 1,
164 line
165 );
166 continue;
167 }
168 }
169
170 let parts: Vec<&str> = if is_txt_file {
172 let comma_parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
175 if comma_parts.len() >= 2
176 && comma_parts[0].parse::<f32>().is_ok()
177 && comma_parts[1].parse::<f32>().is_ok()
178 {
179 comma_parts
180 } else {
181 let tab_parts: Vec<&str> = line.split('\t').map(|s| s.trim()).collect();
183 if tab_parts.len() >= 2
184 && tab_parts[0].parse::<f32>().is_ok()
185 && tab_parts[1].parse::<f32>().is_ok()
186 {
187 tab_parts
188 } else {
189 line.split_whitespace().collect()
191 }
192 }
193 } else {
194 line.split(',').collect()
196 };
197
198 if parts.len() < 2 {
199 let separator = if is_txt_file {
200 "separator (comma/tab/space)"
201 } else {
202 "comma"
203 };
204 return Err(format!(
205 "Invalid format at line {}: expected {} with 2+ values but got '{}'",
206 line_num + 1,
207 separator,
208 line
209 ));
210 }
211
212 let freq: f32 = parts[0]
213 .trim()
214 .parse()
215 .map_err(|e| format!("Invalid frequency at line {}: {}", line_num + 1, e))?;
216 let spl: f32 = parts[1]
217 .trim()
218 .parse()
219 .map_err(|e| format!("Invalid SPL at line {}: {}", line_num + 1, e))?;
220
221 frequencies.push(freq);
222 spl_db.push(spl);
223 }
224
225 if frequencies.is_empty() {
226 return Err(format!("No compensation data found in {:?}", path));
227 }
228
229 for i in 1..frequencies.len() {
231 if frequencies[i] <= frequencies[i - 1] {
232 return Err(format!(
233 "Frequencies must be strictly increasing: found {} after {} at line {}",
234 frequencies[i],
235 frequencies[i - 1],
236 i + 1
237 ));
238 }
239 }
240
241 log::info!(
242 "[MicrophoneCompensation] Loaded {} calibration points: {:.1} Hz - {:.1} Hz",
243 frequencies.len(),
244 frequencies[0],
245 frequencies[frequencies.len() - 1]
246 );
247 log::info!(
248 "[MicrophoneCompensation] SPL range: {:.2} dB to {:.2} dB",
249 spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b)),
250 spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b))
251 );
252
253 Ok(Self {
254 frequencies,
255 spl_db,
256 })
257 }
258
259 pub fn interpolate_at(&self, freq: f32) -> f32 {
264 if freq < self.frequencies[0] || freq > self.frequencies[self.frequencies.len() - 1] {
265 return 0.0;
267 }
268
269 let idx = match self
271 .frequencies
272 .binary_search_by(|f| f.partial_cmp(&freq).unwrap_or(std::cmp::Ordering::Equal))
273 {
274 Ok(i) => return self.spl_db[i], Err(i) => i,
276 };
277
278 if idx == 0 {
279 return self.spl_db[0];
280 }
281 if idx >= self.frequencies.len() {
282 return self.spl_db[self.frequencies.len() - 1];
283 }
284
285 let f0 = self.frequencies[idx - 1];
287 let f1 = self.frequencies[idx];
288 let s0 = self.spl_db[idx - 1];
289 let s1 = self.spl_db[idx];
290
291 let t = (freq - f0) / (f1 - f0);
292 s0 + t * (s1 - s0)
293 }
294}
295
296#[derive(Debug, Clone)]
302pub struct WavAnalysisConfig {
303 pub num_points: usize,
305 pub min_freq: f32,
307 pub max_freq: f32,
309 pub fft_size: Option<usize>,
311 pub overlap: f32,
313 pub single_fft: bool,
315 pub pink_compensation: bool,
317 pub no_window: bool,
319}
320
321impl Default for WavAnalysisConfig {
322 fn default() -> Self {
323 Self {
324 num_points: 2000,
325 min_freq: 20.0,
326 max_freq: 20000.0,
327 fft_size: None,
328 overlap: 0.5,
329 single_fft: false,
330 pink_compensation: false,
331 no_window: false,
332 }
333 }
334}
335
336impl WavAnalysisConfig {
337 pub fn for_log_sweep() -> Self {
339 Self {
340 single_fft: true,
341 pink_compensation: true,
342 no_window: true,
343 ..Default::default()
344 }
345 }
346
347 pub fn for_impulse_response() -> Self {
349 Self {
350 single_fft: true,
351 ..Default::default()
352 }
353 }
354
355 pub fn for_stationary() -> Self {
357 Self::default()
358 }
359}
360
361#[derive(Debug, Clone)]
363pub struct WavAnalysisOutput {
364 pub frequencies: Vec<f32>,
366 pub magnitude_db: Vec<f32>,
368 pub phase_deg: Vec<f32>,
370}
371
372pub fn analyze_wav_buffer(
382 samples: &[f32],
383 sample_rate: u32,
384 config: &WavAnalysisConfig,
385) -> Result<WavAnalysisOutput, String> {
386 if samples.is_empty() {
387 return Err("Signal is empty".to_string());
388 }
389
390 let fft_size = if config.single_fft {
392 config
393 .fft_size
394 .unwrap_or_else(|| wav_next_power_of_two(samples.len()))
395 } else {
396 config.fft_size.unwrap_or(16384)
397 };
398
399 let (freqs, magnitudes_db, phases_deg) = if config.single_fft {
401 compute_single_fft_spectrum_internal(samples, sample_rate, fft_size, config.no_window)?
402 } else {
403 compute_welch_spectrum_internal(samples, sample_rate, fft_size, config.overlap)?
404 };
405
406 let log_freqs = generate_log_frequencies(config.num_points, config.min_freq, config.max_freq);
408
409 let mut interp_mag = interpolate_log(&freqs, &magnitudes_db, &log_freqs);
411 let interp_phase = interpolate_log_phase(&freqs, &phases_deg, &log_freqs);
412
413 if config.pink_compensation {
415 let ref_freq = 1000.0;
416 for (i, freq) in log_freqs.iter().enumerate() {
417 if *freq > 0.0 {
418 let correction = 10.0 * (freq / ref_freq).log10();
419 interp_mag[i] += correction;
420 }
421 }
422 }
423
424 Ok(WavAnalysisOutput {
425 frequencies: log_freqs,
426 magnitude_db: interp_mag,
427 phase_deg: interp_phase,
428 })
429}
430
431pub fn analyze_wav_file(
440 path: &Path,
441 config: &WavAnalysisConfig,
442) -> Result<WavAnalysisOutput, String> {
443 let (samples, sample_rate) = load_wav_mono_with_rate(path)?;
444 analyze_wav_buffer(&samples, sample_rate, config)
445}
446
447fn load_wav_mono_with_rate(path: &Path) -> Result<(Vec<f32>, u32), String> {
449 let mut reader =
450 WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
451
452 let spec = reader.spec();
453 let sample_rate = spec.sample_rate;
454 let channels = spec.channels as usize;
455
456 let samples: Result<Vec<f32>, _> = match spec.sample_format {
457 hound::SampleFormat::Float => reader.samples::<f32>().collect(),
458 hound::SampleFormat::Int => {
459 let max_val = (1_i64 << (spec.bits_per_sample - 1)) as f32;
460 reader
461 .samples::<i32>()
462 .map(|s| s.map(|v| v as f32 / max_val))
463 .collect()
464 }
465 };
466
467 let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
468
469 let mono = if channels == 1 {
471 samples
472 } else {
473 samples
474 .chunks(channels)
475 .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
476 .collect()
477 };
478
479 Ok((mono, sample_rate))
480}
481
482pub fn write_wav_analysis_csv(result: &WavAnalysisOutput, path: &Path) -> Result<(), String> {
488 let mut file =
489 std::fs::File::create(path).map_err(|e| format!("Failed to create CSV: {}", e))?;
490
491 writeln!(file, "frequency_hz,spl_db,phase_deg")
492 .map_err(|e| format!("Failed to write CSV header: {}", e))?;
493
494 for i in 0..result.frequencies.len() {
495 writeln!(
496 file,
497 "{:.2},{:.2},{:.2}",
498 result.frequencies[i], result.magnitude_db[i], result.phase_deg[i]
499 )
500 .map_err(|e| format!("Failed to write CSV row: {}", e))?;
501 }
502
503 Ok(())
504}
505
506fn compute_welch_spectrum_internal(
508 signal: &[f32],
509 sample_rate: u32,
510 fft_size: usize,
511 overlap: f32,
512) -> SpectrumResult {
513 if signal.is_empty() {
514 return Err("Signal is empty".to_string());
515 }
516
517 let overlap_samples = (fft_size as f32 * overlap.clamp(0.0, 0.95)) as usize;
518 let hop_size = fft_size - overlap_samples;
519
520 let num_windows = if signal.len() >= fft_size {
521 1 + (signal.len() - fft_size) / hop_size
522 } else {
523 1
524 };
525
526 let num_bins = fft_size / 2;
527 let mut magnitude_sum = vec![0.0_f32; num_bins];
528 let mut phase_real_sum = vec![0.0_f32; num_bins];
529 let mut phase_imag_sum = vec![0.0_f32; num_bins];
530
531 let hann_window: Vec<f32> = (0..fft_size)
533 .map(|i| 0.5 * (1.0 - ((2.0 * PI * i as f32) / (fft_size as f32 - 1.0)).cos()))
534 .collect();
535
536 let window_power: f32 = hann_window.iter().map(|&w| w * w).sum();
537 let scale_factor = 2.0 / window_power;
538
539 let fft = plan_fft_forward(fft_size);
540
541 let mut windowed = vec![0.0_f32; fft_size];
542 let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
543
544 for window_idx in 0..num_windows {
545 let start = window_idx * hop_size;
546 let end = (start + fft_size).min(signal.len());
547 let window_len = end - start;
548
549 for i in 0..window_len {
551 windowed[i] = signal[start + i] * hann_window[i];
552 }
553 windowed[window_len..fft_size].fill(0.0);
555
556 for (i, &val) in windowed.iter().enumerate() {
558 buffer[i] = Complex::new(val, 0.0);
559 }
560
561 fft.process(&mut buffer);
562
563 for i in 0..num_bins {
564 let mag = buffer[i].norm() * scale_factor.sqrt();
565 magnitude_sum[i] += mag * mag;
566 phase_real_sum[i] += buffer[i].re;
567 phase_imag_sum[i] += buffer[i].im;
568 }
569 }
570
571 let magnitudes_db: Vec<f32> = magnitude_sum
572 .iter()
573 .map(|&mag_sq| {
574 let mag = (mag_sq / num_windows as f32).sqrt();
575 if mag > 1e-10 {
576 20.0 * mag.log10()
577 } else {
578 -200.0
579 }
580 })
581 .collect();
582
583 let phases_deg: Vec<f32> = phase_real_sum
584 .iter()
585 .zip(phase_imag_sum.iter())
586 .map(|(&re, &im)| (im / num_windows as f32).atan2(re / num_windows as f32) * 180.0 / PI)
587 .collect();
588
589 let freqs: Vec<f32> = (0..num_bins)
590 .map(|i| i as f32 * sample_rate as f32 / fft_size as f32)
591 .collect();
592
593 Ok((freqs, magnitudes_db, phases_deg))
594}
595
596fn compute_single_fft_spectrum_internal(
598 signal: &[f32],
599 sample_rate: u32,
600 fft_size: usize,
601 no_window: bool,
602) -> SpectrumResult {
603 if signal.is_empty() {
604 return Err("Signal is empty".to_string());
605 }
606
607 let mut windowed = vec![0.0_f32; fft_size];
608 let copy_len = signal.len().min(fft_size);
609 windowed[..copy_len].copy_from_slice(&signal[..copy_len]);
610
611 let window_scale_factor = if no_window {
612 1.0
613 } else {
614 let hann_window: Vec<f32> = (0..fft_size)
615 .map(|i| 0.5 * (1.0 - ((2.0 * PI * i as f32) / (fft_size as f32 - 1.0)).cos()))
616 .collect();
617
618 for (i, sample) in windowed.iter_mut().enumerate() {
619 *sample *= hann_window[i];
620 }
621
622 hann_window.iter().map(|&w| w * w).sum::<f32>()
623 };
624
625 let mut buffer: Vec<Complex<f32>> = windowed.iter().map(|&x| Complex::new(x, 0.0)).collect();
626
627 let fft = plan_fft_forward(fft_size);
628 fft.process(&mut buffer);
629
630 let scale_factor = if no_window {
631 (2.0 / fft_size as f32).sqrt()
632 } else {
633 (2.0 / window_scale_factor).sqrt()
634 };
635
636 let num_bins = fft_size / 2;
637 let magnitudes_db: Vec<f32> = buffer[..num_bins]
638 .iter()
639 .map(|c| {
640 let mag = c.norm() * scale_factor;
641 if mag > 1e-10 {
642 20.0 * mag.log10()
643 } else {
644 -200.0
645 }
646 })
647 .collect();
648
649 let phases_deg: Vec<f32> = buffer[..num_bins]
650 .iter()
651 .map(|c| c.arg() * 180.0 / PI)
652 .collect();
653
654 let freqs: Vec<f32> = (0..num_bins)
655 .map(|i| i as f32 * sample_rate as f32 / fft_size as f32)
656 .collect();
657
658 Ok((freqs, magnitudes_db, phases_deg))
659}
660
661fn wav_next_power_of_two(n: usize) -> usize {
663 let mut p = 1;
664 while p < n {
665 p *= 2;
666 }
667 p.min(1048576)
668}
669
670fn generate_log_frequencies(num_points: usize, min_freq: f32, max_freq: f32) -> Vec<f32> {
672 let log_min = min_freq.ln();
673 let log_max = max_freq.ln();
674 let step = (log_max - log_min) / (num_points - 1) as f32;
675
676 (0..num_points)
677 .map(|i| (log_min + i as f32 * step).exp())
678 .collect()
679}
680
681fn interpolate_log(x: &[f32], y: &[f32], x_new: &[f32]) -> Vec<f32> {
683 x_new
684 .iter()
685 .map(|&freq| {
686 let idx = x.partition_point(|&f| f < freq).min(x.len() - 1);
687
688 if idx == 0 {
689 return y[0];
690 }
691
692 let x0 = x[idx - 1];
693 let x1 = x[idx];
694 let y0 = y[idx - 1];
695 let y1 = y[idx];
696
697 if x1 <= x0 {
698 return y0;
699 }
700
701 let t = (freq - x0) / (x1 - x0);
702 y0 + t * (y1 - y0)
703 })
704 .collect()
705}
706
707fn interpolate_log_phase(x: &[f32], phase_deg: &[f32], x_new: &[f32]) -> Vec<f32> {
710 x_new
711 .iter()
712 .map(|&freq| {
713 let idx = x.partition_point(|&f| f < freq).min(x.len() - 1);
714
715 if idx == 0 {
716 return phase_deg[0];
717 }
718
719 let x0 = x[idx - 1];
720 let x1 = x[idx];
721
722 if x1 <= x0 {
723 return phase_deg[idx - 1];
724 }
725
726 let t = (freq - x0) / (x1 - x0);
727
728 let p0 = phase_deg[idx - 1];
730 let p1 = phase_deg[idx];
731 let mut diff = p1 - p0;
732 diff -= 360.0 * (diff / 360.0).round();
734 p0 + t * diff
735 })
736 .collect()
737}
738
739#[derive(Debug, Clone)]
745pub struct AnalysisResult {
746 pub frequencies: Vec<f32>,
748 pub spl_db: Vec<f32>,
750 pub phase_deg: Vec<f32>,
752 pub estimated_lag_samples: isize,
754 pub impulse_response: Vec<f32>,
756 pub impulse_time_ms: Vec<f32>,
758 pub excess_group_delay_ms: Vec<f32>,
760 pub thd_percent: Vec<f32>,
762 pub harmonic_distortion_db: Vec<Vec<f32>>,
764 pub rt60_ms: Vec<f32>,
766 pub clarity_c50_db: Vec<f32>,
768 pub clarity_c80_db: Vec<f32>,
770 pub spectrogram_db: Vec<Vec<f32>>,
772}
773
774pub fn analyze_recording(
785 recorded_path: &Path,
786 reference_signal: &[f32],
787 sample_rate: u32,
788 sweep_range: Option<(f32, f32)>,
789) -> Result<AnalysisResult, String> {
790 log::debug!("[FFT Analysis] Loading recorded file: {:?}", recorded_path);
792 let recorded = load_wav_mono(recorded_path)?;
793 log::debug!(
794 "[FFT Analysis] Loaded {} samples from recording",
795 recorded.len()
796 );
797 log::debug!(
798 "[FFT Analysis] Reference has {} samples",
799 reference_signal.len()
800 );
801
802 if recorded.is_empty() {
803 return Err("Recorded signal is empty!".to_string());
804 }
805 if reference_signal.is_empty() {
806 return Err("Reference signal is empty!".to_string());
807 }
808
809 let recorded = &recorded[..];
811 let reference = reference_signal;
812
813 if log::log_enabled!(log::Level::Debug) {
815 let ref_max = reference
816 .iter()
817 .map(|&x| x.abs())
818 .fold(0.0_f32, |a, b| a.max(b));
819 let rec_max = recorded
820 .iter()
821 .map(|&x| x.abs())
822 .fold(0.0_f32, |a, b| a.max(b));
823 let ref_rms =
824 (reference.iter().map(|&x| x * x).sum::<f32>() / reference.len() as f32).sqrt();
825 let rec_rms =
826 (recorded.iter().map(|&x| x * x).sum::<f32>() / recorded.len() as f32).sqrt();
827
828 log::debug!(
829 "[FFT Analysis] Reference: max={:.4}, RMS={:.4}",
830 ref_max,
831 ref_rms
832 );
833 log::debug!(
834 "[FFT Analysis] Recorded: max={:.4}, RMS={:.4}",
835 rec_max,
836 rec_rms
837 );
838 log::debug!(
839 "[FFT Analysis] First 5 reference samples: {:?}",
840 &reference[..5.min(reference.len())]
841 );
842 log::debug!(
843 "[FFT Analysis] First 5 recorded samples: {:?}",
844 &recorded[..5.min(recorded.len())]
845 );
846
847 let check_len = reference.len().min(recorded.len());
848 let mut identical_count = 0;
849 for (r, c) in reference[..check_len]
850 .iter()
851 .zip(recorded[..check_len].iter())
852 {
853 if (r - c).abs() < 1e-6 {
854 identical_count += 1;
855 }
856 }
857 log::debug!(
858 "[FFT Analysis] Identical samples: {}/{} ({:.1}%)",
859 identical_count,
860 check_len,
861 identical_count as f32 * 100.0 / check_len as f32
862 );
863 }
864
865 let lag = estimate_lag(reference, recorded)?;
867
868 log::debug!(
869 "[FFT Analysis] Estimated lag: {} samples ({:.2} ms)",
870 lag,
871 lag as f32 * 1000.0 / sample_rate as f32
872 );
873
874 let (aligned_ref, aligned_rec) = if lag >= 0 {
877 let lag_usize = lag as usize;
878 if lag_usize >= recorded.len() {
879 return Err("Lag is larger than recorded signal length".to_string());
880 }
881 (reference, &recorded[lag_usize..])
883 } else {
884 let lag_usize = (-lag) as usize;
886 if lag_usize >= reference.len() {
887 return Err("Negative lag is larger than reference signal length".to_string());
888 }
889 (&reference[lag_usize..], recorded)
891 };
892
893 log::debug!(
894 "[FFT Analysis] Aligned lengths: ref={}, rec={} (tail included)",
895 aligned_ref.len(),
896 aligned_rec.len()
897 );
898
899 let fft_size = next_power_of_two(aligned_ref.len().max(aligned_rec.len()));
901
902 let ref_spectrum = compute_fft(aligned_ref, fft_size, WindowType::Tukey(0.1))?;
903 let rec_spectrum = compute_fft(aligned_rec, fft_size, WindowType::Tukey(0.1))?;
904
905 let num_output_points = 2000;
907 let log_start = 20.0_f32.ln();
908 let log_end = 20000.0_f32.ln();
909
910 let mut frequencies = Vec::with_capacity(num_output_points);
911 let mut spl_db = Vec::with_capacity(num_output_points);
912 let mut phase_deg = Vec::with_capacity(num_output_points);
913
914 let freq_resolution = sample_rate as f32 / fft_size as f32;
915 let num_bins = fft_size / 2; let mut skipped_count = 0;
919 for i in 0..num_output_points {
920 let target_freq =
922 (log_start + (log_end - log_start) * i as f32 / (num_output_points - 1) as f32).exp();
923
924 let octave_fraction = 1.0 / 48.0;
927 let freq_lower = target_freq * 2.0_f32.powf(-octave_fraction);
928 let freq_upper = target_freq * 2.0_f32.powf(octave_fraction);
929
930 let bin_lower = ((freq_lower / freq_resolution).floor() as usize).max(1);
932 let bin_upper = ((freq_upper / freq_resolution).ceil() as usize).min(num_bins);
933
934 if bin_lower > bin_upper || bin_upper >= ref_spectrum.len() {
935 if skipped_count < 5 {
936 log::debug!(
937 "[FFT Analysis] Skipping freq {:.1} Hz: bin_lower={}, bin_upper={}, ref_spectrum.len()={}",
938 target_freq,
939 bin_lower,
940 bin_upper,
941 ref_spectrum.len()
942 );
943 }
944 skipped_count += 1;
945 continue; }
947
948 let mut sum_magnitude = 0.0;
950 let mut sum_sin = 0.0; let mut sum_cos = 0.0;
952 let mut bin_count = 0;
953
954 for k in bin_lower..=bin_upper {
955 if k >= ref_spectrum.len() {
956 break;
957 }
958
959 let ref_mag_sq = ref_spectrum[k].norm_sqr();
962 let transfer_function = if ref_mag_sq > 1e-20 {
963 rec_spectrum[k] / ref_spectrum[k]
964 } else {
965 Complex::new(0.0, 0.0)
966 };
967 let magnitude = transfer_function.norm();
968
969 let cross_spectrum = ref_spectrum[k].conj() * rec_spectrum[k];
971 let phase_rad = cross_spectrum.arg();
972
973 sum_magnitude += magnitude;
975 sum_sin += phase_rad.sin();
976 sum_cos += phase_rad.cos();
977 bin_count += 1;
978 }
979
980 if bin_count == 0 {
981 continue; }
983
984 let avg_magnitude = sum_magnitude / bin_count as f32;
986
987 let db = 20.0 * avg_magnitude.max(1e-10).log10();
989
990 if frequencies.len() < 5 {
991 log::debug!(
992 "[FFT Analysis] freq={:.1} Hz: avg_magnitude={:.6}, dB={:.2}",
993 target_freq,
994 avg_magnitude,
995 db
996 );
997 }
998
999 let avg_phase_rad = sum_sin.atan2(sum_cos);
1001 let phase = avg_phase_rad * 180.0 / PI;
1002
1003 frequencies.push(target_freq);
1004 spl_db.push(db);
1005 phase_deg.push(phase);
1006 }
1007
1008 log::debug!(
1009 "[FFT Analysis] Generated {} frequency points for CSV output",
1010 frequencies.len()
1011 );
1012 log::debug!(
1013 "[FFT Analysis] Skipped {} frequency points (out of {})",
1014 skipped_count,
1015 num_output_points
1016 );
1017
1018 if log::log_enabled!(log::Level::Debug) && !spl_db.is_empty() {
1019 let min_spl = spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1020 let max_spl = spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1021 log::debug!(
1022 "[FFT Analysis] SPL range: {:.2} dB to {:.2} dB",
1023 min_spl,
1024 max_spl
1025 );
1026 }
1027
1028 let mut transfer_function = vec![Complex::new(0.0, 0.0); fft_size];
1031 for k in 0..fft_size {
1032 let ref_mag_sq = ref_spectrum[k].norm_sqr();
1035 if ref_mag_sq > 1e-20 {
1036 transfer_function[k] = rec_spectrum[k] / ref_spectrum[k];
1037 }
1038 }
1039
1040 let ifft = plan_fft_inverse(fft_size);
1042 ifft.process(&mut transfer_function);
1043
1044 let norm = 1.0 / fft_size as f32;
1049 let mut impulse_response: Vec<f32> = transfer_function.iter().map(|c| c.re * norm).collect();
1050
1051 let peak_idx = impulse_response
1055 .iter()
1056 .enumerate()
1057 .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1058 .map(|(i, _)| i)
1059 .unwrap_or(0);
1060
1061 let pre_ring_samples = (0.005 * sample_rate as f32) as usize; let shift_amount = peak_idx.saturating_sub(pre_ring_samples);
1064
1065 if shift_amount > 0 {
1066 impulse_response.rotate_left(shift_amount);
1067 log::info!(
1068 "[FFT Analysis] IR peak was at index {}, shifted by {} samples to put peak near beginning",
1069 peak_idx,
1070 shift_amount
1071 );
1072 }
1073
1074 let _ir_duration_sec = fft_size as f32 / sample_rate as f32;
1076 let impulse_time_ms: Vec<f32> = (0..fft_size)
1077 .map(|i| i as f32 / sample_rate as f32 * 1000.0)
1078 .collect();
1079
1080 let (thd_percent, harmonic_distortion_db) = if let Some((start, end)) = sweep_range {
1082 let duration = reference_signal.len() as f32 / sample_rate as f32;
1085 compute_thd_from_ir(
1086 &impulse_response,
1087 sample_rate as f32,
1088 &frequencies,
1089 &spl_db,
1090 start,
1091 end,
1092 duration,
1093 )
1094 } else {
1095 (vec![0.0; frequencies.len()], Vec::new())
1096 };
1097
1098 let excess_group_delay_ms = vec![0.0; frequencies.len()];
1101
1102 let ir_max = impulse_response.iter().fold(0.0f32, |a, &b| a.max(b.abs()));
1105 let ir_len = impulse_response.len();
1106 log::info!(
1107 "[Analysis] Impulse response: len={}, max_abs={:.6}, sample_rate={}",
1108 ir_len,
1109 ir_max,
1110 sample_rate
1111 );
1112
1113 let rt60_ms = compute_rt60_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1114 let (clarity_c50_db, clarity_c80_db) =
1115 compute_clarity_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1116
1117 if !rt60_ms.is_empty() {
1119 let rt60_min = rt60_ms.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1120 let rt60_max = rt60_ms.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1121 log::info!(
1122 "[Analysis] RT60 range: {:.1} - {:.1} ms",
1123 rt60_min,
1124 rt60_max
1125 );
1126 }
1127 if !clarity_c50_db.is_empty() {
1128 let c50_min = clarity_c50_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1129 let c50_max = clarity_c50_db
1130 .iter()
1131 .fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1132 log::info!(
1133 "[Analysis] Clarity C50 range: {:.1} - {:.1} dB",
1134 c50_min,
1135 c50_max
1136 );
1137 }
1138
1139 let (spectrogram_db, _, _) =
1141 compute_spectrogram(&impulse_response, sample_rate as f32, 512, 128);
1142
1143 Ok(AnalysisResult {
1144 frequencies,
1145 spl_db,
1146 phase_deg,
1147 estimated_lag_samples: lag,
1148 impulse_response,
1149 impulse_time_ms,
1150 excess_group_delay_ms,
1151 thd_percent,
1152 harmonic_distortion_db,
1153 rt60_ms,
1154 clarity_c50_db,
1155 clarity_c80_db,
1156 spectrogram_db,
1157 })
1158}
1159
1160fn compute_thd_from_ir(
1164 impulse: &[f32],
1165 sample_rate: f32,
1166 frequencies: &[f32],
1167 fundamental_db: &[f32],
1168 start_freq: f32,
1169 end_freq: f32,
1170 duration: f32,
1171) -> (Vec<f32>, Vec<Vec<f32>>) {
1172 if frequencies.is_empty() {
1173 return (Vec::new(), Vec::new());
1174 }
1175
1176 let n = impulse.len();
1177 if n == 0 {
1178 return (vec![0.0; frequencies.len()], Vec::new());
1179 }
1180
1181 let num_harmonics = 4; let mut harmonics_db = vec![vec![-120.0; frequencies.len()]; num_harmonics];
1184
1185 let peak_idx = impulse
1187 .iter()
1188 .enumerate()
1189 .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1190 .map(|(i, _)| i)
1191 .unwrap_or(0);
1192
1193 let sweep_ratio = end_freq / start_freq;
1194 log::debug!(
1195 "[THD] Impulse len={}, peak_idx={}, duration={:.3}s, sweep {:.0}-{:.0} Hz (ratio {:.1})",
1196 n,
1197 peak_idx,
1198 duration,
1199 start_freq,
1200 end_freq,
1201 sweep_ratio
1202 );
1203
1204 for (k_idx, harmonic_db) in harmonics_db.iter_mut().enumerate().take(num_harmonics) {
1206 let harmonic_order = k_idx + 2; let dt = duration * (harmonic_order as f32).ln() / sweep_ratio.ln();
1211 let dn = (dt * sample_rate).round() as isize;
1212
1213 let center = peak_idx as isize - dn;
1215 let center_wrapped = center.rem_euclid(n as isize) as usize;
1216
1217 let dt_next_rel = duration
1219 * ((harmonic_order as f32 + 1.0).ln() - (harmonic_order as f32).ln())
1220 / sweep_ratio.ln();
1221 let win_len = ((dt_next_rel * sample_rate * 0.8).max(256.0) as usize).min(n / 2);
1222
1223 let mut harmonic_ir = vec![0.0f32; win_len];
1225 let mut max_harmonic_sample = 0.0f32;
1226 for (i, harmonic_ir_val) in harmonic_ir.iter_mut().enumerate() {
1227 let src_idx =
1228 (center - (win_len as isize / 2) + i as isize).rem_euclid(n as isize) as usize;
1229 let w = 0.5 * (1.0 - (2.0 * PI * i as f32 / (win_len as f32 - 1.0)).cos());
1231 *harmonic_ir_val = impulse[src_idx] * w;
1232 max_harmonic_sample = max_harmonic_sample.max(harmonic_ir_val.abs());
1233 }
1234
1235 if k_idx == 0 {
1236 log::debug!(
1237 "[THD] H{}: dt={:.3}s, dn={}, center_wrapped={}, win_len={}, max_sample={:.2e}",
1238 harmonic_order,
1239 dt,
1240 dn,
1241 center_wrapped,
1242 win_len,
1243 max_harmonic_sample
1244 );
1245 }
1246
1247 let fft_size = next_power_of_two(win_len);
1249 let nyquist_bin = fft_size / 2; if let Ok(spectrum) = compute_fft_padded(&harmonic_ir, fft_size) {
1251 let freq_resolution = sample_rate / fft_size as f32;
1252
1253 for (i, &f) in frequencies.iter().enumerate() {
1254 let bin = (f / freq_resolution).round() as usize;
1255 if bin < nyquist_bin && bin < spectrum.len() {
1257 let mag = spectrum[bin].norm();
1260 if mag > 1e-6 {
1262 harmonic_db[i] = 20.0 * mag.log10();
1263 }
1264 }
1265 }
1266 }
1267 }
1268
1269 if !frequencies.is_empty() {
1271 let mid_idx = frequencies.len() / 2;
1272 log::debug!(
1273 "[THD] Harmonic levels at {:.0} Hz: H2={:.1}dB, H3={:.1}dB, H4={:.1}dB, H5={:.1}dB, fundamental={:.1}dB",
1274 frequencies[mid_idx],
1275 harmonics_db[0][mid_idx],
1276 harmonics_db[1][mid_idx],
1277 harmonics_db[2][mid_idx],
1278 harmonics_db[3][mid_idx],
1279 fundamental_db[mid_idx]
1280 );
1281 }
1282
1283 let mut thd_percent = Vec::with_capacity(frequencies.len());
1285 for i in 0..frequencies.len() {
1286 let fundamental = 10.0f32.powf(fundamental_db[i] / 20.0);
1287 let mut harmonic_sum_sq = 0.0;
1288
1289 for harmonic_db in harmonics_db.iter().take(num_harmonics) {
1290 let h_mag = 10.0f32.powf(harmonic_db[i] / 20.0);
1291 harmonic_sum_sq += h_mag * h_mag;
1292 }
1293
1294 let thd = if fundamental > 1e-9 {
1296 (harmonic_sum_sq.sqrt() / fundamental) * 100.0
1297 } else {
1298 0.0
1299 };
1300 thd_percent.push(thd);
1301 }
1302
1303 if !thd_percent.is_empty() {
1305 let max_thd = thd_percent.iter().fold(0.0f32, |a, &b| a.max(b));
1306 let min_thd = thd_percent.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1307 log::debug!("[THD] THD range: {:.4}% to {:.4}%", min_thd, max_thd);
1308 }
1309
1310 (thd_percent, harmonics_db)
1311}
1312
1313pub fn write_analysis_csv(
1326 result: &AnalysisResult,
1327 output_path: &Path,
1328 compensation: Option<&MicrophoneCompensation>,
1329) -> Result<(), String> {
1330 use std::fs::File;
1331 use std::io::Write;
1332
1333 log::info!(
1334 "[write_analysis_csv] Writing {} frequency points to {:?}",
1335 result.frequencies.len(),
1336 output_path
1337 );
1338
1339 if let Some(comp) = compensation {
1340 log::info!(
1341 "[write_analysis_csv] Applying inverse microphone compensation ({} calibration points)",
1342 comp.frequencies.len()
1343 );
1344 }
1345
1346 if result.frequencies.is_empty() {
1347 return Err("Cannot write CSV: Analysis result has no frequency points!".to_string());
1348 }
1349
1350 let mut file =
1351 File::create(output_path).map_err(|e| format!("Failed to create CSV file: {}", e))?;
1352
1353 writeln!(
1355 file,
1356 "frequency_hz,spl_db,phase_deg,thd_percent,rt60_ms,c50_db,c80_db,group_delay_ms"
1357 )
1358 .map_err(|e| format!("Failed to write header: {}", e))?;
1359
1360 for i in 0..result.frequencies.len() {
1362 let freq = result.frequencies[i];
1363 let mut spl = result.spl_db[i];
1364
1365 if let Some(comp) = compensation {
1368 let mic_deviation = comp.interpolate_at(freq);
1369 spl -= mic_deviation;
1370 }
1371
1372 let phase = result.phase_deg[i];
1373 let thd = result.thd_percent.get(i).copied().unwrap_or(0.0);
1374 let rt60 = result.rt60_ms.get(i).copied().unwrap_or(0.0);
1375 let c50 = result.clarity_c50_db.get(i).copied().unwrap_or(0.0);
1376 let c80 = result.clarity_c80_db.get(i).copied().unwrap_or(0.0);
1377 let gd = result.excess_group_delay_ms.get(i).copied().unwrap_or(0.0);
1378
1379 writeln!(
1380 file,
1381 "{:.6},{:.3},{:.6},{:.6},{:.3},{:.3},{:.3},{:.6}",
1382 freq, spl, phase, thd, rt60, c50, c80, gd
1383 )
1384 .map_err(|e| format!("Failed to write data: {}", e))?;
1385 }
1386
1387 log::info!(
1388 "[write_analysis_csv] Successfully wrote {} data rows to CSV",
1389 result.frequencies.len()
1390 );
1391
1392 Ok(())
1393}
1394
1395pub fn read_analysis_csv(csv_path: &Path) -> Result<AnalysisResult, String> {
1400 use std::fs::File;
1401 use std::io::{BufRead, BufReader};
1402
1403 let file = File::open(csv_path).map_err(|e| format!("Failed to open CSV: {}", e))?;
1404 let reader = BufReader::new(file);
1405 let mut lines = reader.lines();
1406
1407 let header = lines
1409 .next()
1410 .ok_or("Empty CSV file")?
1411 .map_err(|e| format!("Failed to read header: {}", e))?;
1412
1413 let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
1414 let has_extended_format = columns.len() >= 8;
1415
1416 let mut frequencies = Vec::new();
1417 let mut spl_db = Vec::new();
1418 let mut phase_deg = Vec::new();
1419 let mut thd_percent = Vec::new();
1420 let mut rt60_ms = Vec::new();
1421 let mut clarity_c50_db = Vec::new();
1422 let mut clarity_c80_db = Vec::new();
1423 let mut excess_group_delay_ms = Vec::new();
1424
1425 for line in lines {
1426 let line = line.map_err(|e| format!("Failed to read line: {}", e))?;
1427 let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
1428
1429 if parts.len() < 3 {
1430 continue;
1431 }
1432
1433 let freq: f32 = parts[0].parse().unwrap_or(0.0);
1434 let spl: f32 = parts[1].parse().unwrap_or(0.0);
1435 let phase: f32 = parts[2].parse().unwrap_or(0.0);
1436
1437 frequencies.push(freq);
1438 spl_db.push(spl);
1439 phase_deg.push(phase);
1440
1441 if has_extended_format && parts.len() >= 8 {
1442 thd_percent.push(parts[3].parse().unwrap_or(0.0));
1443 rt60_ms.push(parts[4].parse().unwrap_or(0.0));
1444 clarity_c50_db.push(parts[5].parse().unwrap_or(0.0));
1445 clarity_c80_db.push(parts[6].parse().unwrap_or(0.0));
1446 excess_group_delay_ms.push(parts[7].parse().unwrap_or(0.0));
1447 }
1448 }
1449
1450 let n = frequencies.len();
1452 if thd_percent.is_empty() {
1453 thd_percent = vec![0.0; n];
1454 rt60_ms = vec![0.0; n];
1455 clarity_c50_db = vec![0.0; n];
1456 clarity_c80_db = vec![0.0; n];
1457 excess_group_delay_ms = vec![0.0; n];
1458 }
1459
1460 Ok(AnalysisResult {
1461 frequencies,
1462 spl_db,
1463 phase_deg,
1464 estimated_lag_samples: 0,
1465 impulse_response: Vec::new(),
1466 impulse_time_ms: Vec::new(),
1467 thd_percent,
1468 harmonic_distortion_db: Vec::new(),
1469 rt60_ms,
1470 clarity_c50_db,
1471 clarity_c80_db,
1472 excess_group_delay_ms,
1473 spectrogram_db: Vec::new(),
1474 })
1475}
1476
1477#[derive(Debug, Clone, Copy)]
1479enum WindowType {
1480 Hann,
1481 Tukey(f32), }
1483
1484fn estimate_lag(reference: &[f32], recorded: &[f32]) -> Result<isize, String> {
1495 let len = reference.len().min(recorded.len());
1496
1497 let fft_size = next_power_of_two(len * 2);
1499
1500 let ref_fft = compute_fft(reference, fft_size, WindowType::Hann)?;
1502 let rec_fft = compute_fft(recorded, fft_size, WindowType::Hann)?;
1503
1504 let mut cross_corr_fft: Vec<Complex<f32>> = ref_fft
1506 .iter()
1507 .zip(rec_fft.iter())
1508 .map(|(x, y)| x.conj() * y)
1509 .collect();
1510
1511 let ifft = plan_fft_inverse(fft_size);
1513 ifft.process(&mut cross_corr_fft);
1514
1515 let mut max_val = 0.0;
1517 let mut max_idx = 0;
1518
1519 for (i, &val) in cross_corr_fft.iter().enumerate() {
1520 let magnitude = val.norm();
1521 if magnitude > max_val {
1522 max_val = magnitude;
1523 max_idx = i;
1524 }
1525 }
1526
1527 Ok(if max_idx <= fft_size / 2 {
1529 max_idx as isize
1530 } else {
1531 max_idx as isize - fft_size as isize
1532 })
1533}
1534
1535fn compute_fft(
1545 signal: &[f32],
1546 fft_size: usize,
1547 window_type: WindowType,
1548) -> Result<Vec<Complex<f32>>, String> {
1549 let windowed = match window_type {
1551 WindowType::Hann => apply_hann_window(signal),
1552 WindowType::Tukey(alpha) => apply_tukey_window(signal, alpha),
1553 };
1554
1555 compute_fft_padded(&windowed, fft_size)
1556}
1557
1558fn compute_fft_padded(signal: &[f32], fft_size: usize) -> Result<Vec<Complex<f32>>, String> {
1560 let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
1562 for (dst, &src) in buffer.iter_mut().zip(signal.iter()) {
1563 dst.re = src;
1564 }
1565
1566 let fft = plan_fft_forward(fft_size);
1568 fft.process(&mut buffer);
1569
1570 let norm_factor = 1.0 / fft_size as f32;
1572 for val in buffer.iter_mut() {
1573 *val *= norm_factor;
1574 }
1575
1576 Ok(buffer)
1577}
1578
1579fn apply_hann_window(signal: &[f32]) -> Vec<f32> {
1581 let len = signal.len();
1582 if len < 2 {
1583 return signal.to_vec();
1584 }
1585 signal
1586 .iter()
1587 .enumerate()
1588 .map(|(i, &x)| {
1589 let window = 0.5 * (1.0 - (2.0 * PI * i as f32 / (len - 1) as f32).cos());
1590 x * window
1591 })
1592 .collect()
1593}
1594
1595fn apply_tukey_window(signal: &[f32], alpha: f32) -> Vec<f32> {
1600 let len = signal.len();
1601 if len < 2 {
1602 return signal.to_vec();
1603 }
1604
1605 let alpha = alpha.clamp(0.0, 1.0);
1606 let limit = (alpha * (len as f32 - 1.0) / 2.0).round() as usize;
1607
1608 if limit == 0 {
1609 return signal.to_vec();
1610 }
1611
1612 signal
1613 .iter()
1614 .enumerate()
1615 .map(|(i, &x)| {
1616 let w = if i < limit {
1617 0.5 * (1.0 - (PI * i as f32 / limit as f32).cos())
1619 } else if i >= len - limit {
1620 let n = len - 1 - i;
1622 0.5 * (1.0 - (PI * n as f32 / limit as f32).cos())
1623 } else {
1624 1.0
1626 };
1627 x * w
1628 })
1629 .collect()
1630}
1631
1632fn next_power_of_two(n: usize) -> usize {
1634 if n == 0 {
1635 return 1;
1636 }
1637 n.next_power_of_two()
1638}
1639
1640fn load_wav_mono_channel(path: &Path, channel_index: Option<usize>) -> Result<Vec<f32>, String> {
1647 let mut reader =
1648 WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
1649
1650 let spec = reader.spec();
1651 let channels = spec.channels as usize;
1652
1653 log::info!(
1654 "[load_wav_mono_channel] WAV file: {} channels, {} Hz, {:?} format",
1655 channels,
1656 spec.sample_rate,
1657 spec.sample_format
1658 );
1659
1660 let samples: Result<Vec<f32>, _> = match spec.sample_format {
1662 hound::SampleFormat::Float => reader.samples::<f32>().collect(),
1663 hound::SampleFormat::Int => reader
1664 .samples::<i32>()
1665 .map(|s| s.map(|v| v as f32 / i32::MAX as f32))
1666 .collect(),
1667 };
1668
1669 let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
1670 log::info!(
1671 "[load_wav_mono_channel] Read {} total samples",
1672 samples.len()
1673 );
1674
1675 if channels == 1 {
1677 log::info!(
1678 "[load_wav_mono_channel] File is already mono, returning {} samples",
1679 samples.len()
1680 );
1681 return Ok(samples);
1682 }
1683
1684 if let Some(ch_idx) = channel_index {
1686 if ch_idx >= channels {
1688 return Err(format!(
1689 "Channel index {} out of range (file has {} channels)",
1690 ch_idx, channels
1691 ));
1692 }
1693 log::info!(
1694 "[load_wav_mono_channel] Extracting channel {} from {} channels",
1695 ch_idx,
1696 channels
1697 );
1698 Ok(samples
1699 .chunks(channels)
1700 .map(|chunk| chunk[ch_idx])
1701 .collect())
1702 } else {
1703 log::info!(
1705 "[load_wav_mono_channel] Averaging {} channels to mono",
1706 channels
1707 );
1708 Ok(samples
1709 .chunks(channels)
1710 .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
1711 .collect())
1712 }
1713}
1714
1715fn load_wav_mono(path: &Path) -> Result<Vec<f32>, String> {
1717 load_wav_mono_channel(path, None)
1718}
1719
1720pub fn smooth_response_f64(frequencies: &[f64], values: &[f64], octaves: f64) -> Vec<f64> {
1729 if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
1730 return values.to_vec();
1731 }
1732
1733 let n = values.len();
1734
1735 let mut prefix = Vec::with_capacity(n + 1);
1737 prefix.push(0.0);
1738 for &v in values {
1739 prefix.push(prefix.last().unwrap() + v);
1740 }
1741
1742 let ratio = 2.0_f64.powf(octaves / 2.0);
1743 let mut smoothed = Vec::with_capacity(n);
1744 let mut lo = 0usize;
1745 let mut hi = 0usize;
1746
1747 for (i, ¢er_freq) in frequencies.iter().enumerate() {
1748 if center_freq <= 0.0 {
1749 smoothed.push(values[i]);
1750 continue;
1751 }
1752
1753 let low_freq = center_freq / ratio;
1754 let high_freq = center_freq * ratio;
1755
1756 while lo < n && frequencies[lo] < low_freq {
1758 lo += 1;
1759 }
1760 while hi < n && frequencies[hi] <= high_freq {
1762 hi += 1;
1763 }
1764
1765 let count = hi - lo;
1766 if count > 0 {
1767 smoothed.push((prefix[hi] - prefix[lo]) / count as f64);
1768 } else {
1769 smoothed.push(values[i]);
1770 }
1771 }
1772
1773 smoothed
1774}
1775
1776pub fn smooth_response_f32(frequencies: &[f32], values: &[f32], octaves: f32) -> Vec<f32> {
1781 if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
1782 return values.to_vec();
1783 }
1784
1785 let n = values.len();
1786
1787 let mut prefix = Vec::with_capacity(n + 1);
1789 prefix.push(0.0_f64);
1790 for &v in values {
1791 prefix.push(prefix.last().unwrap() + v as f64);
1792 }
1793
1794 let ratio = 2.0_f32.powf(octaves / 2.0);
1795 let mut smoothed = Vec::with_capacity(n);
1796 let mut lo = 0usize;
1797 let mut hi = 0usize;
1798
1799 for (i, ¢er_freq) in frequencies.iter().enumerate() {
1800 if center_freq <= 0.0 {
1801 smoothed.push(values[i]);
1802 continue;
1803 }
1804
1805 let low_freq = center_freq / ratio;
1806 let high_freq = center_freq * ratio;
1807
1808 while lo < n && frequencies[lo] < low_freq {
1810 lo += 1;
1811 }
1812 while hi < n && frequencies[hi] <= high_freq {
1814 hi += 1;
1815 }
1816
1817 let count = hi - lo;
1818 if count > 0 {
1819 smoothed.push(((prefix[hi] - prefix[lo]) / count as f64) as f32);
1820 } else {
1821 smoothed.push(values[i]);
1822 }
1823 }
1824
1825 smoothed
1826}
1827
1828pub fn compute_group_delay(frequencies: &[f32], phase_deg: &[f32]) -> Vec<f32> {
1834 if frequencies.len() < 2 {
1835 return vec![0.0; frequencies.len()];
1836 }
1837
1838 let unwrapped = unwrap_phase_deg(phase_deg);
1840
1841 let mut group_delay_ms = Vec::with_capacity(frequencies.len());
1842
1843 for i in 0..frequencies.len() {
1844 let delay = if i == 0 {
1845 let df = frequencies[1] - frequencies[0];
1847 let dp = unwrapped[1] - unwrapped[0];
1848 if df.abs() > 1e-6 {
1849 -dp / df / 360.0 * 1000.0 } else {
1851 0.0
1852 }
1853 } else if i == frequencies.len() - 1 {
1854 let df = frequencies[i] - frequencies[i - 1];
1856 let dp = unwrapped[i] - unwrapped[i - 1];
1857 if df.abs() > 1e-6 {
1858 -dp / df / 360.0 * 1000.0
1859 } else {
1860 0.0
1861 }
1862 } else {
1863 let df = frequencies[i + 1] - frequencies[i - 1];
1865 let dp = unwrapped[i + 1] - unwrapped[i - 1];
1866 if df.abs() > 1e-6 {
1867 -dp / df / 360.0 * 1000.0
1868 } else {
1869 0.0
1870 }
1871 };
1872 group_delay_ms.push(delay);
1873 }
1874
1875 group_delay_ms
1876}
1877
1878fn unwrap_phase_deg(phase_deg: &[f32]) -> Vec<f32> {
1882 if phase_deg.is_empty() {
1883 return Vec::new();
1884 }
1885
1886 let mut unwrapped = Vec::with_capacity(phase_deg.len());
1887 unwrapped.push(phase_deg[0]);
1888
1889 for i in 1..phase_deg.len() {
1890 let diff = phase_deg[i] - phase_deg[i - 1];
1891 let wrapped_diff = diff - 360.0 * (diff / 360.0).round();
1892 unwrapped.push(unwrapped[i - 1] + wrapped_diff);
1893 }
1894
1895 unwrapped
1896}
1897
1898pub fn compute_impulse_response_from_fr(
1906 frequencies: &[f32],
1907 magnitude_db: &[f32],
1908 phase_deg: &[f32],
1909 sample_rate: f32,
1910) -> (Vec<f32>, Vec<f32>) {
1911 let fft_size = 1024;
1912 let half = fft_size / 2; let freq_bin = sample_rate / fft_size as f32;
1914
1915 let unwrapped_phase = unwrap_phase_deg(phase_deg);
1917
1918 let mut spectrum = vec![Complex::new(0.0_f32, 0.0); fft_size];
1920
1921 for (k, spectrum_bin) in spectrum.iter_mut().enumerate().take(half + 1) {
1922 let f = k as f32 * freq_bin;
1923
1924 let (mag_db, phase_d) = interpolate_fr(frequencies, magnitude_db, &unwrapped_phase, f);
1926
1927 let mag_linear = 10.0_f32.powf(mag_db / 20.0);
1928 let phase_rad = phase_d * PI / 180.0;
1929
1930 *spectrum_bin = Complex::new(mag_linear * phase_rad.cos(), mag_linear * phase_rad.sin());
1931 }
1932
1933 for k in 1..half {
1935 spectrum[fft_size - k] = spectrum[k].conj();
1936 }
1937
1938 let ifft = plan_fft_inverse(fft_size);
1940 ifft.process(&mut spectrum);
1941
1942 let scale = 1.0 / fft_size as f32;
1944 let mut impulse: Vec<f32> = spectrum.iter().map(|c| c.re * scale).collect();
1945
1946 let max_val = impulse.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
1948 if max_val > 0.0 {
1949 for v in &mut impulse {
1950 *v /= max_val;
1951 }
1952 }
1953
1954 let time_step = 1.0 / sample_rate;
1955 let times: Vec<f32> = (0..fft_size)
1956 .map(|i| i as f32 * time_step * 1000.0)
1957 .collect();
1958
1959 (times, impulse)
1960}
1961
1962fn interpolate_fr(
1967 frequencies: &[f32],
1968 magnitude_db: &[f32],
1969 unwrapped_phase_deg: &[f32],
1970 target_freq: f32,
1971) -> (f32, f32) {
1972 if frequencies.is_empty() {
1973 return (0.0, 0.0);
1974 }
1975 if target_freq <= frequencies[0] {
1976 return (magnitude_db[0], unwrapped_phase_deg[0]);
1977 }
1978 let last = frequencies.len() - 1;
1979 if target_freq >= frequencies[last] {
1980 return (magnitude_db[last], unwrapped_phase_deg[last]);
1981 }
1982
1983 let idx = match frequencies.binary_search_by(|f| f.partial_cmp(&target_freq).unwrap()) {
1985 Ok(i) => return (magnitude_db[i], unwrapped_phase_deg[i]),
1986 Err(i) => i, };
1988
1989 let f0 = frequencies[idx - 1];
1990 let f1 = frequencies[idx];
1991 let t = (target_freq - f0) / (f1 - f0);
1992
1993 let mag = magnitude_db[idx - 1] + t * (magnitude_db[idx] - magnitude_db[idx - 1]);
1994 let phase = unwrapped_phase_deg[idx - 1]
1995 + t * (unwrapped_phase_deg[idx] - unwrapped_phase_deg[idx - 1]);
1996 (mag, phase)
1997}
1998
1999fn compute_schroeder_decay(impulse: &[f32]) -> Vec<f32> {
2001 let mut energy = 0.0;
2002 let mut decay = vec![0.0; impulse.len()];
2003
2004 for i in (0..impulse.len()).rev() {
2006 energy += impulse[i] * impulse[i];
2007 decay[i] = energy;
2008 }
2009
2010 let max_energy = decay.first().copied().unwrap_or(1.0);
2012 if max_energy > 0.0 {
2013 for v in &mut decay {
2014 *v /= max_energy;
2015 }
2016 }
2017
2018 decay
2019}
2020
2021pub fn compute_rt60_broadband(impulse: &[f32], sample_rate: f32) -> f32 {
2024 let decay = compute_schroeder_decay(impulse);
2025 let decay_db: Vec<f32> = decay.iter().map(|&v| 10.0 * v.max(1e-9).log10()).collect();
2026
2027 let t_minus_5 = decay_db.iter().position(|&v| v < -5.0);
2029 let t_minus_25 = decay_db.iter().position(|&v| v < -25.0);
2030
2031 match (t_minus_5, t_minus_25) {
2032 (Some(start), Some(end)) => {
2033 if end > start {
2034 let dt = (end - start) as f32 / sample_rate; dt * 3.0 } else {
2037 0.0
2038 }
2039 }
2040 _ => 0.0,
2041 }
2042}
2043
2044pub fn compute_clarity_broadband(impulse: &[f32], sample_rate: f32) -> (f32, f32) {
2047 let mut energy_0_50 = 0.0;
2048 let mut energy_50_inf = 0.0;
2049 let mut energy_0_80 = 0.0;
2050 let mut energy_80_inf = 0.0;
2051
2052 let samp_50ms = (0.050 * sample_rate) as usize;
2053 let samp_80ms = (0.080 * sample_rate) as usize;
2054
2055 for (i, &samp) in impulse.iter().enumerate() {
2056 let sq = samp * samp;
2057
2058 if i < samp_50ms {
2059 energy_0_50 += sq;
2060 } else {
2061 energy_50_inf += sq;
2062 }
2063
2064 if i < samp_80ms {
2065 energy_0_80 += sq;
2066 } else {
2067 energy_80_inf += sq;
2068 }
2069 }
2070
2071 const MAX_CLARITY_DB: f32 = 60.0;
2074
2075 let c50 = if energy_50_inf > 1e-12 && energy_0_50 > 1e-12 {
2076 let ratio = energy_0_50 / energy_50_inf;
2077 (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2078 } else if energy_0_50 > energy_50_inf {
2079 MAX_CLARITY_DB } else {
2081 -MAX_CLARITY_DB };
2083
2084 let c80 = if energy_80_inf > 1e-12 && energy_0_80 > 1e-12 {
2085 let ratio = energy_0_80 / energy_80_inf;
2086 (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2087 } else if energy_80_inf > energy_0_80 {
2088 MAX_CLARITY_DB } else {
2090 -MAX_CLARITY_DB };
2092
2093 (c50, c80)
2094}
2095
2096pub fn compute_rt60_spectrum(impulse: &[f32], sample_rate: f32, frequencies: &[f32]) -> Vec<f32> {
2098 if impulse.is_empty() {
2099 return vec![0.0; frequencies.len()];
2100 }
2101
2102 let centers = [
2104 63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2105 ];
2106 let mut band_rt60s = Vec::with_capacity(centers.len());
2107 let mut valid_centers = Vec::with_capacity(centers.len());
2108
2109 for &freq in ¢ers {
2111 if freq >= sample_rate / 2.0 {
2113 continue;
2114 }
2115
2116 let mut biquad = Biquad::new(
2119 BiquadFilterType::Bandpass,
2120 freq as f64,
2121 sample_rate as f64,
2122 1.414,
2123 0.0,
2124 );
2125
2126 let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2128 biquad.process_block(&mut filtered);
2129 let filtered_f32: Vec<f32> = filtered.iter().map(|&x| x as f32).collect();
2130
2131 let rt60 = compute_rt60_broadband(&filtered_f32, sample_rate);
2133
2134 band_rt60s.push(rt60);
2135 valid_centers.push(freq);
2136 }
2137
2138 log::info!(
2140 "[RT60] Per-band values: {:?}",
2141 valid_centers
2142 .iter()
2143 .zip(band_rt60s.iter())
2144 .map(|(f, v)| format!("{:.0}Hz:{:.1}ms", f, v))
2145 .collect::<Vec<_>>()
2146 );
2147
2148 if valid_centers.is_empty() {
2149 return vec![0.0; frequencies.len()];
2150 }
2151
2152 interpolate_log(&valid_centers, &band_rt60s, frequencies)
2154}
2155
2156pub fn compute_clarity_spectrum(
2159 impulse: &[f32],
2160 sample_rate: f32,
2161 frequencies: &[f32],
2162) -> (Vec<f32>, Vec<f32>) {
2163 if impulse.is_empty() || frequencies.is_empty() {
2164 return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2165 }
2166
2167 let centers = [
2169 63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2170 ];
2171 let mut band_c50s = Vec::with_capacity(centers.len());
2172 let mut band_c80s = Vec::with_capacity(centers.len());
2173 let mut valid_centers = Vec::with_capacity(centers.len());
2174
2175 let samp_50ms = (0.050 * sample_rate) as usize;
2177 let samp_80ms = (0.080 * sample_rate) as usize;
2178
2179 for &freq in ¢ers {
2181 if freq >= sample_rate / 2.0 {
2182 continue;
2183 }
2184
2185 let mut biquad1 = Biquad::new(
2187 BiquadFilterType::Bandpass,
2188 freq as f64,
2189 sample_rate as f64,
2190 0.707, 0.0,
2192 );
2193 let mut biquad2 = Biquad::new(
2194 BiquadFilterType::Bandpass,
2195 freq as f64,
2196 sample_rate as f64,
2197 0.707,
2198 0.0,
2199 );
2200
2201 let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2202 biquad1.process_block(&mut filtered);
2203 biquad2.process_block(&mut filtered);
2204
2205 let mut energy_0_50 = 0.0f64;
2207 let mut energy_50_inf = 0.0f64;
2208 let mut energy_0_80 = 0.0f64;
2209 let mut energy_80_inf = 0.0f64;
2210
2211 for (i, &samp) in filtered.iter().enumerate() {
2212 let sq = samp * samp;
2213
2214 if i < samp_50ms {
2215 energy_0_50 += sq;
2216 } else {
2217 energy_50_inf += sq;
2218 }
2219
2220 if i < samp_80ms {
2221 energy_0_80 += sq;
2222 } else {
2223 energy_80_inf += sq;
2224 }
2225 }
2226
2227 const MAX_CLARITY_DB: f32 = 40.0;
2230 const MIN_ENERGY: f64 = 1e-20;
2231
2232 let c50 = if energy_50_inf > MIN_ENERGY && energy_0_50 > MIN_ENERGY {
2233 let ratio = energy_0_50 / energy_50_inf;
2234 (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2235 } else if energy_0_50 > energy_50_inf {
2236 MAX_CLARITY_DB
2237 } else {
2238 -MAX_CLARITY_DB
2239 };
2240
2241 let c80 = if energy_80_inf > MIN_ENERGY && energy_0_80 > MIN_ENERGY {
2242 let ratio = energy_0_80 / energy_80_inf;
2243 (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2244 } else if energy_0_80 > energy_80_inf {
2245 MAX_CLARITY_DB
2246 } else {
2247 -MAX_CLARITY_DB
2248 };
2249
2250 band_c50s.push(c50);
2251 band_c80s.push(c80);
2252 valid_centers.push(freq);
2253 }
2254
2255 log::info!(
2257 "[Clarity] Per-band C50: {:?}",
2258 valid_centers
2259 .iter()
2260 .zip(band_c50s.iter())
2261 .map(|(f, v)| format!("{:.0}Hz:{:.1}dB", f, v))
2262 .collect::<Vec<_>>()
2263 );
2264
2265 if valid_centers.is_empty() {
2266 return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2267 }
2268
2269 let c50_interp = interpolate_log(&valid_centers, &band_c50s, frequencies);
2271 let c80_interp = interpolate_log(&valid_centers, &band_c80s, frequencies);
2272
2273 (c50_interp, c80_interp)
2274}
2275
2276pub fn compute_spectrogram(
2280 impulse: &[f32],
2281 sample_rate: f32,
2282 window_size: usize,
2283 hop_size: usize,
2284) -> (Vec<Vec<f32>>, Vec<f32>, Vec<f32>) {
2285 use rustfft::num_complex::Complex;
2286
2287 if impulse.len() < window_size {
2288 return (Vec::new(), Vec::new(), Vec::new());
2289 }
2290
2291 let num_frames = (impulse.len() - window_size) / hop_size;
2292 let mut spectrogram = Vec::with_capacity(num_frames);
2293 let mut times = Vec::with_capacity(num_frames);
2294
2295 let window: Vec<f32> = (0..window_size)
2297 .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (window_size as f32 - 1.0)).cos()))
2298 .collect();
2299
2300 let fft = plan_fft_forward(window_size);
2302
2303 for i in 0..num_frames {
2304 let start = i * hop_size;
2305 let time_ms = (start as f32 / sample_rate) * 1000.0;
2306 times.push(time_ms);
2307
2308 let mut buffer: Vec<Complex<f32>> = (0..window_size)
2309 .map(|j| {
2310 let sample = impulse.get(start + j).copied().unwrap_or(0.0);
2311 Complex::new(sample * window[j], 0.0)
2312 })
2313 .collect();
2314
2315 fft.process(&mut buffer);
2316
2317 let magnitude_db: Vec<f32> = buffer[..window_size / 2]
2320 .iter()
2321 .map(|c| {
2322 let mag = c.norm();
2323 if mag > 1e-9 {
2324 20.0 * mag.log10()
2325 } else {
2326 -180.0
2327 }
2328 })
2329 .collect();
2330
2331 spectrogram.push(magnitude_db);
2332 }
2333
2334 let num_bins = window_size / 2;
2336 let freq_step = sample_rate / window_size as f32;
2337 let freqs: Vec<f32> = (0..num_bins).map(|i| i as f32 * freq_step).collect();
2338
2339 (spectrogram, freqs, times)
2340}
2341
2342pub fn find_db_point(
2353 frequencies: &[f32],
2354 magnitude_db: &[f32],
2355 target_db: f32,
2356 from_start: bool,
2357) -> Option<f32> {
2358 if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2359 return None;
2360 }
2361
2362 if from_start {
2363 for i in 0..magnitude_db.len() - 1 {
2364 let m0 = magnitude_db[i];
2365 let m1 = magnitude_db[i + 1];
2366
2367 if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2369 let denominator = m1 - m0;
2371 if denominator.abs() < 1e-9 {
2372 return Some(frequencies[i]);
2373 }
2374 let t = (target_db - m0) / denominator;
2375 return Some(frequencies[i] + t * (frequencies[i + 1] - frequencies[i]));
2376 }
2377 }
2378 } else {
2379 for i in (1..magnitude_db.len()).rev() {
2380 let m0 = magnitude_db[i];
2381 let m1 = magnitude_db[i - 1];
2382
2383 if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2385 let denominator = m1 - m0;
2386 if denominator.abs() < 1e-9 {
2387 return Some(frequencies[i]);
2388 }
2389 let t = (target_db - m0) / denominator;
2390 return Some(frequencies[i] + t * (frequencies[i - 1] - frequencies[i]));
2391 }
2392 }
2393 }
2394
2395 None
2396}
2397
2398pub fn compute_average_response(
2409 frequencies: &[f32],
2410 magnitude_db: &[f32],
2411 freq_range: Option<(f32, f32)>,
2412) -> f32 {
2413 if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2414 return magnitude_db.first().copied().unwrap_or(0.0);
2415 }
2416
2417 let (start_freq, end_freq) =
2418 freq_range.unwrap_or((frequencies[0], frequencies[frequencies.len() - 1]));
2419
2420 let mut sum_weighted_db = 0.0;
2421 let mut sum_weights = 0.0;
2422
2423 for i in 0..frequencies.len() - 1 {
2424 let f0 = frequencies[i];
2425 let f1 = frequencies[i + 1];
2426
2427 if f1 < start_freq || f0 > end_freq {
2429 continue;
2430 }
2431
2432 let fa = f0.max(start_freq);
2434 let fb = f1.min(end_freq);
2435
2436 if fb <= fa {
2437 continue;
2438 }
2439
2440 let weight = (fb / fa).log2();
2443
2444 let m0 = magnitude_db[i];
2449 let m1 = magnitude_db[i + 1];
2450 let avg_m = (m0 + m1) / 2.0;
2451
2452 sum_weighted_db += avg_m * weight;
2453 sum_weights += weight;
2454 }
2455
2456 if sum_weights > 0.0 {
2457 sum_weighted_db / sum_weights
2458 } else {
2459 magnitude_db.first().copied().unwrap_or(0.0)
2460 }
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465 use super::*;
2466
2467 #[test]
2468 fn test_next_power_of_two() {
2469 assert_eq!(next_power_of_two(1), 1);
2470 assert_eq!(next_power_of_two(2), 2);
2471 assert_eq!(next_power_of_two(3), 4);
2472 assert_eq!(next_power_of_two(1000), 1024);
2473 assert_eq!(next_power_of_two(1024), 1024);
2474 assert_eq!(next_power_of_two(1025), 2048);
2475 }
2476
2477 #[test]
2478 fn test_hann_window() {
2479 let signal = vec![1.0; 100];
2480 let windowed = apply_hann_window(&signal);
2481
2482 assert!(windowed[0].abs() < 0.01);
2484 assert!(windowed[99].abs() < 0.01);
2485
2486 assert!((windowed[50] - 1.0).abs() < 0.01);
2488 }
2489
2490 #[test]
2491 fn test_estimate_lag_zero() {
2492 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2494 let lag = estimate_lag(&signal, &signal).unwrap();
2495 assert_eq!(lag, 0);
2496 }
2497
2498 #[test]
2499 fn test_estimate_lag_positive() {
2500 let mut reference = vec![0.0; 100];
2503 let mut recorded = vec![0.0; 100];
2504
2505 for i in 10..20 {
2507 reference[i] = (i - 10) as f32 / 10.0;
2508 }
2509 for i in 15..25 {
2511 recorded[i] = (i - 15) as f32 / 10.0;
2512 }
2513
2514 let lag = estimate_lag(&reference, &recorded).unwrap();
2515 assert_eq!(lag, 5, "Recorded signal is delayed by 5 samples");
2516 }
2517
2518 #[test]
2519 fn test_identical_signals_have_zero_lag() {
2520 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2523 let lag = estimate_lag(&signal, &signal).unwrap();
2524 assert_eq!(lag, 0, "Identical signals should have zero lag");
2525 }
2526}