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 = (recorded.iter().map(|&x| x * x).sum::<f32>() / recorded.len() as f32).sqrt();
826
827 log::debug!(
828 "[FFT Analysis] Reference: max={:.4}, RMS={:.4}",
829 ref_max,
830 ref_rms
831 );
832 log::debug!(
833 "[FFT Analysis] Recorded: max={:.4}, RMS={:.4}",
834 rec_max,
835 rec_rms
836 );
837 log::debug!(
838 "[FFT Analysis] First 5 reference samples: {:?}",
839 &reference[..5.min(reference.len())]
840 );
841 log::debug!(
842 "[FFT Analysis] First 5 recorded samples: {:?}",
843 &recorded[..5.min(recorded.len())]
844 );
845
846 let check_len = reference.len().min(recorded.len());
847 let mut identical_count = 0;
848 for (r, c) in reference[..check_len]
849 .iter()
850 .zip(recorded[..check_len].iter())
851 {
852 if (r - c).abs() < 1e-6 {
853 identical_count += 1;
854 }
855 }
856 log::debug!(
857 "[FFT Analysis] Identical samples: {}/{} ({:.1}%)",
858 identical_count,
859 check_len,
860 identical_count as f32 * 100.0 / check_len as f32
861 );
862 }
863
864 let lag = estimate_lag(reference, recorded)?;
866
867 log::debug!(
868 "[FFT Analysis] Estimated lag: {} samples ({:.2} ms)",
869 lag,
870 lag as f32 * 1000.0 / sample_rate as f32
871 );
872
873 let (aligned_ref, aligned_rec) = if lag >= 0 {
876 let lag_usize = lag as usize;
877 if lag_usize >= recorded.len() {
878 return Err("Lag is larger than recorded signal length".to_string());
879 }
880 (reference, &recorded[lag_usize..])
882 } else {
883 let lag_usize = (-lag) as usize;
885 if lag_usize >= reference.len() {
886 return Err("Negative lag is larger than reference signal length".to_string());
887 }
888 (&reference[lag_usize..], recorded)
890 };
891
892 log::debug!(
893 "[FFT Analysis] Aligned lengths: ref={}, rec={} (tail included)",
894 aligned_ref.len(),
895 aligned_rec.len()
896 );
897
898 let fft_size = next_power_of_two(aligned_ref.len().max(aligned_rec.len()));
900
901 let ref_spectrum = compute_fft(aligned_ref, fft_size, WindowType::Tukey(0.1))?;
902 let rec_spectrum = compute_fft(aligned_rec, fft_size, WindowType::Tukey(0.1))?;
903
904 let num_output_points = 2000;
906 let log_start = 20.0_f32.ln();
907 let log_end = 20000.0_f32.ln();
908
909 let mut frequencies = Vec::with_capacity(num_output_points);
910 let mut spl_db = Vec::with_capacity(num_output_points);
911 let mut phase_deg = Vec::with_capacity(num_output_points);
912
913 let freq_resolution = sample_rate as f32 / fft_size as f32;
914 let num_bins = fft_size / 2; let ref_peak_mag_sq = ref_spectrum[1..num_bins.min(ref_spectrum.len())]
922 .iter()
923 .map(|c| c.norm_sqr())
924 .fold(0.0_f32, |a, b| a.max(b));
925 let ref_regularization_threshold = ref_peak_mag_sq * 1e-6;
927
928 let mut skipped_count = 0;
930 for i in 0..num_output_points {
931 let target_freq =
933 (log_start + (log_end - log_start) * i as f32 / (num_output_points - 1) as f32).exp();
934
935 let octave_fraction = 1.0 / 48.0;
938 let freq_lower = target_freq * 2.0_f32.powf(-octave_fraction);
939 let freq_upper = target_freq * 2.0_f32.powf(octave_fraction);
940
941 let bin_lower = ((freq_lower / freq_resolution).floor() as usize).max(1);
943 let bin_upper = ((freq_upper / freq_resolution).ceil() as usize).min(num_bins);
944
945 if bin_lower > bin_upper || bin_upper >= ref_spectrum.len() {
946 if skipped_count < 5 {
947 log::debug!(
948 "[FFT Analysis] Skipping freq {:.1} Hz: bin_lower={}, bin_upper={}, ref_spectrum.len()={}",
949 target_freq,
950 bin_lower,
951 bin_upper,
952 ref_spectrum.len()
953 );
954 }
955 skipped_count += 1;
956 frequencies.push(target_freq);
959 spl_db.push(-200.0);
960 phase_deg.push(0.0);
961 continue;
962 }
963
964 let mut sum_magnitude = 0.0;
966 let mut sum_sin = 0.0; let mut sum_cos = 0.0;
968 let mut bin_count = 0;
969
970 for k in bin_lower..=bin_upper {
971 if k >= ref_spectrum.len() {
972 break;
973 }
974
975 let ref_mag_sq = ref_spectrum[k].norm_sqr();
981 if ref_mag_sq <= ref_regularization_threshold {
982 continue;
983 }
984 let transfer_function = rec_spectrum[k] / ref_spectrum[k];
985 let magnitude = transfer_function.norm();
986
987 let cross_spectrum = ref_spectrum[k].conj() * rec_spectrum[k];
989 let phase_rad = cross_spectrum.arg();
990
991 sum_magnitude += magnitude;
993 sum_sin += phase_rad.sin();
994 sum_cos += phase_rad.cos();
995 bin_count += 1;
996 }
997
998 let (avg_magnitude, db) = if bin_count == 0 {
1003 (0.0, -200.0)
1004 } else {
1005 let avg = sum_magnitude / bin_count as f32;
1006 (avg, 20.0 * avg.max(1e-10).log10())
1007 };
1008
1009 if frequencies.len() < 5 {
1010 log::debug!(
1011 "[FFT Analysis] freq={:.1} Hz: avg_magnitude={:.6}, dB={:.2}",
1012 target_freq,
1013 avg_magnitude,
1014 db
1015 );
1016 }
1017
1018 let avg_phase_rad = sum_sin.atan2(sum_cos);
1020 let phase = avg_phase_rad * 180.0 / PI;
1021
1022 frequencies.push(target_freq);
1023 spl_db.push(db);
1024 phase_deg.push(phase);
1025 }
1026
1027 log::debug!(
1028 "[FFT Analysis] Generated {} frequency points for CSV output",
1029 frequencies.len()
1030 );
1031 log::debug!(
1032 "[FFT Analysis] Skipped {} frequency points (out of {})",
1033 skipped_count,
1034 num_output_points
1035 );
1036
1037 if log::log_enabled!(log::Level::Debug) && !spl_db.is_empty() {
1038 let min_spl = spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1039 let max_spl = spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1040 log::debug!(
1041 "[FFT Analysis] SPL range: {:.2} dB to {:.2} dB",
1042 min_spl,
1043 max_spl
1044 );
1045 }
1046
1047 let mut transfer_function = vec![Complex::new(0.0, 0.0); fft_size];
1050 for k in 0..fft_size {
1051 let ref_mag_sq = ref_spectrum[k].norm_sqr();
1054 if ref_mag_sq > 1e-20 {
1055 transfer_function[k] = rec_spectrum[k] / ref_spectrum[k];
1056 }
1057 }
1058
1059 let ifft = plan_fft_inverse(fft_size);
1061 ifft.process(&mut transfer_function);
1062
1063 let norm = 1.0 / fft_size as f32;
1068 let mut impulse_response: Vec<f32> = transfer_function.iter().map(|c| c.re * norm).collect();
1069
1070 let peak_idx = impulse_response
1074 .iter()
1075 .enumerate()
1076 .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1077 .map(|(i, _)| i)
1078 .unwrap_or(0);
1079
1080 let pre_ring_samples = (0.005 * sample_rate as f32) as usize; let shift_amount = peak_idx.saturating_sub(pre_ring_samples);
1083
1084 if shift_amount > 0 {
1085 impulse_response.rotate_left(shift_amount);
1086 log::info!(
1087 "[FFT Analysis] IR peak was at index {}, shifted by {} samples to put peak near beginning",
1088 peak_idx,
1089 shift_amount
1090 );
1091 }
1092
1093 let _ir_duration_sec = fft_size as f32 / sample_rate as f32;
1095 let impulse_time_ms: Vec<f32> = (0..fft_size)
1096 .map(|i| i as f32 / sample_rate as f32 * 1000.0)
1097 .collect();
1098
1099 let (thd_percent, harmonic_distortion_db) = if let Some((start, end)) = sweep_range {
1101 let duration = reference_signal.len() as f32 / sample_rate as f32;
1104 compute_thd_from_ir(
1105 &impulse_response,
1106 sample_rate as f32,
1107 &frequencies,
1108 &spl_db,
1109 start,
1110 end,
1111 duration,
1112 )
1113 } else {
1114 (vec![0.0; frequencies.len()], Vec::new())
1115 };
1116
1117 let excess_group_delay_ms = vec![0.0; frequencies.len()];
1120
1121 let ir_max = impulse_response.iter().fold(0.0f32, |a, &b| a.max(b.abs()));
1124 let ir_len = impulse_response.len();
1125 log::info!(
1126 "[Analysis] Impulse response: len={}, max_abs={:.6}, sample_rate={}",
1127 ir_len,
1128 ir_max,
1129 sample_rate
1130 );
1131
1132 let rt60_ms = compute_rt60_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1133 let (clarity_c50_db, clarity_c80_db) =
1134 compute_clarity_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1135
1136 if !rt60_ms.is_empty() {
1138 let rt60_min = rt60_ms.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1139 let rt60_max = rt60_ms.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1140 log::info!(
1141 "[Analysis] RT60 range: {:.1} - {:.1} ms",
1142 rt60_min,
1143 rt60_max
1144 );
1145 }
1146 if !clarity_c50_db.is_empty() {
1147 let c50_min = clarity_c50_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1148 let c50_max = clarity_c50_db
1149 .iter()
1150 .fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1151 log::info!(
1152 "[Analysis] Clarity C50 range: {:.1} - {:.1} dB",
1153 c50_min,
1154 c50_max
1155 );
1156 }
1157
1158 let (spectrogram_db, _, _) =
1160 compute_spectrogram(&impulse_response, sample_rate as f32, 512, 128);
1161
1162 Ok(AnalysisResult {
1163 frequencies,
1164 spl_db,
1165 phase_deg,
1166 estimated_lag_samples: lag,
1167 impulse_response,
1168 impulse_time_ms,
1169 excess_group_delay_ms,
1170 thd_percent,
1171 harmonic_distortion_db,
1172 rt60_ms,
1173 clarity_c50_db,
1174 clarity_c80_db,
1175 spectrogram_db,
1176 })
1177}
1178
1179fn compute_thd_from_ir(
1183 impulse: &[f32],
1184 sample_rate: f32,
1185 frequencies: &[f32],
1186 fundamental_db: &[f32],
1187 start_freq: f32,
1188 end_freq: f32,
1189 duration: f32,
1190) -> (Vec<f32>, Vec<Vec<f32>>) {
1191 if frequencies.is_empty() {
1192 return (Vec::new(), Vec::new());
1193 }
1194
1195 let n = impulse.len();
1196 if n == 0 {
1197 return (vec![0.0; frequencies.len()], Vec::new());
1198 }
1199
1200 let num_harmonics = 4; let mut harmonics_db = vec![vec![-120.0; frequencies.len()]; num_harmonics];
1203
1204 let peak_idx = impulse
1206 .iter()
1207 .enumerate()
1208 .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1209 .map(|(i, _)| i)
1210 .unwrap_or(0);
1211
1212 let sweep_ratio = end_freq / start_freq;
1213 log::debug!(
1214 "[THD] Impulse len={}, peak_idx={}, duration={:.3}s, sweep {:.0}-{:.0} Hz (ratio {:.1})",
1215 n,
1216 peak_idx,
1217 duration,
1218 start_freq,
1219 end_freq,
1220 sweep_ratio
1221 );
1222
1223 for (k_idx, harmonic_db) in harmonics_db.iter_mut().enumerate().take(num_harmonics) {
1225 let harmonic_order = k_idx + 2; let dt = duration * (harmonic_order as f32).ln() / sweep_ratio.ln();
1230 let dn = (dt * sample_rate).round() as isize;
1231
1232 let center = peak_idx as isize - dn;
1234 let center_wrapped = center.rem_euclid(n as isize) as usize;
1235
1236 let dt_next_rel = duration
1238 * ((harmonic_order as f32 + 1.0).ln() - (harmonic_order as f32).ln())
1239 / sweep_ratio.ln();
1240 let win_len = ((dt_next_rel * sample_rate * 0.8).max(256.0) as usize).min(n / 2);
1241
1242 let mut harmonic_ir = vec![0.0f32; win_len];
1244 let mut max_harmonic_sample = 0.0f32;
1245 for (i, harmonic_ir_val) in harmonic_ir.iter_mut().enumerate() {
1246 let src_idx =
1247 (center - (win_len as isize / 2) + i as isize).rem_euclid(n as isize) as usize;
1248 let w = 0.5 * (1.0 - (2.0 * PI * i as f32 / (win_len as f32 - 1.0)).cos());
1250 *harmonic_ir_val = impulse[src_idx] * w;
1251 max_harmonic_sample = max_harmonic_sample.max(harmonic_ir_val.abs());
1252 }
1253
1254 if k_idx == 0 {
1255 log::debug!(
1256 "[THD] H{}: dt={:.3}s, dn={}, center_wrapped={}, win_len={}, max_sample={:.2e}",
1257 harmonic_order,
1258 dt,
1259 dn,
1260 center_wrapped,
1261 win_len,
1262 max_harmonic_sample
1263 );
1264 }
1265
1266 let fft_size = next_power_of_two(win_len);
1268 let nyquist_bin = fft_size / 2; if let Ok(spectrum) = compute_fft_padded(&harmonic_ir, fft_size) {
1270 let freq_resolution = sample_rate / fft_size as f32;
1271
1272 for (i, &f) in frequencies.iter().enumerate() {
1273 let bin = (f / freq_resolution).round() as usize;
1274 if bin < nyquist_bin && bin < spectrum.len() {
1276 let mag = spectrum[bin].norm();
1279 if mag > 1e-6 {
1281 harmonic_db[i] = 20.0 * mag.log10();
1282 }
1283 }
1284 }
1285 }
1286 }
1287
1288 if !frequencies.is_empty() {
1290 let mid_idx = frequencies.len() / 2;
1291 log::debug!(
1292 "[THD] Harmonic levels at {:.0} Hz: H2={:.1}dB, H3={:.1}dB, H4={:.1}dB, H5={:.1}dB, fundamental={:.1}dB",
1293 frequencies[mid_idx],
1294 harmonics_db[0][mid_idx],
1295 harmonics_db[1][mid_idx],
1296 harmonics_db[2][mid_idx],
1297 harmonics_db[3][mid_idx],
1298 fundamental_db[mid_idx]
1299 );
1300 }
1301
1302 let mut thd_percent = Vec::with_capacity(frequencies.len());
1304 for i in 0..frequencies.len() {
1305 let fundamental = 10.0f32.powf(fundamental_db[i] / 20.0);
1306 let mut harmonic_sum_sq = 0.0;
1307
1308 for harmonic_db in harmonics_db.iter().take(num_harmonics) {
1309 let h_mag = 10.0f32.powf(harmonic_db[i] / 20.0);
1310 harmonic_sum_sq += h_mag * h_mag;
1311 }
1312
1313 let thd = if fundamental > 1e-9 {
1315 (harmonic_sum_sq.sqrt() / fundamental) * 100.0
1316 } else {
1317 0.0
1318 };
1319 thd_percent.push(thd);
1320 }
1321
1322 if !thd_percent.is_empty() {
1324 let max_thd = thd_percent.iter().fold(0.0f32, |a, &b| a.max(b));
1325 let min_thd = thd_percent.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1326 log::debug!("[THD] THD range: {:.4}% to {:.4}%", min_thd, max_thd);
1327 }
1328
1329 (thd_percent, harmonics_db)
1330}
1331
1332pub fn write_analysis_csv(
1345 result: &AnalysisResult,
1346 output_path: &Path,
1347 compensation: Option<&MicrophoneCompensation>,
1348) -> Result<(), String> {
1349 use std::fs::File;
1350 use std::io::Write;
1351
1352 log::info!(
1353 "[write_analysis_csv] Writing {} frequency points to {:?}",
1354 result.frequencies.len(),
1355 output_path
1356 );
1357
1358 if let Some(comp) = compensation {
1359 log::info!(
1360 "[write_analysis_csv] Applying inverse microphone compensation ({} calibration points)",
1361 comp.frequencies.len()
1362 );
1363 }
1364
1365 if result.frequencies.is_empty() {
1366 return Err("Cannot write CSV: Analysis result has no frequency points!".to_string());
1367 }
1368
1369 let mut file =
1370 File::create(output_path).map_err(|e| format!("Failed to create CSV file: {}", e))?;
1371
1372 writeln!(
1374 file,
1375 "frequency_hz,spl_db,phase_deg,thd_percent,rt60_ms,c50_db,c80_db,group_delay_ms"
1376 )
1377 .map_err(|e| format!("Failed to write header: {}", e))?;
1378
1379 for i in 0..result.frequencies.len() {
1381 let freq = result.frequencies[i];
1382 let mut spl = result.spl_db[i];
1383
1384 if let Some(comp) = compensation {
1387 let mic_deviation = comp.interpolate_at(freq);
1388 spl -= mic_deviation;
1389 }
1390
1391 let phase = result.phase_deg[i];
1392 let thd = result.thd_percent.get(i).copied().unwrap_or(0.0);
1393 let rt60 = result.rt60_ms.get(i).copied().unwrap_or(0.0);
1394 let c50 = result.clarity_c50_db.get(i).copied().unwrap_or(0.0);
1395 let c80 = result.clarity_c80_db.get(i).copied().unwrap_or(0.0);
1396 let gd = result.excess_group_delay_ms.get(i).copied().unwrap_or(0.0);
1397
1398 writeln!(
1399 file,
1400 "{:.6},{:.3},{:.6},{:.6},{:.3},{:.3},{:.3},{:.6}",
1401 freq, spl, phase, thd, rt60, c50, c80, gd
1402 )
1403 .map_err(|e| format!("Failed to write data: {}", e))?;
1404 }
1405
1406 log::info!(
1407 "[write_analysis_csv] Successfully wrote {} data rows to CSV",
1408 result.frequencies.len()
1409 );
1410
1411 Ok(())
1412}
1413
1414pub fn read_analysis_csv(csv_path: &Path) -> Result<AnalysisResult, String> {
1419 use std::fs::File;
1420 use std::io::{BufRead, BufReader};
1421
1422 let file = File::open(csv_path).map_err(|e| format!("Failed to open CSV: {}", e))?;
1423 let reader = BufReader::new(file);
1424 let mut lines = reader.lines();
1425
1426 let header = lines
1428 .next()
1429 .ok_or("Empty CSV file")?
1430 .map_err(|e| format!("Failed to read header: {}", e))?;
1431
1432 let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
1433 let has_extended_format = columns.len() >= 8;
1434
1435 let mut frequencies = Vec::new();
1436 let mut spl_db = Vec::new();
1437 let mut phase_deg = Vec::new();
1438 let mut thd_percent = Vec::new();
1439 let mut rt60_ms = Vec::new();
1440 let mut clarity_c50_db = Vec::new();
1441 let mut clarity_c80_db = Vec::new();
1442 let mut excess_group_delay_ms = Vec::new();
1443
1444 for line in lines {
1445 let line = line.map_err(|e| format!("Failed to read line: {}", e))?;
1446 let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
1447
1448 if parts.len() < 3 {
1449 continue;
1450 }
1451
1452 let freq: f32 = parts[0].parse().unwrap_or(0.0);
1453 let spl: f32 = parts[1].parse().unwrap_or(0.0);
1454 let phase: f32 = parts[2].parse().unwrap_or(0.0);
1455
1456 frequencies.push(freq);
1457 spl_db.push(spl);
1458 phase_deg.push(phase);
1459
1460 if has_extended_format && parts.len() >= 8 {
1461 thd_percent.push(parts[3].parse().unwrap_or(0.0));
1462 rt60_ms.push(parts[4].parse().unwrap_or(0.0));
1463 clarity_c50_db.push(parts[5].parse().unwrap_or(0.0));
1464 clarity_c80_db.push(parts[6].parse().unwrap_or(0.0));
1465 excess_group_delay_ms.push(parts[7].parse().unwrap_or(0.0));
1466 }
1467 }
1468
1469 let n = frequencies.len();
1471 if thd_percent.is_empty() {
1472 thd_percent = vec![0.0; n];
1473 rt60_ms = vec![0.0; n];
1474 clarity_c50_db = vec![0.0; n];
1475 clarity_c80_db = vec![0.0; n];
1476 excess_group_delay_ms = vec![0.0; n];
1477 }
1478
1479 Ok(AnalysisResult {
1480 frequencies,
1481 spl_db,
1482 phase_deg,
1483 estimated_lag_samples: 0,
1484 impulse_response: Vec::new(),
1485 impulse_time_ms: Vec::new(),
1486 thd_percent,
1487 harmonic_distortion_db: Vec::new(),
1488 rt60_ms,
1489 clarity_c50_db,
1490 clarity_c80_db,
1491 excess_group_delay_ms,
1492 spectrogram_db: Vec::new(),
1493 })
1494}
1495
1496#[derive(Debug, Clone, Copy)]
1498enum WindowType {
1499 Hann,
1500 Tukey(f32), }
1502
1503fn estimate_lag(reference: &[f32], recorded: &[f32]) -> Result<isize, String> {
1514 let len = reference.len().min(recorded.len());
1515
1516 let fft_size = next_power_of_two(len * 2);
1518
1519 let ref_fft = compute_fft(reference, fft_size, WindowType::Hann)?;
1521 let rec_fft = compute_fft(recorded, fft_size, WindowType::Hann)?;
1522
1523 let mut cross_corr_fft: Vec<Complex<f32>> = ref_fft
1525 .iter()
1526 .zip(rec_fft.iter())
1527 .map(|(x, y)| x.conj() * y)
1528 .collect();
1529
1530 let ifft = plan_fft_inverse(fft_size);
1532 ifft.process(&mut cross_corr_fft);
1533
1534 let mut max_val = 0.0;
1536 let mut max_idx = 0;
1537
1538 for (i, &val) in cross_corr_fft.iter().enumerate() {
1539 let magnitude = val.norm();
1540 if magnitude > max_val {
1541 max_val = magnitude;
1542 max_idx = i;
1543 }
1544 }
1545
1546 Ok(if max_idx <= fft_size / 2 {
1548 max_idx as isize
1549 } else {
1550 max_idx as isize - fft_size as isize
1551 })
1552}
1553
1554fn compute_fft(
1564 signal: &[f32],
1565 fft_size: usize,
1566 window_type: WindowType,
1567) -> Result<Vec<Complex<f32>>, String> {
1568 let windowed = match window_type {
1570 WindowType::Hann => apply_hann_window(signal),
1571 WindowType::Tukey(alpha) => apply_tukey_window(signal, alpha),
1572 };
1573
1574 compute_fft_padded(&windowed, fft_size)
1575}
1576
1577fn compute_fft_padded(signal: &[f32], fft_size: usize) -> Result<Vec<Complex<f32>>, String> {
1579 let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
1581 for (dst, &src) in buffer.iter_mut().zip(signal.iter()) {
1582 dst.re = src;
1583 }
1584
1585 let fft = plan_fft_forward(fft_size);
1587 fft.process(&mut buffer);
1588
1589 let norm_factor = 1.0 / fft_size as f32;
1591 for val in buffer.iter_mut() {
1592 *val *= norm_factor;
1593 }
1594
1595 Ok(buffer)
1596}
1597
1598fn apply_hann_window(signal: &[f32]) -> Vec<f32> {
1600 let len = signal.len();
1601 if len < 2 {
1602 return signal.to_vec();
1603 }
1604 signal
1605 .iter()
1606 .enumerate()
1607 .map(|(i, &x)| {
1608 let window = 0.5 * (1.0 - (2.0 * PI * i as f32 / (len - 1) as f32).cos());
1609 x * window
1610 })
1611 .collect()
1612}
1613
1614fn apply_tukey_window(signal: &[f32], alpha: f32) -> Vec<f32> {
1619 let len = signal.len();
1620 if len < 2 {
1621 return signal.to_vec();
1622 }
1623
1624 let alpha = alpha.clamp(0.0, 1.0);
1625 let limit = (alpha * (len as f32 - 1.0) / 2.0).round() as usize;
1626
1627 if limit == 0 {
1628 return signal.to_vec();
1629 }
1630
1631 signal
1632 .iter()
1633 .enumerate()
1634 .map(|(i, &x)| {
1635 let w = if i < limit {
1636 0.5 * (1.0 - (PI * i as f32 / limit as f32).cos())
1638 } else if i >= len - limit {
1639 let n = len - 1 - i;
1641 0.5 * (1.0 - (PI * n as f32 / limit as f32).cos())
1642 } else {
1643 1.0
1645 };
1646 x * w
1647 })
1648 .collect()
1649}
1650
1651fn next_power_of_two(n: usize) -> usize {
1653 if n == 0 {
1654 return 1;
1655 }
1656 n.next_power_of_two()
1657}
1658
1659fn load_wav_mono_channel(path: &Path, channel_index: Option<usize>) -> Result<Vec<f32>, String> {
1666 let mut reader =
1667 WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
1668
1669 let spec = reader.spec();
1670 let channels = spec.channels as usize;
1671
1672 log::info!(
1673 "[load_wav_mono_channel] WAV file: {} channels, {} Hz, {:?} format",
1674 channels,
1675 spec.sample_rate,
1676 spec.sample_format
1677 );
1678
1679 let samples: Result<Vec<f32>, _> = match spec.sample_format {
1681 hound::SampleFormat::Float => reader.samples::<f32>().collect(),
1682 hound::SampleFormat::Int => reader
1683 .samples::<i32>()
1684 .map(|s| s.map(|v| v as f32 / i32::MAX as f32))
1685 .collect(),
1686 };
1687
1688 let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
1689 log::info!(
1690 "[load_wav_mono_channel] Read {} total samples",
1691 samples.len()
1692 );
1693
1694 if channels == 1 {
1696 log::info!(
1697 "[load_wav_mono_channel] File is already mono, returning {} samples",
1698 samples.len()
1699 );
1700 return Ok(samples);
1701 }
1702
1703 if let Some(ch_idx) = channel_index {
1705 if ch_idx >= channels {
1707 return Err(format!(
1708 "Channel index {} out of range (file has {} channels)",
1709 ch_idx, channels
1710 ));
1711 }
1712 log::info!(
1713 "[load_wav_mono_channel] Extracting channel {} from {} channels",
1714 ch_idx,
1715 channels
1716 );
1717 Ok(samples
1718 .chunks(channels)
1719 .map(|chunk| chunk[ch_idx])
1720 .collect())
1721 } else {
1722 log::info!(
1724 "[load_wav_mono_channel] Averaging {} channels to mono",
1725 channels
1726 );
1727 Ok(samples
1728 .chunks(channels)
1729 .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
1730 .collect())
1731 }
1732}
1733
1734fn load_wav_mono(path: &Path) -> Result<Vec<f32>, String> {
1736 load_wav_mono_channel(path, None)
1737}
1738
1739pub fn smooth_response_f64(frequencies: &[f64], values: &[f64], octaves: f64) -> Vec<f64> {
1748 if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
1749 return values.to_vec();
1750 }
1751
1752 let n = values.len();
1753
1754 let mut prefix = Vec::with_capacity(n + 1);
1756 prefix.push(0.0);
1757 for &v in values {
1758 prefix.push(prefix.last().unwrap() + v);
1759 }
1760
1761 let ratio = 2.0_f64.powf(octaves / 2.0);
1762 let mut smoothed = Vec::with_capacity(n);
1763 let mut lo = 0usize;
1764 let mut hi = 0usize;
1765
1766 for (i, ¢er_freq) in frequencies.iter().enumerate() {
1767 if center_freq <= 0.0 {
1768 smoothed.push(values[i]);
1769 continue;
1770 }
1771
1772 let low_freq = center_freq / ratio;
1773 let high_freq = center_freq * ratio;
1774
1775 while lo < n && frequencies[lo] < low_freq {
1777 lo += 1;
1778 }
1779 while hi < n && frequencies[hi] <= high_freq {
1781 hi += 1;
1782 }
1783
1784 let count = hi - lo;
1785 if count > 0 {
1786 smoothed.push((prefix[hi] - prefix[lo]) / count as f64);
1787 } else {
1788 smoothed.push(values[i]);
1789 }
1790 }
1791
1792 smoothed
1793}
1794
1795pub fn smooth_response_f32(frequencies: &[f32], values: &[f32], octaves: f32) -> Vec<f32> {
1800 if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
1801 return values.to_vec();
1802 }
1803
1804 let n = values.len();
1805
1806 let mut prefix = Vec::with_capacity(n + 1);
1808 prefix.push(0.0_f64);
1809 for &v in values {
1810 prefix.push(prefix.last().unwrap() + v as f64);
1811 }
1812
1813 let ratio = 2.0_f32.powf(octaves / 2.0);
1814 let mut smoothed = Vec::with_capacity(n);
1815 let mut lo = 0usize;
1816 let mut hi = 0usize;
1817
1818 for (i, ¢er_freq) in frequencies.iter().enumerate() {
1819 if center_freq <= 0.0 {
1820 smoothed.push(values[i]);
1821 continue;
1822 }
1823
1824 let low_freq = center_freq / ratio;
1825 let high_freq = center_freq * ratio;
1826
1827 while lo < n && frequencies[lo] < low_freq {
1829 lo += 1;
1830 }
1831 while hi < n && frequencies[hi] <= high_freq {
1833 hi += 1;
1834 }
1835
1836 let count = hi - lo;
1837 if count > 0 {
1838 smoothed.push(((prefix[hi] - prefix[lo]) / count as f64) as f32);
1839 } else {
1840 smoothed.push(values[i]);
1841 }
1842 }
1843
1844 smoothed
1845}
1846
1847pub fn compute_group_delay(frequencies: &[f32], phase_deg: &[f32]) -> Vec<f32> {
1853 if frequencies.len() < 2 {
1854 return vec![0.0; frequencies.len()];
1855 }
1856
1857 let unwrapped = unwrap_phase_deg(phase_deg);
1859
1860 let mut group_delay_ms = Vec::with_capacity(frequencies.len());
1861
1862 for i in 0..frequencies.len() {
1863 let delay = if i == 0 {
1864 let df = frequencies[1] - frequencies[0];
1866 let dp = unwrapped[1] - unwrapped[0];
1867 if df.abs() > 1e-6 {
1868 -dp / df / 360.0 * 1000.0 } else {
1870 0.0
1871 }
1872 } else if i == frequencies.len() - 1 {
1873 let df = frequencies[i] - frequencies[i - 1];
1875 let dp = unwrapped[i] - unwrapped[i - 1];
1876 if df.abs() > 1e-6 {
1877 -dp / df / 360.0 * 1000.0
1878 } else {
1879 0.0
1880 }
1881 } else {
1882 let df = frequencies[i + 1] - frequencies[i - 1];
1884 let dp = unwrapped[i + 1] - unwrapped[i - 1];
1885 if df.abs() > 1e-6 {
1886 -dp / df / 360.0 * 1000.0
1887 } else {
1888 0.0
1889 }
1890 };
1891 group_delay_ms.push(delay);
1892 }
1893
1894 group_delay_ms
1895}
1896
1897fn unwrap_phase_deg(phase_deg: &[f32]) -> Vec<f32> {
1901 if phase_deg.is_empty() {
1902 return Vec::new();
1903 }
1904
1905 let mut unwrapped = Vec::with_capacity(phase_deg.len());
1906 unwrapped.push(phase_deg[0]);
1907
1908 for i in 1..phase_deg.len() {
1909 let diff = phase_deg[i] - phase_deg[i - 1];
1910 let wrapped_diff = diff - 360.0 * (diff / 360.0).round();
1911 unwrapped.push(unwrapped[i - 1] + wrapped_diff);
1912 }
1913
1914 unwrapped
1915}
1916
1917pub fn compute_impulse_response_from_fr(
1925 frequencies: &[f32],
1926 magnitude_db: &[f32],
1927 phase_deg: &[f32],
1928 sample_rate: f32,
1929) -> (Vec<f32>, Vec<f32>) {
1930 let fft_size = 1024;
1931 let half = fft_size / 2; let freq_bin = sample_rate / fft_size as f32;
1933
1934 let unwrapped_phase = unwrap_phase_deg(phase_deg);
1936
1937 let mut spectrum = vec![Complex::new(0.0_f32, 0.0); fft_size];
1939
1940 for (k, spectrum_bin) in spectrum.iter_mut().enumerate().take(half + 1) {
1941 let f = k as f32 * freq_bin;
1942
1943 let (mag_db, phase_d) = interpolate_fr(frequencies, magnitude_db, &unwrapped_phase, f);
1945
1946 let mag_linear = 10.0_f32.powf(mag_db / 20.0);
1947 let phase_rad = phase_d * PI / 180.0;
1948
1949 *spectrum_bin = Complex::new(mag_linear * phase_rad.cos(), mag_linear * phase_rad.sin());
1950 }
1951
1952 for k in 1..half {
1954 spectrum[fft_size - k] = spectrum[k].conj();
1955 }
1956
1957 let ifft = plan_fft_inverse(fft_size);
1959 ifft.process(&mut spectrum);
1960
1961 let scale = 1.0 / fft_size as f32;
1963 let mut impulse: Vec<f32> = spectrum.iter().map(|c| c.re * scale).collect();
1964
1965 let max_val = impulse.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
1967 if max_val > 0.0 {
1968 for v in &mut impulse {
1969 *v /= max_val;
1970 }
1971 }
1972
1973 let time_step = 1.0 / sample_rate;
1974 let times: Vec<f32> = (0..fft_size)
1975 .map(|i| i as f32 * time_step * 1000.0)
1976 .collect();
1977
1978 (times, impulse)
1979}
1980
1981fn interpolate_fr(
1986 frequencies: &[f32],
1987 magnitude_db: &[f32],
1988 unwrapped_phase_deg: &[f32],
1989 target_freq: f32,
1990) -> (f32, f32) {
1991 if frequencies.is_empty() {
1992 return (0.0, 0.0);
1993 }
1994 if target_freq <= frequencies[0] {
1995 return (magnitude_db[0], unwrapped_phase_deg[0]);
1996 }
1997 let last = frequencies.len() - 1;
1998 if target_freq >= frequencies[last] {
1999 return (magnitude_db[last], unwrapped_phase_deg[last]);
2000 }
2001
2002 let idx = match frequencies.binary_search_by(|f| f.partial_cmp(&target_freq).unwrap()) {
2004 Ok(i) => return (magnitude_db[i], unwrapped_phase_deg[i]),
2005 Err(i) => i, };
2007
2008 let f0 = frequencies[idx - 1];
2009 let f1 = frequencies[idx];
2010 let t = (target_freq - f0) / (f1 - f0);
2011
2012 let mag = magnitude_db[idx - 1] + t * (magnitude_db[idx] - magnitude_db[idx - 1]);
2013 let phase = unwrapped_phase_deg[idx - 1]
2014 + t * (unwrapped_phase_deg[idx] - unwrapped_phase_deg[idx - 1]);
2015 (mag, phase)
2016}
2017
2018fn compute_schroeder_decay(impulse: &[f32]) -> Vec<f32> {
2020 let mut energy = 0.0;
2021 let mut decay = vec![0.0; impulse.len()];
2022
2023 for i in (0..impulse.len()).rev() {
2025 energy += impulse[i] * impulse[i];
2026 decay[i] = energy;
2027 }
2028
2029 let max_energy = decay.first().copied().unwrap_or(1.0);
2031 if max_energy > 0.0 {
2032 for v in &mut decay {
2033 *v /= max_energy;
2034 }
2035 }
2036
2037 decay
2038}
2039
2040pub fn compute_rt60_broadband(impulse: &[f32], sample_rate: f32) -> f32 {
2043 let decay = compute_schroeder_decay(impulse);
2044 let decay_db: Vec<f32> = decay.iter().map(|&v| 10.0 * v.max(1e-9).log10()).collect();
2045
2046 let t_minus_5 = decay_db.iter().position(|&v| v < -5.0);
2048 let t_minus_25 = decay_db.iter().position(|&v| v < -25.0);
2049
2050 match (t_minus_5, t_minus_25) {
2051 (Some(start), Some(end)) => {
2052 if end > start {
2053 let dt = (end - start) as f32 / sample_rate; dt * 3.0 } else {
2056 0.0
2057 }
2058 }
2059 _ => 0.0,
2060 }
2061}
2062
2063pub fn compute_clarity_broadband(impulse: &[f32], sample_rate: f32) -> (f32, f32) {
2066 let mut energy_0_50 = 0.0;
2067 let mut energy_50_inf = 0.0;
2068 let mut energy_0_80 = 0.0;
2069 let mut energy_80_inf = 0.0;
2070
2071 let samp_50ms = (0.050 * sample_rate) as usize;
2072 let samp_80ms = (0.080 * sample_rate) as usize;
2073
2074 for (i, &samp) in impulse.iter().enumerate() {
2075 let sq = samp * samp;
2076
2077 if i < samp_50ms {
2078 energy_0_50 += sq;
2079 } else {
2080 energy_50_inf += sq;
2081 }
2082
2083 if i < samp_80ms {
2084 energy_0_80 += sq;
2085 } else {
2086 energy_80_inf += sq;
2087 }
2088 }
2089
2090 const MAX_CLARITY_DB: f32 = 60.0;
2093
2094 let c50 = if energy_50_inf > 1e-12 && energy_0_50 > 1e-12 {
2095 let ratio = energy_0_50 / energy_50_inf;
2096 (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2097 } else if energy_0_50 > energy_50_inf {
2098 MAX_CLARITY_DB } else {
2100 -MAX_CLARITY_DB };
2102
2103 let c80 = if energy_80_inf > 1e-12 && energy_0_80 > 1e-12 {
2104 let ratio = energy_0_80 / energy_80_inf;
2105 (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2106 } else if energy_80_inf > energy_0_80 {
2107 MAX_CLARITY_DB } else {
2109 -MAX_CLARITY_DB };
2111
2112 (c50, c80)
2113}
2114
2115pub fn compute_rt60_spectrum(impulse: &[f32], sample_rate: f32, frequencies: &[f32]) -> Vec<f32> {
2117 if impulse.is_empty() {
2118 return vec![0.0; frequencies.len()];
2119 }
2120
2121 let centers = [
2123 63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2124 ];
2125 let mut band_rt60s = Vec::with_capacity(centers.len());
2126 let mut valid_centers = Vec::with_capacity(centers.len());
2127
2128 for &freq in ¢ers {
2130 if freq >= sample_rate / 2.0 {
2132 continue;
2133 }
2134
2135 let mut biquad = Biquad::new(
2138 BiquadFilterType::Bandpass,
2139 freq as f64,
2140 sample_rate as f64,
2141 1.414,
2142 0.0,
2143 );
2144
2145 let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2147 biquad.process_block(&mut filtered);
2148 let filtered_f32: Vec<f32> = filtered.iter().map(|&x| x as f32).collect();
2149
2150 let rt60 = compute_rt60_broadband(&filtered_f32, sample_rate);
2152
2153 band_rt60s.push(rt60);
2154 valid_centers.push(freq);
2155 }
2156
2157 log::info!(
2159 "[RT60] Per-band values: {:?}",
2160 valid_centers
2161 .iter()
2162 .zip(band_rt60s.iter())
2163 .map(|(f, v)| format!("{:.0}Hz:{:.1}ms", f, v))
2164 .collect::<Vec<_>>()
2165 );
2166
2167 if valid_centers.is_empty() {
2168 return vec![0.0; frequencies.len()];
2169 }
2170
2171 interpolate_log(&valid_centers, &band_rt60s, frequencies)
2173}
2174
2175pub fn compute_clarity_spectrum(
2178 impulse: &[f32],
2179 sample_rate: f32,
2180 frequencies: &[f32],
2181) -> (Vec<f32>, Vec<f32>) {
2182 if impulse.is_empty() || frequencies.is_empty() {
2183 return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2184 }
2185
2186 let centers = [
2188 63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2189 ];
2190 let mut band_c50s = Vec::with_capacity(centers.len());
2191 let mut band_c80s = Vec::with_capacity(centers.len());
2192 let mut valid_centers = Vec::with_capacity(centers.len());
2193
2194 let samp_50ms = (0.050 * sample_rate) as usize;
2196 let samp_80ms = (0.080 * sample_rate) as usize;
2197
2198 for &freq in ¢ers {
2200 if freq >= sample_rate / 2.0 {
2201 continue;
2202 }
2203
2204 let mut biquad1 = Biquad::new(
2206 BiquadFilterType::Bandpass,
2207 freq as f64,
2208 sample_rate as f64,
2209 0.707, 0.0,
2211 );
2212 let mut biquad2 = Biquad::new(
2213 BiquadFilterType::Bandpass,
2214 freq as f64,
2215 sample_rate as f64,
2216 0.707,
2217 0.0,
2218 );
2219
2220 let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2221 biquad1.process_block(&mut filtered);
2222 biquad2.process_block(&mut filtered);
2223
2224 let mut energy_0_50 = 0.0f64;
2226 let mut energy_50_inf = 0.0f64;
2227 let mut energy_0_80 = 0.0f64;
2228 let mut energy_80_inf = 0.0f64;
2229
2230 for (i, &samp) in filtered.iter().enumerate() {
2231 let sq = samp * samp;
2232
2233 if i < samp_50ms {
2234 energy_0_50 += sq;
2235 } else {
2236 energy_50_inf += sq;
2237 }
2238
2239 if i < samp_80ms {
2240 energy_0_80 += sq;
2241 } else {
2242 energy_80_inf += sq;
2243 }
2244 }
2245
2246 const MAX_CLARITY_DB: f32 = 40.0;
2249 const MIN_ENERGY: f64 = 1e-20;
2250
2251 let c50 = if energy_50_inf > MIN_ENERGY && energy_0_50 > MIN_ENERGY {
2252 let ratio = energy_0_50 / energy_50_inf;
2253 (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2254 } else if energy_0_50 > energy_50_inf {
2255 MAX_CLARITY_DB
2256 } else {
2257 -MAX_CLARITY_DB
2258 };
2259
2260 let c80 = if energy_80_inf > MIN_ENERGY && energy_0_80 > MIN_ENERGY {
2261 let ratio = energy_0_80 / energy_80_inf;
2262 (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2263 } else if energy_0_80 > energy_80_inf {
2264 MAX_CLARITY_DB
2265 } else {
2266 -MAX_CLARITY_DB
2267 };
2268
2269 band_c50s.push(c50);
2270 band_c80s.push(c80);
2271 valid_centers.push(freq);
2272 }
2273
2274 log::info!(
2276 "[Clarity] Per-band C50: {:?}",
2277 valid_centers
2278 .iter()
2279 .zip(band_c50s.iter())
2280 .map(|(f, v)| format!("{:.0}Hz:{:.1}dB", f, v))
2281 .collect::<Vec<_>>()
2282 );
2283
2284 if valid_centers.is_empty() {
2285 return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2286 }
2287
2288 let c50_interp = interpolate_log(&valid_centers, &band_c50s, frequencies);
2290 let c80_interp = interpolate_log(&valid_centers, &band_c80s, frequencies);
2291
2292 (c50_interp, c80_interp)
2293}
2294
2295pub fn compute_spectrogram(
2299 impulse: &[f32],
2300 sample_rate: f32,
2301 window_size: usize,
2302 hop_size: usize,
2303) -> (Vec<Vec<f32>>, Vec<f32>, Vec<f32>) {
2304 use rustfft::num_complex::Complex;
2305
2306 if impulse.len() < window_size {
2307 return (Vec::new(), Vec::new(), Vec::new());
2308 }
2309
2310 let num_frames = (impulse.len() - window_size) / hop_size;
2311 let mut spectrogram = Vec::with_capacity(num_frames);
2312 let mut times = Vec::with_capacity(num_frames);
2313
2314 let window: Vec<f32> = (0..window_size)
2316 .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (window_size as f32 - 1.0)).cos()))
2317 .collect();
2318
2319 let fft = plan_fft_forward(window_size);
2321
2322 for i in 0..num_frames {
2323 let start = i * hop_size;
2324 let time_ms = (start as f32 / sample_rate) * 1000.0;
2325 times.push(time_ms);
2326
2327 let mut buffer: Vec<Complex<f32>> = (0..window_size)
2328 .map(|j| {
2329 let sample = impulse.get(start + j).copied().unwrap_or(0.0);
2330 Complex::new(sample * window[j], 0.0)
2331 })
2332 .collect();
2333
2334 fft.process(&mut buffer);
2335
2336 let magnitude_db: Vec<f32> = buffer[..window_size / 2]
2339 .iter()
2340 .map(|c| {
2341 let mag = c.norm();
2342 if mag > 1e-9 {
2343 20.0 * mag.log10()
2344 } else {
2345 -180.0
2346 }
2347 })
2348 .collect();
2349
2350 spectrogram.push(magnitude_db);
2351 }
2352
2353 let num_bins = window_size / 2;
2355 let freq_step = sample_rate / window_size as f32;
2356 let freqs: Vec<f32> = (0..num_bins).map(|i| i as f32 * freq_step).collect();
2357
2358 (spectrogram, freqs, times)
2359}
2360
2361pub fn find_db_point(
2372 frequencies: &[f32],
2373 magnitude_db: &[f32],
2374 target_db: f32,
2375 from_start: bool,
2376) -> Option<f32> {
2377 if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2378 return None;
2379 }
2380
2381 if from_start {
2382 for i in 0..magnitude_db.len() - 1 {
2383 let m0 = magnitude_db[i];
2384 let m1 = magnitude_db[i + 1];
2385
2386 if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2388 let denominator = m1 - m0;
2390 if denominator.abs() < 1e-9 {
2391 return Some(frequencies[i]);
2392 }
2393 let t = (target_db - m0) / denominator;
2394 return Some(frequencies[i] + t * (frequencies[i + 1] - frequencies[i]));
2395 }
2396 }
2397 } else {
2398 for i in (1..magnitude_db.len()).rev() {
2399 let m0 = magnitude_db[i];
2400 let m1 = magnitude_db[i - 1];
2401
2402 if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2404 let denominator = m1 - m0;
2405 if denominator.abs() < 1e-9 {
2406 return Some(frequencies[i]);
2407 }
2408 let t = (target_db - m0) / denominator;
2409 return Some(frequencies[i] + t * (frequencies[i - 1] - frequencies[i]));
2410 }
2411 }
2412 }
2413
2414 None
2415}
2416
2417pub fn compute_average_response(
2431 frequencies: &[f32],
2432 magnitude_db: &[f32],
2433 freq_range: Option<(f32, f32)>,
2434) -> f32 {
2435 if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2436 return magnitude_db.first().copied().unwrap_or(0.0);
2437 }
2438
2439 let (start_freq, end_freq) =
2440 freq_range.unwrap_or((frequencies[0], frequencies[frequencies.len() - 1]));
2441
2442 let mut sum_weighted_db = 0.0;
2443 let mut sum_weights = 0.0;
2444
2445 for i in 0..frequencies.len() - 1 {
2446 let f0 = frequencies[i];
2447 let f1 = frequencies[i + 1];
2448
2449 if f1 < start_freq || f0 > end_freq {
2451 continue;
2452 }
2453
2454 let fa = f0.max(start_freq);
2456 let fb = f1.min(end_freq);
2457
2458 if fb <= fa {
2459 continue;
2460 }
2461
2462 let weight = (fb / fa).log2();
2465
2466 let m0 = magnitude_db[i];
2471 let m1 = magnitude_db[i + 1];
2472 let avg_m = (m0 + m1) / 2.0;
2473
2474 sum_weighted_db += avg_m * weight;
2475 sum_weights += weight;
2476 }
2477
2478 if sum_weights > 0.0 {
2479 sum_weighted_db / sum_weights
2480 } else {
2481 magnitude_db.first().copied().unwrap_or(0.0)
2482 }
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487 use super::*;
2488
2489 #[test]
2490 fn test_next_power_of_two() {
2491 assert_eq!(next_power_of_two(1), 1);
2492 assert_eq!(next_power_of_two(2), 2);
2493 assert_eq!(next_power_of_two(3), 4);
2494 assert_eq!(next_power_of_two(1000), 1024);
2495 assert_eq!(next_power_of_two(1024), 1024);
2496 assert_eq!(next_power_of_two(1025), 2048);
2497 }
2498
2499 #[test]
2500 fn test_hann_window() {
2501 let signal = vec![1.0; 100];
2502 let windowed = apply_hann_window(&signal);
2503
2504 assert!(windowed[0].abs() < 0.01);
2506 assert!(windowed[99].abs() < 0.01);
2507
2508 assert!((windowed[50] - 1.0).abs() < 0.01);
2510 }
2511
2512 #[test]
2513 fn test_estimate_lag_zero() {
2514 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2516 let lag = estimate_lag(&signal, &signal).unwrap();
2517 assert_eq!(lag, 0);
2518 }
2519
2520 #[test]
2521 fn test_estimate_lag_positive() {
2522 let mut reference = vec![0.0; 100];
2525 let mut recorded = vec![0.0; 100];
2526
2527 for (j, val) in reference[10..20].iter_mut().enumerate() {
2529 *val = j as f32 / 10.0;
2530 }
2531 for (j, val) in recorded[15..25].iter_mut().enumerate() {
2533 *val = j as f32 / 10.0;
2534 }
2535
2536 let lag = estimate_lag(&reference, &recorded).unwrap();
2537 assert_eq!(lag, 5, "Recorded signal is delayed by 5 samples");
2538 }
2539
2540 #[test]
2541 fn test_identical_signals_have_zero_lag() {
2542 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2545 let lag = estimate_lag(&signal, &signal).unwrap();
2546 assert_eq!(lag, 0, "Identical signals should have zero lag");
2547 }
2548
2549 fn write_test_wav(path: &std::path::Path, samples: &[f32], sample_rate: u32) {
2551 let spec = hound::WavSpec {
2552 channels: 1,
2553 sample_rate,
2554 bits_per_sample: 32,
2555 sample_format: hound::SampleFormat::Float,
2556 };
2557 let mut writer = hound::WavWriter::create(path, spec).unwrap();
2558 for &s in samples {
2559 writer.write_sample(s).unwrap();
2560 }
2561 writer.finalize().unwrap();
2562 }
2563
2564 fn generate_test_sweep(
2566 start_freq: f32,
2567 end_freq: f32,
2568 duration_secs: f32,
2569 sample_rate: u32,
2570 amplitude: f32,
2571 ) -> Vec<f32> {
2572 let num_samples = (duration_secs * sample_rate as f32) as usize;
2573 let mut signal = Vec::with_capacity(num_samples);
2574 let ln_ratio = (end_freq / start_freq).ln();
2575 for i in 0..num_samples {
2576 let t = i as f32 / sample_rate as f32;
2577 let phase = 2.0 * PI * start_freq * duration_secs / ln_ratio
2578 * ((t / duration_secs * ln_ratio).exp() - 1.0);
2579 signal.push(amplitude * phase.sin());
2580 }
2581 signal
2582 }
2583
2584 #[test]
2585 fn test_analyze_recording_normal_channel() {
2586 let sample_rate = 48000;
2589 let duration = 1.0;
2590 let reference = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
2591
2592 let delay = 100;
2594 let attenuation = 0.5;
2595 let mut recorded = vec![0.0_f32; reference.len() + delay];
2596 for (i, &s) in reference.iter().enumerate() {
2597 recorded[i + delay] = s * attenuation;
2598 }
2599
2600 let dir = std::env::temp_dir().join(format!(
2601 "sotf_test_normal_{}",
2602 std::process::id()
2603 ));
2604 std::fs::create_dir_all(&dir).unwrap();
2605 let wav_path = dir.join("test_normal.wav");
2606 write_test_wav(&wav_path, &recorded, sample_rate);
2607
2608 let result = analyze_recording(&wav_path, &reference, sample_rate, None).unwrap();
2609 std::fs::remove_dir_all(&dir).ok();
2610
2611 let mut sum = 0.0_f32;
2613 let mut count = 0;
2614 for (&freq, &db) in result.frequencies.iter().zip(result.spl_db.iter()) {
2615 if (200.0..=10000.0).contains(&freq) {
2616 sum += db;
2617 count += 1;
2618 }
2619 }
2620 let avg_db = sum / count as f32;
2621
2622 assert!(
2625 avg_db > -12.0 && avg_db < 0.0,
2626 "Normal channel avg SPL should be near -6 dB, got {:.1} dB",
2627 avg_db
2628 );
2629
2630 let max_db = result
2632 .spl_db
2633 .iter()
2634 .zip(result.frequencies.iter())
2635 .filter(|&(_, &f)| (200.0..=10000.0).contains(&f))
2636 .map(|(&db, _)| db)
2637 .fold(f32::NEG_INFINITY, f32::max);
2638 assert!(
2639 max_db < 6.0,
2640 "Normal channel should not have bins above +6 dB, got {:.1} dB",
2641 max_db
2642 );
2643 }
2644
2645 #[test]
2646 fn test_analyze_recording_silent_channel() {
2647 let sample_rate = 48000;
2650 let duration = 1.0;
2651 let reference = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
2652
2653 let noise_amplitude = 0.001;
2655 let num_samples = reference.len();
2656 let mut recorded = Vec::with_capacity(num_samples);
2657 for i in 0..num_samples {
2659 let pseudo_noise = noise_amplitude
2660 * (((i as f32 * 0.1).sin() + (i as f32 * 0.37).cos()) * 0.5);
2661 recorded.push(pseudo_noise);
2662 }
2663
2664 let dir = std::env::temp_dir().join(format!(
2665 "sotf_test_silent_{}",
2666 std::process::id()
2667 ));
2668 std::fs::create_dir_all(&dir).unwrap();
2669 let wav_path = dir.join("test_silent.wav");
2670 write_test_wav(&wav_path, &recorded, sample_rate);
2671
2672 let result = analyze_recording(&wav_path, &reference, sample_rate, None).unwrap();
2673 std::fs::remove_dir_all(&dir).ok();
2674
2675 let max_db = result
2678 .spl_db
2679 .iter()
2680 .zip(result.frequencies.iter())
2681 .filter(|&(_, &f)| (100.0..=10000.0).contains(&f))
2682 .map(|(&db, _)| db)
2683 .fold(f32::NEG_INFINITY, f32::max);
2684
2685 assert!(
2686 max_db < 0.0,
2687 "Silent/disconnected channel should not have positive dB values, got max {:.1} dB",
2688 max_db
2689 );
2690 }
2691
2692 #[test]
2693 fn test_analyze_recording_lfe_narrow_sweep_same_point_count() {
2694 let sample_rate = 48000;
2699 let duration = 1.0;
2700
2701 let ref_full = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
2703 let ref_lfe = generate_test_sweep(20.0, 500.0, duration, sample_rate, 0.5);
2705
2706 let delay = 50;
2708 let atten = 0.3;
2709
2710 let mut rec_full = vec![0.0_f32; ref_full.len() + delay];
2711 for (i, &s) in ref_full.iter().enumerate() {
2712 rec_full[i + delay] = s * atten;
2713 }
2714
2715 let mut rec_lfe = vec![0.0_f32; ref_lfe.len() + delay];
2716 for (i, &s) in ref_lfe.iter().enumerate() {
2717 rec_lfe[i + delay] = s * atten;
2718 }
2719
2720 let dir = std::env::temp_dir().join(format!(
2721 "sotf_test_lfe_points_{}",
2722 std::process::id()
2723 ));
2724 std::fs::create_dir_all(&dir).unwrap();
2725
2726 let wav_full = dir.join("main.wav");
2727 let wav_lfe = dir.join("lfe.wav");
2728 write_test_wav(&wav_full, &rec_full, sample_rate);
2729 write_test_wav(&wav_lfe, &rec_lfe, sample_rate);
2730
2731 let result_full = analyze_recording(&wav_full, &ref_full, sample_rate, None).unwrap();
2732 let result_lfe = analyze_recording(&wav_lfe, &ref_lfe, sample_rate, None).unwrap();
2733 std::fs::remove_dir_all(&dir).ok();
2734
2735 assert_eq!(
2737 result_full.frequencies.len(),
2738 result_lfe.frequencies.len(),
2739 "Main ({}) and LFE ({}) must have the same number of frequency points",
2740 result_full.frequencies.len(),
2741 result_lfe.frequencies.len()
2742 );
2743 assert_eq!(
2744 result_full.spl_db.len(),
2745 result_lfe.spl_db.len(),
2746 "SPL arrays must match in length"
2747 );
2748
2749 let lfe_valid_count = result_lfe
2751 .spl_db
2752 .iter()
2753 .zip(result_lfe.frequencies.iter())
2754 .filter(|&(&db, &f)| f <= 500.0 && db > -100.0)
2755 .count();
2756 assert!(
2757 lfe_valid_count > 100,
2758 "LFE should have valid data below 500 Hz, got {} points",
2759 lfe_valid_count
2760 );
2761
2762 let lfe_above_500_max = result_lfe
2763 .spl_db
2764 .iter()
2765 .zip(result_lfe.frequencies.iter())
2766 .filter(|&(_, &f)| f > 1000.0)
2767 .map(|(&db, _)| db)
2768 .fold(f32::NEG_INFINITY, f32::max);
2769 assert!(
2770 lfe_above_500_max <= -100.0,
2771 "LFE above 1 kHz should be at noise floor, got {:.1} dB",
2772 lfe_above_500_max
2773 );
2774 }
2775}