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
27pub fn plan_fft_forward(size: usize) -> Arc<dyn rustfft::Fft<f32>> {
32 FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(size))
33}
34
35pub fn plan_fft_inverse(size: usize) -> Arc<dyn rustfft::Fft<f32>> {
37 FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_inverse(size))
38}
39
40#[derive(Debug, Clone)]
42pub struct MicrophoneCompensation {
43 pub frequencies: Vec<f32>,
45 pub spl_db: Vec<f32>,
47}
48
49impl MicrophoneCompensation {
50 pub fn apply_to_sweep(
65 &self,
66 signal: &[f32],
67 start_freq: f32,
68 end_freq: f32,
69 sample_rate: u32,
70 inverse: bool,
71 ) -> Vec<f32> {
72 let duration = signal.len() as f32 / sample_rate as f32;
73 let mut compensated = Vec::with_capacity(signal.len());
74
75 let debug_points = [0, signal.len() / 4, signal.len() / 2, 3 * signal.len() / 4];
77
78 for (i, &sample) in signal.iter().enumerate() {
79 let t = i as f32 / sample_rate as f32;
80
81 let freq = start_freq * ((t * (end_freq / start_freq).ln()) / duration).exp();
84
85 let comp_db = self.interpolate_at(freq);
87
88 let gain_db = if inverse { -comp_db } else { comp_db };
90
91 let gain = 10_f32.powf(gain_db / 20.0);
93
94 if debug_points.contains(&i) {
96 log::debug!(
97 "[apply_to_sweep] t={:.3}s, freq={:.1}Hz, comp_db={:.2}dB, gain_db={:.2}dB, gain={:.3}x",
98 t,
99 freq,
100 comp_db,
101 gain_db,
102 gain
103 );
104 }
105
106 compensated.push(sample * gain);
107 }
108
109 log::debug!(
110 "[apply_to_sweep] Processed {} samples, duration={:.2}s",
111 signal.len(),
112 duration
113 );
114 compensated
115 }
116
117 pub fn from_file(path: &Path) -> Result<Self, String> {
123 use std::fs::File;
124 use std::io::{BufRead, BufReader};
125
126 log::debug!("[MicrophoneCompensation] Loading from {:?}", path);
127
128 let file = File::open(path)
129 .map_err(|e| format!("Failed to open compensation file {:?}: {}", path, e))?;
130 let reader = BufReader::new(file);
131
132 let is_txt_file = path
134 .extension()
135 .and_then(|e| e.to_str())
136 .map(|e| e.to_lowercase() == "txt")
137 .unwrap_or(false);
138
139 if is_txt_file {
140 log::info!(
141 "[MicrophoneCompensation] Detected .txt file - assuming space/tab-separated without header"
142 );
143 }
144
145 let mut frequencies = Vec::new();
146 let mut spl_db = Vec::new();
147
148 for (line_num, line) in reader.lines().enumerate() {
149 let line = line.map_err(|e| format!("Failed to read line {}: {}", line_num + 1, e))?;
150 let line = line.trim();
151
152 if line.is_empty() || line.starts_with('#') {
154 continue;
155 }
156
157 if !is_txt_file && line.starts_with("frequency") {
159 continue;
160 }
161
162 if is_txt_file {
164 let first_char = line.chars().next().unwrap_or(' ');
165 if !first_char.is_ascii_digit() && first_char != '-' && first_char != '+' {
166 log::info!(
167 "[MicrophoneCompensation] Skipping non-numeric line {}: '{}'",
168 line_num + 1,
169 line
170 );
171 continue;
172 }
173 }
174
175 let parts: Vec<&str> = if is_txt_file {
177 let comma_parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
180 if comma_parts.len() >= 2
181 && comma_parts[0].parse::<f32>().is_ok()
182 && comma_parts[1].parse::<f32>().is_ok()
183 {
184 comma_parts
185 } else {
186 let tab_parts: Vec<&str> = line.split('\t').map(|s| s.trim()).collect();
188 if tab_parts.len() >= 2
189 && tab_parts[0].parse::<f32>().is_ok()
190 && tab_parts[1].parse::<f32>().is_ok()
191 {
192 tab_parts
193 } else {
194 line.split_whitespace().collect()
196 }
197 }
198 } else {
199 line.split(',').collect()
201 };
202
203 if parts.len() < 2 {
204 let separator = if is_txt_file {
205 "separator (comma/tab/space)"
206 } else {
207 "comma"
208 };
209 return Err(format!(
210 "Invalid format at line {}: expected {} with 2+ values but got '{}'",
211 line_num + 1,
212 separator,
213 line
214 ));
215 }
216
217 let freq: f32 = parts[0]
218 .trim()
219 .parse()
220 .map_err(|e| format!("Invalid frequency at line {}: {}", line_num + 1, e))?;
221 let spl: f32 = parts[1]
222 .trim()
223 .parse()
224 .map_err(|e| format!("Invalid SPL at line {}: {}", line_num + 1, e))?;
225
226 frequencies.push(freq);
227 spl_db.push(spl);
228 }
229
230 if frequencies.is_empty() {
231 return Err(format!("No compensation data found in {:?}", path));
232 }
233
234 for i in 1..frequencies.len() {
236 if frequencies[i] <= frequencies[i - 1] {
237 return Err(format!(
238 "Frequencies must be strictly increasing: found {} after {} at line {}",
239 frequencies[i],
240 frequencies[i - 1],
241 i + 1
242 ));
243 }
244 }
245
246 log::info!(
247 "[MicrophoneCompensation] Loaded {} calibration points: {:.1} Hz - {:.1} Hz",
248 frequencies.len(),
249 frequencies[0],
250 frequencies[frequencies.len() - 1]
251 );
252 log::info!(
253 "[MicrophoneCompensation] SPL range: {:.2} dB to {:.2} dB",
254 spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b)),
255 spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b))
256 );
257
258 Ok(Self {
259 frequencies,
260 spl_db,
261 })
262 }
263
264 pub fn interpolate_at(&self, freq: f32) -> f32 {
269 if freq < self.frequencies[0] || freq > self.frequencies[self.frequencies.len() - 1] {
270 return 0.0;
272 }
273
274 let idx = match self
276 .frequencies
277 .binary_search_by(|f| f.partial_cmp(&freq).unwrap_or(std::cmp::Ordering::Equal))
278 {
279 Ok(i) => return self.spl_db[i], Err(i) => i,
281 };
282
283 if idx == 0 {
284 return self.spl_db[0];
285 }
286 if idx >= self.frequencies.len() {
287 return self.spl_db[self.frequencies.len() - 1];
288 }
289
290 let f0 = self.frequencies[idx - 1];
292 let f1 = self.frequencies[idx];
293 let s0 = self.spl_db[idx - 1];
294 let s1 = self.spl_db[idx];
295
296 let t = (freq - f0) / (f1 - f0);
297 s0 + t * (s1 - s0)
298 }
299}
300
301#[derive(Debug, Clone)]
307pub struct WavAnalysisConfig {
308 pub num_points: usize,
310 pub min_freq: f32,
312 pub max_freq: f32,
314 pub fft_size: Option<usize>,
316 pub overlap: f32,
318 pub single_fft: bool,
320 pub pink_compensation: bool,
322 pub no_window: bool,
324}
325
326impl Default for WavAnalysisConfig {
327 fn default() -> Self {
328 Self {
329 num_points: 2000,
330 min_freq: 20.0,
331 max_freq: 20000.0,
332 fft_size: None,
333 overlap: 0.5,
334 single_fft: false,
335 pink_compensation: false,
336 no_window: false,
337 }
338 }
339}
340
341impl WavAnalysisConfig {
342 pub fn for_log_sweep() -> Self {
344 Self {
345 single_fft: true,
346 pink_compensation: true,
347 no_window: true,
348 ..Default::default()
349 }
350 }
351
352 pub fn for_impulse_response() -> Self {
354 Self {
355 single_fft: true,
356 ..Default::default()
357 }
358 }
359
360 pub fn for_stationary() -> Self {
362 Self::default()
363 }
364}
365
366#[derive(Debug, Clone)]
368pub struct WavAnalysisOutput {
369 pub frequencies: Vec<f32>,
371 pub magnitude_db: Vec<f32>,
373 pub phase_deg: Vec<f32>,
375}
376
377pub fn analyze_wav_buffer(
387 samples: &[f32],
388 sample_rate: u32,
389 config: &WavAnalysisConfig,
390) -> Result<WavAnalysisOutput, String> {
391 if samples.is_empty() {
392 return Err("Signal is empty".to_string());
393 }
394
395 let fft_size = if config.single_fft {
397 config
398 .fft_size
399 .unwrap_or_else(|| wav_next_power_of_two(samples.len()))
400 } else {
401 config.fft_size.unwrap_or(16384)
402 };
403
404 let (freqs, magnitudes_db, phases_deg) = if config.single_fft {
406 compute_single_fft_spectrum_internal(samples, sample_rate, fft_size, config.no_window)?
407 } else {
408 compute_welch_spectrum_internal(samples, sample_rate, fft_size, config.overlap)?
409 };
410
411 let log_freqs = generate_log_frequencies(config.num_points, config.min_freq, config.max_freq);
413
414 let mut interp_mag = interpolate_log(&freqs, &magnitudes_db, &log_freqs);
416 let interp_phase = interpolate_log_phase(&freqs, &phases_deg, &log_freqs);
417
418 if config.pink_compensation {
420 let ref_freq = 1000.0;
421 for (i, freq) in log_freqs.iter().enumerate() {
422 if *freq > 0.0 {
423 let correction = 10.0 * (freq / ref_freq).log10();
424 interp_mag[i] += correction;
425 }
426 }
427 }
428
429 Ok(WavAnalysisOutput {
430 frequencies: log_freqs,
431 magnitude_db: interp_mag,
432 phase_deg: interp_phase,
433 })
434}
435
436pub fn analyze_wav_file(
445 path: &Path,
446 config: &WavAnalysisConfig,
447) -> Result<WavAnalysisOutput, String> {
448 let (samples, sample_rate) = load_wav_mono_with_rate(path)?;
449 analyze_wav_buffer(&samples, sample_rate, config)
450}
451
452fn load_wav_mono_with_rate(path: &Path) -> Result<(Vec<f32>, u32), String> {
454 let mut reader =
455 WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
456
457 let spec = reader.spec();
458 let sample_rate = spec.sample_rate;
459 let channels = spec.channels as usize;
460
461 let samples: Result<Vec<f32>, _> = match spec.sample_format {
462 hound::SampleFormat::Float => reader.samples::<f32>().collect(),
463 hound::SampleFormat::Int => {
464 let max_val = (1_i64 << (spec.bits_per_sample - 1)) as f32;
465 reader
466 .samples::<i32>()
467 .map(|s| s.map(|v| v as f32 / max_val))
468 .collect()
469 }
470 };
471
472 let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
473
474 let mono = if channels == 1 {
476 samples
477 } else {
478 samples
479 .chunks(channels)
480 .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
481 .collect()
482 };
483
484 Ok((mono, sample_rate))
485}
486
487pub fn write_wav_analysis_csv(result: &WavAnalysisOutput, path: &Path) -> Result<(), String> {
493 let mut file =
494 std::fs::File::create(path).map_err(|e| format!("Failed to create CSV: {}", e))?;
495
496 writeln!(file, "frequency_hz,spl_db,phase_deg")
497 .map_err(|e| format!("Failed to write CSV header: {}", e))?;
498
499 for i in 0..result.frequencies.len() {
500 writeln!(
501 file,
502 "{:.2},{:.2},{:.2}",
503 result.frequencies[i], result.magnitude_db[i], result.phase_deg[i]
504 )
505 .map_err(|e| format!("Failed to write CSV row: {}", e))?;
506 }
507
508 Ok(())
509}
510
511fn compute_welch_spectrum_internal(
513 signal: &[f32],
514 sample_rate: u32,
515 fft_size: usize,
516 overlap: f32,
517) -> SpectrumResult {
518 if signal.is_empty() {
519 return Err("Signal is empty".to_string());
520 }
521
522 let overlap_samples = (fft_size as f32 * overlap.clamp(0.0, 0.95)) as usize;
523 let hop_size = fft_size - overlap_samples;
524
525 let num_windows = if signal.len() >= fft_size {
526 1 + (signal.len() - fft_size) / hop_size
527 } else {
528 1
529 };
530
531 let num_bins = fft_size / 2;
532 let mut magnitude_sum = vec![0.0_f32; num_bins];
533 let mut phase_real_sum = vec![0.0_f32; num_bins];
534 let mut phase_imag_sum = vec![0.0_f32; num_bins];
535
536 let hann_window = crate::stft::generate_hann_window_symmetric(fft_size);
538
539 let window_power: f32 = hann_window.iter().map(|&w| w * w).sum();
540 let scale_factor = 2.0 / window_power;
541
542 let fft = plan_fft_forward(fft_size);
543
544 let mut windowed = vec![0.0_f32; fft_size];
545 let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
546
547 for window_idx in 0..num_windows {
548 let start = window_idx * hop_size;
549 let end = (start + fft_size).min(signal.len());
550 let window_len = end - start;
551
552 for i in 0..window_len {
554 windowed[i] = signal[start + i] * hann_window[i];
555 }
556 windowed[window_len..fft_size].fill(0.0);
558
559 for (i, &val) in windowed.iter().enumerate() {
561 buffer[i] = Complex::new(val, 0.0);
562 }
563
564 fft.process(&mut buffer);
565
566 for i in 0..num_bins {
567 let mag = buffer[i].norm() * scale_factor.sqrt();
568 magnitude_sum[i] += mag * mag;
569 phase_real_sum[i] += buffer[i].re;
570 phase_imag_sum[i] += buffer[i].im;
571 }
572 }
573
574 let magnitudes_db: Vec<f32> = magnitude_sum
575 .iter()
576 .map(|&mag_sq| {
577 let mag = (mag_sq / num_windows as f32).sqrt();
578 if mag > 1e-10 {
579 20.0 * mag.log10()
580 } else {
581 -200.0
582 }
583 })
584 .collect();
585
586 let phases_deg: Vec<f32> = phase_real_sum
587 .iter()
588 .zip(phase_imag_sum.iter())
589 .map(|(&re, &im)| (im / num_windows as f32).atan2(re / num_windows as f32) * 180.0 / PI)
590 .collect();
591
592 let freqs: Vec<f32> = (0..num_bins)
593 .map(|i| i as f32 * sample_rate as f32 / fft_size as f32)
594 .collect();
595
596 Ok((freqs, magnitudes_db, phases_deg))
597}
598
599fn compute_single_fft_spectrum_internal(
601 signal: &[f32],
602 sample_rate: u32,
603 fft_size: usize,
604 no_window: bool,
605) -> SpectrumResult {
606 if signal.is_empty() {
607 return Err("Signal is empty".to_string());
608 }
609
610 let mut windowed = vec![0.0_f32; fft_size];
611 let copy_len = signal.len().min(fft_size);
612 windowed[..copy_len].copy_from_slice(&signal[..copy_len]);
613
614 let window_scale_factor = if no_window {
615 1.0
616 } else {
617 let hann_window = crate::stft::generate_hann_window_symmetric(fft_size);
618
619 for (i, sample) in windowed.iter_mut().enumerate() {
620 *sample *= hann_window[i];
621 }
622
623 hann_window.iter().map(|&w| w * w).sum::<f32>()
624 };
625
626 let mut buffer: Vec<Complex<f32>> = windowed.iter().map(|&x| Complex::new(x, 0.0)).collect();
627
628 let fft = plan_fft_forward(fft_size);
629 fft.process(&mut buffer);
630
631 let scale_factor = if no_window {
632 (2.0 / fft_size as f32).sqrt()
633 } else {
634 (2.0 / window_scale_factor).sqrt()
635 };
636
637 let num_bins = fft_size / 2;
638 let magnitudes_db: Vec<f32> = buffer[..num_bins]
639 .iter()
640 .map(|c| {
641 let mag = c.norm() * scale_factor;
642 if mag > 1e-10 {
643 20.0 * mag.log10()
644 } else {
645 -200.0
646 }
647 })
648 .collect();
649
650 let phases_deg: Vec<f32> = buffer[..num_bins]
651 .iter()
652 .map(|c| c.arg() * 180.0 / PI)
653 .collect();
654
655 let freqs: Vec<f32> = (0..num_bins)
656 .map(|i| i as f32 * sample_rate as f32 / fft_size as f32)
657 .collect();
658
659 Ok((freqs, magnitudes_db, phases_deg))
660}
661
662fn wav_next_power_of_two(n: usize) -> usize {
664 let mut p = 1;
665 while p < n {
666 p *= 2;
667 }
668 p.min(1048576)
669}
670
671fn generate_log_frequencies(num_points: usize, min_freq: f32, max_freq: f32) -> Vec<f32> {
673 let log_min = min_freq.ln();
674 let log_max = max_freq.ln();
675 let step = (log_max - log_min) / (num_points - 1) as f32;
676
677 (0..num_points)
678 .map(|i| (log_min + i as f32 * step).exp())
679 .collect()
680}
681
682fn interpolate_log(x: &[f32], y: &[f32], x_new: &[f32]) -> Vec<f32> {
684 x_new
685 .iter()
686 .map(|&freq| {
687 let idx = x.partition_point(|&f| f < freq).min(x.len() - 1);
688
689 if idx == 0 {
690 return y[0];
691 }
692
693 let x0 = x[idx - 1];
694 let x1 = x[idx];
695 let y0 = y[idx - 1];
696 let y1 = y[idx];
697
698 if x1 <= x0 {
699 return y0;
700 }
701
702 let t = (freq - x0) / (x1 - x0);
703 y0 + t * (y1 - y0)
704 })
705 .collect()
706}
707
708fn interpolate_log_phase(x: &[f32], phase_deg: &[f32], x_new: &[f32]) -> Vec<f32> {
711 x_new
712 .iter()
713 .map(|&freq| {
714 let idx = x.partition_point(|&f| f < freq).min(x.len() - 1);
715
716 if idx == 0 {
717 return phase_deg[0];
718 }
719
720 let x0 = x[idx - 1];
721 let x1 = x[idx];
722
723 if x1 <= x0 {
724 return phase_deg[idx - 1];
725 }
726
727 let t = (freq - x0) / (x1 - x0);
728
729 let p0 = phase_deg[idx - 1];
731 let p1 = phase_deg[idx];
732 let mut diff = p1 - p0;
733 diff -= 360.0 * (diff / 360.0).round();
735 p0 + t * diff
736 })
737 .collect()
738}
739
740#[derive(Debug, Clone)]
746pub struct AnalysisResult {
747 pub frequencies: Vec<f32>,
749 pub spl_db: Vec<f32>,
751 pub phase_deg: Vec<f32>,
753 pub estimated_lag_samples: isize,
755 pub impulse_response: Vec<f32>,
757 pub impulse_time_ms: Vec<f32>,
759 pub excess_group_delay_ms: Vec<f32>,
761 pub thd_percent: Vec<f32>,
763 pub harmonic_distortion_db: Vec<Vec<f32>>,
765 pub rt60_ms: Vec<f32>,
767 pub clarity_c50_db: Vec<f32>,
769 pub clarity_c80_db: Vec<f32>,
771 pub spectrogram_db: Vec<Vec<f32>>,
773}
774
775pub fn analyze_recording(
786 recorded_path: &Path,
787 reference_signal: &[f32],
788 sample_rate: u32,
789 sweep_range: Option<(f32, f32)>,
790) -> Result<AnalysisResult, String> {
791 log::debug!("[FFT Analysis] Loading recorded file: {:?}", recorded_path);
793 let recorded = load_wav_mono(recorded_path)?;
794 log::debug!(
795 "[FFT Analysis] Loaded {} samples from recording",
796 recorded.len()
797 );
798 log::debug!(
799 "[FFT Analysis] Reference has {} samples",
800 reference_signal.len()
801 );
802
803 if recorded.is_empty() {
804 return Err("Recorded signal is empty!".to_string());
805 }
806 if reference_signal.is_empty() {
807 return Err("Reference signal is empty!".to_string());
808 }
809
810 let recorded = &recorded[..];
812 let reference = reference_signal;
813
814 if log::log_enabled!(log::Level::Debug) {
816 let ref_max = reference
817 .iter()
818 .map(|&x| x.abs())
819 .fold(0.0_f32, |a, b| a.max(b));
820 let rec_max = recorded
821 .iter()
822 .map(|&x| x.abs())
823 .fold(0.0_f32, |a, b| a.max(b));
824 let ref_rms =
825 (reference.iter().map(|&x| x * x).sum::<f32>() / reference.len() as f32).sqrt();
826 let rec_rms = (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 ref_peak_mag_sq = ref_spectrum[1..num_bins.min(ref_spectrum.len())]
923 .iter()
924 .map(|c| c.norm_sqr())
925 .fold(0.0_f32, |a, b| a.max(b));
926 let ref_regularization_threshold = ref_peak_mag_sq * 1e-6;
928
929 let mut skipped_count = 0;
931 for i in 0..num_output_points {
932 let target_freq =
934 (log_start + (log_end - log_start) * i as f32 / (num_output_points - 1) as f32).exp();
935
936 let octave_fraction = 1.0 / 48.0;
939 let freq_lower = target_freq * 2.0_f32.powf(-octave_fraction);
940 let freq_upper = target_freq * 2.0_f32.powf(octave_fraction);
941
942 let bin_lower = ((freq_lower / freq_resolution).floor() as usize).max(1);
944 let bin_upper = ((freq_upper / freq_resolution).ceil() as usize).min(num_bins);
945
946 if bin_lower > bin_upper || bin_upper >= ref_spectrum.len() {
947 if skipped_count < 5 {
948 log::debug!(
949 "[FFT Analysis] Skipping freq {:.1} Hz: bin_lower={}, bin_upper={}, ref_spectrum.len()={}",
950 target_freq,
951 bin_lower,
952 bin_upper,
953 ref_spectrum.len()
954 );
955 }
956 skipped_count += 1;
957 frequencies.push(target_freq);
960 spl_db.push(-200.0);
961 phase_deg.push(0.0);
962 continue;
963 }
964
965 let mut sum_magnitude = 0.0;
967 let mut sum_sin = 0.0; let mut sum_cos = 0.0;
969 let mut bin_count = 0;
970
971 for k in bin_lower..=bin_upper {
972 if k >= ref_spectrum.len() {
973 break;
974 }
975
976 let ref_mag_sq = ref_spectrum[k].norm_sqr();
982 if ref_mag_sq <= ref_regularization_threshold {
983 continue;
984 }
985 let transfer_function = rec_spectrum[k] / ref_spectrum[k];
986 let magnitude = transfer_function.norm();
987
988 let cross_spectrum = ref_spectrum[k].conj() * rec_spectrum[k];
990 let phase_rad = cross_spectrum.arg();
991
992 sum_magnitude += magnitude;
994 sum_sin += phase_rad.sin();
995 sum_cos += phase_rad.cos();
996 bin_count += 1;
997 }
998
999 let (avg_magnitude, db) = if bin_count == 0 {
1004 (0.0, -200.0)
1005 } else {
1006 let avg = sum_magnitude / bin_count as f32;
1007 (avg, 20.0 * avg.max(1e-10).log10())
1008 };
1009
1010 if frequencies.len() < 5 {
1011 log::debug!(
1012 "[FFT Analysis] freq={:.1} Hz: avg_magnitude={:.6}, dB={:.2}",
1013 target_freq,
1014 avg_magnitude,
1015 db
1016 );
1017 }
1018
1019 let avg_phase_rad = sum_sin.atan2(sum_cos);
1021 let phase = avg_phase_rad * 180.0 / PI;
1022
1023 frequencies.push(target_freq);
1024 spl_db.push(db);
1025 phase_deg.push(phase);
1026 }
1027
1028 log::debug!(
1029 "[FFT Analysis] Generated {} frequency points for CSV output",
1030 frequencies.len()
1031 );
1032 log::debug!(
1033 "[FFT Analysis] Skipped {} frequency points (out of {})",
1034 skipped_count,
1035 num_output_points
1036 );
1037
1038 if log::log_enabled!(log::Level::Debug) && !spl_db.is_empty() {
1039 let min_spl = spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1040 let max_spl = spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1041 log::debug!(
1042 "[FFT Analysis] SPL range: {:.2} dB to {:.2} dB",
1043 min_spl,
1044 max_spl
1045 );
1046 }
1047
1048 let mut transfer_function = vec![Complex::new(0.0, 0.0); fft_size];
1051 for k in 0..fft_size {
1052 let ref_mag_sq = ref_spectrum[k].norm_sqr();
1055 if ref_mag_sq > 1e-20 {
1056 transfer_function[k] = rec_spectrum[k] / ref_spectrum[k];
1057 }
1058 }
1059
1060 let ifft = plan_fft_inverse(fft_size);
1062 ifft.process(&mut transfer_function);
1063
1064 let norm = 1.0 / fft_size as f32;
1069 let mut impulse_response: Vec<f32> = transfer_function.iter().map(|c| c.re * norm).collect();
1070
1071 let peak_idx = impulse_response
1075 .iter()
1076 .enumerate()
1077 .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1078 .map(|(i, _)| i)
1079 .unwrap_or(0);
1080
1081 let pre_ring_samples = (0.005 * sample_rate as f32) as usize; let shift_amount = peak_idx.saturating_sub(pre_ring_samples);
1084
1085 if shift_amount > 0 {
1086 impulse_response.rotate_left(shift_amount);
1087 log::info!(
1088 "[FFT Analysis] IR peak was at index {}, shifted by {} samples to put peak near beginning",
1089 peak_idx,
1090 shift_amount
1091 );
1092 }
1093
1094 let _ir_duration_sec = fft_size as f32 / sample_rate as f32;
1096 let impulse_time_ms: Vec<f32> = (0..fft_size)
1097 .map(|i| i as f32 / sample_rate as f32 * 1000.0)
1098 .collect();
1099
1100 let (thd_percent, harmonic_distortion_db) = if let Some((start, end)) = sweep_range {
1102 let duration = reference_signal.len() as f32 / sample_rate as f32;
1105 compute_thd_from_ir(
1106 &impulse_response,
1107 sample_rate as f32,
1108 &frequencies,
1109 &spl_db,
1110 start,
1111 end,
1112 duration,
1113 )
1114 } else {
1115 (vec![0.0; frequencies.len()], Vec::new())
1116 };
1117
1118 let excess_group_delay_ms = vec![0.0; frequencies.len()];
1121
1122 let ir_max = impulse_response.iter().fold(0.0f32, |a, &b| a.max(b.abs()));
1125 let ir_len = impulse_response.len();
1126 log::info!(
1127 "[Analysis] Impulse response: len={}, max_abs={:.6}, sample_rate={}",
1128 ir_len,
1129 ir_max,
1130 sample_rate
1131 );
1132
1133 let rt60_ms = compute_rt60_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1134 let (clarity_c50_db, clarity_c80_db) =
1135 compute_clarity_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1136
1137 if !rt60_ms.is_empty() {
1139 let rt60_min = rt60_ms.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1140 let rt60_max = rt60_ms.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1141 log::info!(
1142 "[Analysis] RT60 range: {:.1} - {:.1} ms",
1143 rt60_min,
1144 rt60_max
1145 );
1146 }
1147 if !clarity_c50_db.is_empty() {
1148 let c50_min = clarity_c50_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1149 let c50_max = clarity_c50_db
1150 .iter()
1151 .fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1152 log::info!(
1153 "[Analysis] Clarity C50 range: {:.1} - {:.1} dB",
1154 c50_min,
1155 c50_max
1156 );
1157 }
1158
1159 let (spectrogram_db, _, _) =
1161 compute_spectrogram(&impulse_response, sample_rate as f32, 512, 128);
1162
1163 Ok(AnalysisResult {
1164 frequencies,
1165 spl_db,
1166 phase_deg,
1167 estimated_lag_samples: lag,
1168 impulse_response,
1169 impulse_time_ms,
1170 excess_group_delay_ms,
1171 thd_percent,
1172 harmonic_distortion_db,
1173 rt60_ms,
1174 clarity_c50_db,
1175 clarity_c80_db,
1176 spectrogram_db,
1177 })
1178}
1179
1180fn compute_thd_from_ir(
1184 impulse: &[f32],
1185 sample_rate: f32,
1186 frequencies: &[f32],
1187 fundamental_db: &[f32],
1188 start_freq: f32,
1189 end_freq: f32,
1190 duration: f32,
1191) -> (Vec<f32>, Vec<Vec<f32>>) {
1192 if frequencies.is_empty() {
1193 return (Vec::new(), Vec::new());
1194 }
1195
1196 let n = impulse.len();
1197 if n == 0 {
1198 return (vec![0.0; frequencies.len()], Vec::new());
1199 }
1200
1201 let num_harmonics = 4; let mut harmonics_db = vec![vec![-120.0; frequencies.len()]; num_harmonics];
1204
1205 let peak_idx = impulse
1207 .iter()
1208 .enumerate()
1209 .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1210 .map(|(i, _)| i)
1211 .unwrap_or(0);
1212
1213 let sweep_ratio = end_freq / start_freq;
1214 log::debug!(
1215 "[THD] Impulse len={}, peak_idx={}, duration={:.3}s, sweep {:.0}-{:.0} Hz (ratio {:.1})",
1216 n,
1217 peak_idx,
1218 duration,
1219 start_freq,
1220 end_freq,
1221 sweep_ratio
1222 );
1223
1224 for (k_idx, harmonic_db) in harmonics_db.iter_mut().enumerate().take(num_harmonics) {
1226 let harmonic_order = k_idx + 2; let dt = duration * (harmonic_order as f32).ln() / sweep_ratio.ln();
1231 let dn = (dt * sample_rate).round() as isize;
1232
1233 let center = peak_idx as isize - dn;
1235 let center_wrapped = center.rem_euclid(n as isize) as usize;
1236
1237 let dt_next_rel = duration
1242 * ((harmonic_order as f32 + 1.0).ln() - (harmonic_order as f32).ln())
1243 / sweep_ratio.ln();
1244 let min_win_len = (3.0 * sample_rate / (harmonic_order as f32 * start_freq)).max(16.0);
1245 let win_len = ((dt_next_rel * sample_rate * 0.8).max(min_win_len) as usize).min(n / 2);
1246
1247 let mut harmonic_ir = vec![0.0f32; win_len];
1249 let mut max_harmonic_sample = 0.0f32;
1250 for (i, harmonic_ir_val) in harmonic_ir.iter_mut().enumerate() {
1251 let src_idx =
1252 (center - (win_len as isize / 2) + i as isize).rem_euclid(n as isize) as usize;
1253 let w = 0.5 * (1.0 - (2.0 * PI * i as f32 / (win_len as f32 - 1.0)).cos());
1255 *harmonic_ir_val = impulse[src_idx] * w;
1256 max_harmonic_sample = max_harmonic_sample.max(harmonic_ir_val.abs());
1257 }
1258
1259 if k_idx == 0 {
1260 log::debug!(
1261 "[THD] H{}: dt={:.3}s, dn={}, center_wrapped={}, win_len={}, max_sample={:.2e}",
1262 harmonic_order,
1263 dt,
1264 dn,
1265 center_wrapped,
1266 win_len,
1267 max_harmonic_sample
1268 );
1269 }
1270
1271 let fft_size = next_power_of_two(win_len);
1273 let nyquist_bin = fft_size / 2; if let Ok(spectrum) = compute_fft_padded(&harmonic_ir, fft_size) {
1275 let freq_resolution = sample_rate / fft_size as f32;
1276
1277 for (i, &f) in frequencies.iter().enumerate() {
1278 let bin = (f / freq_resolution).round() as usize;
1279 if bin < nyquist_bin && bin < spectrum.len() {
1281 let mag = spectrum[bin].norm();
1284 if mag > 1e-6 {
1286 harmonic_db[i] = 20.0 * mag.log10();
1287 }
1288 }
1289 }
1290 }
1291 }
1292
1293 if !frequencies.is_empty() {
1295 let mid_idx = frequencies.len() / 2;
1296 log::debug!(
1297 "[THD] Harmonic levels at {:.0} Hz: H2={:.1}dB, H3={:.1}dB, H4={:.1}dB, H5={:.1}dB, fundamental={:.1}dB",
1298 frequencies[mid_idx],
1299 harmonics_db[0][mid_idx],
1300 harmonics_db[1][mid_idx],
1301 harmonics_db[2][mid_idx],
1302 harmonics_db[3][mid_idx],
1303 fundamental_db[mid_idx]
1304 );
1305 }
1306
1307 let mut thd_percent = Vec::with_capacity(frequencies.len());
1309 for i in 0..frequencies.len() {
1310 let fundamental = 10.0f32.powf(fundamental_db[i] / 20.0);
1311 let mut harmonic_sum_sq = 0.0;
1312
1313 for harmonic_db in harmonics_db.iter().take(num_harmonics) {
1314 let h_mag = 10.0f32.powf(harmonic_db[i] / 20.0);
1315 harmonic_sum_sq += h_mag * h_mag;
1316 }
1317
1318 let thd = if fundamental > 1e-9 {
1320 (harmonic_sum_sq.sqrt() / fundamental) * 100.0
1321 } else {
1322 0.0
1323 };
1324 thd_percent.push(thd);
1325 }
1326
1327 if !thd_percent.is_empty() {
1329 let max_thd = thd_percent.iter().fold(0.0f32, |a, &b| a.max(b));
1330 let min_thd = thd_percent.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1331 log::debug!("[THD] THD range: {:.4}% to {:.4}%", min_thd, max_thd);
1332 }
1333
1334 (thd_percent, harmonics_db)
1335}
1336
1337pub fn write_analysis_csv(
1350 result: &AnalysisResult,
1351 output_path: &Path,
1352 compensation: Option<&MicrophoneCompensation>,
1353) -> Result<(), String> {
1354 use std::fs::File;
1355 use std::io::Write;
1356
1357 log::info!(
1358 "[write_analysis_csv] Writing {} frequency points to {:?}",
1359 result.frequencies.len(),
1360 output_path
1361 );
1362
1363 if let Some(comp) = compensation {
1364 log::info!(
1365 "[write_analysis_csv] Applying inverse microphone compensation ({} calibration points)",
1366 comp.frequencies.len()
1367 );
1368 }
1369
1370 if result.frequencies.is_empty() {
1371 return Err("Cannot write CSV: Analysis result has no frequency points!".to_string());
1372 }
1373
1374 let mut file =
1375 File::create(output_path).map_err(|e| format!("Failed to create CSV file: {}", e))?;
1376
1377 writeln!(
1379 file,
1380 "frequency_hz,spl_db,phase_deg,thd_percent,rt60_ms,c50_db,c80_db,group_delay_ms"
1381 )
1382 .map_err(|e| format!("Failed to write header: {}", e))?;
1383
1384 for i in 0..result.frequencies.len() {
1386 let freq = result.frequencies[i];
1387 let mut spl = result.spl_db[i];
1388
1389 if let Some(comp) = compensation {
1392 let mic_deviation = comp.interpolate_at(freq);
1393 spl -= mic_deviation;
1394 }
1395
1396 let phase = result.phase_deg[i];
1397 let thd = result.thd_percent.get(i).copied().unwrap_or(0.0);
1398 let rt60 = result.rt60_ms.get(i).copied().unwrap_or(0.0);
1399 let c50 = result.clarity_c50_db.get(i).copied().unwrap_or(0.0);
1400 let c80 = result.clarity_c80_db.get(i).copied().unwrap_or(0.0);
1401 let gd = result.excess_group_delay_ms.get(i).copied().unwrap_or(0.0);
1402
1403 writeln!(
1404 file,
1405 "{:.6},{:.3},{:.6},{:.6},{:.3},{:.3},{:.3},{:.6}",
1406 freq, spl, phase, thd, rt60, c50, c80, gd
1407 )
1408 .map_err(|e| format!("Failed to write data: {}", e))?;
1409 }
1410
1411 log::info!(
1412 "[write_analysis_csv] Successfully wrote {} data rows to CSV",
1413 result.frequencies.len()
1414 );
1415
1416 Ok(())
1417}
1418
1419pub fn read_analysis_csv(csv_path: &Path) -> Result<AnalysisResult, String> {
1424 use std::fs::File;
1425 use std::io::{BufRead, BufReader};
1426
1427 let file = File::open(csv_path).map_err(|e| format!("Failed to open CSV: {}", e))?;
1428 let reader = BufReader::new(file);
1429 let mut lines = reader.lines();
1430
1431 let header = lines
1433 .next()
1434 .ok_or("Empty CSV file")?
1435 .map_err(|e| format!("Failed to read header: {}", e))?;
1436
1437 let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
1438 let has_extended_format = columns.len() >= 8;
1439
1440 let mut frequencies = Vec::new();
1441 let mut spl_db = Vec::new();
1442 let mut phase_deg = Vec::new();
1443 let mut thd_percent = Vec::new();
1444 let mut rt60_ms = Vec::new();
1445 let mut clarity_c50_db = Vec::new();
1446 let mut clarity_c80_db = Vec::new();
1447 let mut excess_group_delay_ms = Vec::new();
1448
1449 for line in lines {
1450 let line = line.map_err(|e| format!("Failed to read line: {}", e))?;
1451 let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
1452
1453 if parts.len() < 3 {
1454 continue;
1455 }
1456
1457 let freq: f32 = parts[0].parse().unwrap_or(0.0);
1458 let spl: f32 = parts[1].parse().unwrap_or(0.0);
1459 let phase: f32 = parts[2].parse().unwrap_or(0.0);
1460
1461 frequencies.push(freq);
1462 spl_db.push(spl);
1463 phase_deg.push(phase);
1464
1465 if has_extended_format && parts.len() >= 8 {
1466 thd_percent.push(parts[3].parse().unwrap_or(0.0));
1467 rt60_ms.push(parts[4].parse().unwrap_or(0.0));
1468 clarity_c50_db.push(parts[5].parse().unwrap_or(0.0));
1469 clarity_c80_db.push(parts[6].parse().unwrap_or(0.0));
1470 excess_group_delay_ms.push(parts[7].parse().unwrap_or(0.0));
1471 }
1472 }
1473
1474 let n = frequencies.len();
1476 if thd_percent.is_empty() {
1477 thd_percent = vec![0.0; n];
1478 rt60_ms = vec![0.0; n];
1479 clarity_c50_db = vec![0.0; n];
1480 clarity_c80_db = vec![0.0; n];
1481 excess_group_delay_ms = vec![0.0; n];
1482 }
1483
1484 Ok(AnalysisResult {
1485 frequencies,
1486 spl_db,
1487 phase_deg,
1488 estimated_lag_samples: 0,
1489 impulse_response: Vec::new(),
1490 impulse_time_ms: Vec::new(),
1491 thd_percent,
1492 harmonic_distortion_db: Vec::new(),
1493 rt60_ms,
1494 clarity_c50_db,
1495 clarity_c80_db,
1496 excess_group_delay_ms,
1497 spectrogram_db: Vec::new(),
1498 })
1499}
1500
1501#[derive(Debug, Clone, Copy)]
1503enum WindowType {
1504 Hann,
1505 Tukey(f32), }
1507
1508fn estimate_lag(reference: &[f32], recorded: &[f32]) -> Result<isize, String> {
1519 let len = reference.len().min(recorded.len());
1520
1521 let fft_size = next_power_of_two(len * 2);
1523
1524 let ref_fft = compute_fft(reference, fft_size, WindowType::Hann)?;
1526 let rec_fft = compute_fft(recorded, fft_size, WindowType::Hann)?;
1527
1528 let mut cross_corr_fft: Vec<Complex<f32>> = ref_fft
1530 .iter()
1531 .zip(rec_fft.iter())
1532 .map(|(x, y)| x.conj() * y)
1533 .collect();
1534
1535 let ifft = plan_fft_inverse(fft_size);
1537 ifft.process(&mut cross_corr_fft);
1538
1539 let mut max_val = 0.0;
1541 let mut max_idx = 0;
1542
1543 for (i, &val) in cross_corr_fft.iter().enumerate() {
1544 let magnitude = val.norm();
1545 if magnitude > max_val {
1546 max_val = magnitude;
1547 max_idx = i;
1548 }
1549 }
1550
1551 Ok(if max_idx <= fft_size / 2 {
1553 max_idx as isize
1554 } else {
1555 max_idx as isize - fft_size as isize
1556 })
1557}
1558
1559#[derive(Debug, Clone)]
1564pub struct CrossCorrelationEnvelopeResult {
1565 pub envelope: Vec<f32>,
1567 pub peak_sample: usize,
1569 pub peak_sample_refined: f64,
1571 pub peak_value: f32,
1573 pub arrival_ms: f64,
1575}
1576
1577pub fn cross_correlate_envelope(
1592 probe: &[f32],
1593 recorded: &[f32],
1594 sample_rate: u32,
1595) -> Result<CrossCorrelationEnvelopeResult, String> {
1596 if probe.is_empty() || recorded.is_empty() {
1597 return Err("Probe and recorded signals must be non-empty".to_string());
1598 }
1599
1600 let fft_size = next_power_of_two(probe.len() + recorded.len());
1602
1603 let fft_forward = plan_fft_forward(fft_size);
1607
1608 let mut probe_buf: Vec<Complex<f32>> = vec![Complex::new(0.0, 0.0); fft_size];
1609 for (dst, &src) in probe_buf.iter_mut().zip(probe.iter()) {
1610 dst.re = src;
1611 }
1612 fft_forward.process(&mut probe_buf);
1613
1614 let mut rec_buf: Vec<Complex<f32>> = vec![Complex::new(0.0, 0.0); fft_size];
1615 for (dst, &src) in rec_buf.iter_mut().zip(recorded.iter()) {
1616 dst.re = src;
1617 }
1618 fft_forward.process(&mut rec_buf);
1619
1620 let mut cross_fft: Vec<Complex<f32>> = probe_buf
1622 .iter()
1623 .zip(rec_buf.iter())
1624 .map(|(p, r)| p.conj() * r)
1625 .collect();
1626
1627 let ifft = plan_fft_inverse(fft_size);
1629 ifft.process(&mut cross_fft);
1630
1631 let norm = 1.0 / fft_size as f32;
1633 let xcorr: Vec<f32> = cross_fft.iter().map(|c| c.re * norm).collect();
1634
1635 let analytic = crate::instantaneous_frequency::analytic_signal(&xcorr);
1637 let envelope: Vec<f32> = analytic.iter().map(|c| c.norm()).collect();
1638
1639 let search_len = fft_size / 2;
1641 let mut peak_sample = 0_usize;
1642 let mut peak_value = 0.0_f32;
1643 for (i, &val) in envelope.iter().enumerate().take(search_len) {
1644 if val > peak_value {
1645 peak_value = val;
1646 peak_sample = i;
1647 }
1648 }
1649
1650 let peak_refined = if peak_sample > 0 && peak_sample < search_len - 1 {
1652 let y_prev = envelope[peak_sample - 1] as f64;
1653 let y_peak = envelope[peak_sample] as f64;
1654 let y_next = envelope[peak_sample + 1] as f64;
1655 let denom = 2.0 * (2.0 * y_peak - y_prev - y_next);
1656 if denom.abs() > 1e-12 {
1657 peak_sample as f64 + (y_prev - y_next) / denom
1658 } else {
1659 peak_sample as f64
1660 }
1661 } else {
1662 peak_sample as f64
1663 };
1664
1665 let arrival_ms = peak_refined / sample_rate as f64 * 1000.0;
1666
1667 Ok(CrossCorrelationEnvelopeResult {
1668 envelope,
1669 peak_sample,
1670 peak_sample_refined: peak_refined,
1671 peak_value,
1672 arrival_ms,
1673 })
1674}
1675
1676#[derive(Debug, Clone)]
1681pub struct WindowedFrequencyResponse {
1682 pub direct_sound_freq: Vec<f32>,
1684 pub direct_sound_spl: Vec<f32>,
1685 pub early_reflections_freq: Vec<f32>,
1687 pub early_reflections_spl: Vec<f32>,
1688 pub late_reverb_freq: Vec<f32>,
1690 pub late_reverb_spl: Vec<f32>,
1691 pub direct_end_ms: f64,
1693 pub early_end_ms: f64,
1694}
1695
1696pub fn compute_windowed_fr(
1706 impulse_response: &[f32],
1707 direct_end_sample: usize,
1708 early_end_sample: usize,
1709 sample_rate: u32,
1710 num_output_points: usize,
1711) -> Result<WindowedFrequencyResponse, String> {
1712 if impulse_response.is_empty() {
1713 return Err("Impulse response must be non-empty".to_string());
1714 }
1715 if num_output_points == 0 {
1716 return Err("num_output_points must be > 0".to_string());
1717 }
1718
1719 let ir_len = impulse_response.len();
1720 let direct_end = direct_end_sample.min(ir_len);
1721 let early_end = early_end_sample.max(direct_end).min(ir_len);
1722
1723 let direct_end_ms = direct_end as f64 / sample_rate as f64 * 1000.0;
1724 let early_end_ms = early_end as f64 / sample_rate as f64 * 1000.0;
1725
1726 let fade_1ms = (sample_rate as usize) / 1000;
1728
1729 let window_to_fr = |start: usize, end: usize| -> (Vec<f32>, Vec<f32>) {
1730 let win_len = end.saturating_sub(start);
1731 if win_len == 0 {
1732 let log_start = 20.0_f32.ln();
1734 let log_end = 20000.0_f32.ln();
1735 let freqs: Vec<f32> = (0..num_output_points)
1736 .map(|i| {
1737 (log_start
1738 + (log_end - log_start) * i as f32 / (num_output_points.max(2) - 1) as f32)
1739 .exp()
1740 })
1741 .collect();
1742 let spl = vec![-200.0_f32; num_output_points];
1743 return (freqs, spl);
1744 }
1745
1746 let mut window: Vec<f32> = impulse_response[start..end].to_vec();
1750 let fade_len = fade_1ms.min(win_len / 2).max(1);
1751 if start > 0 {
1752 crate::signals::apply_fade_in(&mut window, fade_len);
1753 }
1754 crate::signals::apply_fade_out(&mut window, fade_len);
1755
1756 let fft_size = next_power_of_two(win_len);
1758 let fft_forward = plan_fft_forward(fft_size);
1759
1760 let mut buf: Vec<Complex<f32>> = vec![Complex::new(0.0, 0.0); fft_size];
1761 for (dst, &src) in buf.iter_mut().zip(window.iter()) {
1762 dst.re = src;
1763 }
1764 fft_forward.process(&mut buf);
1765
1766 let norm = 1.0 / fft_size as f32;
1768
1769 let log_start = 20.0_f32.ln();
1771 let log_end = 20000.0_f32.ln();
1772 let freq_resolution = sample_rate as f32 / fft_size as f32;
1773 let num_bins = fft_size / 2;
1774
1775 let mut freqs = Vec::with_capacity(num_output_points);
1776 let mut raw_db = Vec::with_capacity(num_output_points);
1777
1778 for i in 0..num_output_points {
1779 let target_freq = (log_start
1780 + (log_end - log_start) * i as f32 / (num_output_points.max(2) - 1) as f32)
1781 .exp();
1782 freqs.push(target_freq);
1783
1784 let bin = ((target_freq / freq_resolution).round() as usize).clamp(1, num_bins - 1);
1786 let mag = buf[bin].norm() * norm;
1787 let db = if mag > 1e-20 {
1788 20.0 * mag.log10()
1789 } else {
1790 -200.0
1791 };
1792 raw_db.push(db);
1793 }
1794
1795 let smoothed = smooth_response_f32(&freqs, &raw_db, 1.0 / 24.0);
1797 (freqs, smoothed)
1798 };
1799
1800 let (direct_sound_freq, direct_sound_spl) = window_to_fr(0, direct_end);
1801 let (early_reflections_freq, early_reflections_spl) = window_to_fr(direct_end, early_end);
1802 let (late_reverb_freq, late_reverb_spl) = window_to_fr(early_end, ir_len);
1803
1804 Ok(WindowedFrequencyResponse {
1805 direct_sound_freq,
1806 direct_sound_spl,
1807 early_reflections_freq,
1808 early_reflections_spl,
1809 late_reverb_freq,
1810 late_reverb_spl,
1811 direct_end_ms,
1812 early_end_ms,
1813 })
1814}
1815
1816fn compute_fft(
1826 signal: &[f32],
1827 fft_size: usize,
1828 window_type: WindowType,
1829) -> Result<Vec<Complex<f32>>, String> {
1830 let windowed = match window_type {
1832 WindowType::Hann => apply_hann_window(signal),
1833 WindowType::Tukey(alpha) => apply_tukey_window(signal, alpha),
1834 };
1835
1836 compute_fft_padded(&windowed, fft_size)
1837}
1838
1839fn compute_fft_padded(signal: &[f32], fft_size: usize) -> Result<Vec<Complex<f32>>, String> {
1841 let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
1843 for (dst, &src) in buffer.iter_mut().zip(signal.iter()) {
1844 dst.re = src;
1845 }
1846
1847 let fft = plan_fft_forward(fft_size);
1849 fft.process(&mut buffer);
1850
1851 let norm_factor = 1.0 / fft_size as f32;
1853 for val in buffer.iter_mut() {
1854 *val *= norm_factor;
1855 }
1856
1857 Ok(buffer)
1858}
1859
1860fn apply_hann_window(signal: &[f32]) -> Vec<f32> {
1862 let len = signal.len();
1863 if len < 2 {
1864 return signal.to_vec();
1865 }
1866 signal
1867 .iter()
1868 .enumerate()
1869 .map(|(i, &x)| {
1870 let window = 0.5 * (1.0 - (2.0 * PI * i as f32 / (len - 1) as f32).cos());
1871 x * window
1872 })
1873 .collect()
1874}
1875
1876fn apply_tukey_window(signal: &[f32], alpha: f32) -> Vec<f32> {
1881 let len = signal.len();
1882 if len < 2 {
1883 return signal.to_vec();
1884 }
1885
1886 let alpha = alpha.clamp(0.0, 1.0);
1887 let limit = (alpha * (len as f32 - 1.0) / 2.0).round() as usize;
1888
1889 if limit == 0 {
1890 return signal.to_vec();
1891 }
1892
1893 signal
1894 .iter()
1895 .enumerate()
1896 .map(|(i, &x)| {
1897 let w = if i < limit {
1898 0.5 * (1.0 - (PI * i as f32 / limit as f32).cos())
1900 } else if i >= len - limit {
1901 let n = len - 1 - i;
1903 0.5 * (1.0 - (PI * n as f32 / limit as f32).cos())
1904 } else {
1905 1.0
1907 };
1908 x * w
1909 })
1910 .collect()
1911}
1912
1913fn next_power_of_two(n: usize) -> usize {
1915 if n == 0 {
1916 return 1;
1917 }
1918 n.next_power_of_two()
1919}
1920
1921fn load_wav_mono_channel(path: &Path, channel_index: Option<usize>) -> Result<Vec<f32>, String> {
1928 let mut reader =
1929 WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
1930
1931 let spec = reader.spec();
1932 let channels = spec.channels as usize;
1933
1934 log::info!(
1935 "[load_wav_mono_channel] WAV file: {} channels, {} Hz, {:?} format",
1936 channels,
1937 spec.sample_rate,
1938 spec.sample_format
1939 );
1940
1941 let samples: Result<Vec<f32>, _> = match spec.sample_format {
1943 hound::SampleFormat::Float => reader.samples::<f32>().collect(),
1944 hound::SampleFormat::Int => reader
1945 .samples::<i32>()
1946 .map(|s| s.map(|v| v as f32 / i32::MAX as f32))
1947 .collect(),
1948 };
1949
1950 let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
1951 log::info!(
1952 "[load_wav_mono_channel] Read {} total samples",
1953 samples.len()
1954 );
1955
1956 if channels == 1 {
1958 log::info!(
1959 "[load_wav_mono_channel] File is already mono, returning {} samples",
1960 samples.len()
1961 );
1962 return Ok(samples);
1963 }
1964
1965 if let Some(ch_idx) = channel_index {
1967 if ch_idx >= channels {
1969 return Err(format!(
1970 "Channel index {} out of range (file has {} channels)",
1971 ch_idx, channels
1972 ));
1973 }
1974 log::info!(
1975 "[load_wav_mono_channel] Extracting channel {} from {} channels",
1976 ch_idx,
1977 channels
1978 );
1979 Ok(samples
1980 .chunks(channels)
1981 .map(|chunk| chunk[ch_idx])
1982 .collect())
1983 } else {
1984 log::info!(
1986 "[load_wav_mono_channel] Averaging {} channels to mono",
1987 channels
1988 );
1989 Ok(samples
1990 .chunks(channels)
1991 .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
1992 .collect())
1993 }
1994}
1995
1996fn load_wav_mono(path: &Path) -> Result<Vec<f32>, String> {
1998 load_wav_mono_channel(path, None)
1999}
2000
2001pub fn smooth_response_f64(frequencies: &[f64], values: &[f64], octaves: f64) -> Vec<f64> {
2012 if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
2013 return values.to_vec();
2014 }
2015
2016 let paired_len = frequencies.len().min(values.len());
2017
2018 let mut prefix = Vec::with_capacity(paired_len + 1);
2020 prefix.push(0.0);
2021 for &v in &values[..paired_len] {
2022 prefix.push(prefix.last().unwrap() + v);
2023 }
2024
2025 let ratio = 2.0_f64.powf(octaves / 2.0);
2026 let mut smoothed = Vec::with_capacity(paired_len);
2027 let mut lo = 0usize;
2028 let mut hi = 0usize;
2029
2030 for (i, ¢er_freq) in frequencies[..paired_len].iter().enumerate() {
2031 if center_freq <= 0.0 {
2032 smoothed.push(values[i]);
2033 continue;
2034 }
2035
2036 let low_freq = center_freq / ratio;
2037 let high_freq = center_freq * ratio;
2038
2039 while lo < paired_len && frequencies[lo] < low_freq {
2041 lo += 1;
2042 }
2043 while hi < paired_len && frequencies[hi] <= high_freq {
2045 hi += 1;
2046 }
2047
2048 let count = hi - lo;
2049 if count > 0 {
2050 smoothed.push((prefix[hi] - prefix[lo]) / count as f64);
2051 } else {
2052 smoothed.push(values[i]);
2053 }
2054 }
2055
2056 smoothed
2057}
2058
2059pub fn smooth_response_f32(frequencies: &[f32], values: &[f32], octaves: f32) -> Vec<f32> {
2066 if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
2067 return values.to_vec();
2068 }
2069
2070 let paired_len = frequencies.len().min(values.len());
2071
2072 let mut prefix = Vec::with_capacity(paired_len + 1);
2074 prefix.push(0.0_f64);
2075 for &v in &values[..paired_len] {
2076 prefix.push(prefix.last().unwrap() + v as f64);
2077 }
2078
2079 let ratio = 2.0_f32.powf(octaves / 2.0);
2080 let mut smoothed = Vec::with_capacity(paired_len);
2081 let mut lo = 0usize;
2082 let mut hi = 0usize;
2083
2084 for (i, ¢er_freq) in frequencies[..paired_len].iter().enumerate() {
2085 if center_freq <= 0.0 {
2086 smoothed.push(values[i]);
2087 continue;
2088 }
2089
2090 let low_freq = center_freq / ratio;
2091 let high_freq = center_freq * ratio;
2092
2093 while lo < paired_len && frequencies[lo] < low_freq {
2095 lo += 1;
2096 }
2097 while hi < paired_len && frequencies[hi] <= high_freq {
2099 hi += 1;
2100 }
2101
2102 let count = hi - lo;
2103 if count > 0 {
2104 smoothed.push(((prefix[hi] - prefix[lo]) / count as f64) as f32);
2105 } else {
2106 smoothed.push(values[i]);
2107 }
2108 }
2109
2110 smoothed
2111}
2112
2113pub fn compute_group_delay(frequencies: &[f32], phase_deg: &[f32]) -> Vec<f32> {
2119 if frequencies.len() < 2 {
2120 return vec![0.0; frequencies.len()];
2121 }
2122
2123 let unwrapped = unwrap_phase_deg(phase_deg);
2125
2126 let mut group_delay_ms = Vec::with_capacity(frequencies.len());
2127
2128 for i in 0..frequencies.len() {
2129 let delay = if i == 0 {
2130 let df = frequencies[1] - frequencies[0];
2132 let dp = unwrapped[1] - unwrapped[0];
2133 if df.abs() > 1e-6 {
2134 -dp / df / 360.0 * 1000.0 } else {
2136 0.0
2137 }
2138 } else if i == frequencies.len() - 1 {
2139 let df = frequencies[i] - frequencies[i - 1];
2141 let dp = unwrapped[i] - unwrapped[i - 1];
2142 if df.abs() > 1e-6 {
2143 -dp / df / 360.0 * 1000.0
2144 } else {
2145 0.0
2146 }
2147 } else {
2148 let df = frequencies[i + 1] - frequencies[i - 1];
2150 let dp = unwrapped[i + 1] - unwrapped[i - 1];
2151 if df.abs() > 1e-6 {
2152 -dp / df / 360.0 * 1000.0
2153 } else {
2154 0.0
2155 }
2156 };
2157 group_delay_ms.push(delay);
2158 }
2159
2160 group_delay_ms
2161}
2162
2163fn unwrap_phase_deg(phase_deg: &[f32]) -> Vec<f32> {
2167 if phase_deg.is_empty() {
2168 return Vec::new();
2169 }
2170
2171 let mut unwrapped = Vec::with_capacity(phase_deg.len());
2172 unwrapped.push(phase_deg[0]);
2173
2174 for i in 1..phase_deg.len() {
2175 let diff = phase_deg[i] - phase_deg[i - 1];
2176 let wrapped_diff = diff - 360.0 * (diff / 360.0).round();
2177 unwrapped.push(unwrapped[i - 1] + wrapped_diff);
2178 }
2179
2180 unwrapped
2181}
2182
2183pub fn compute_impulse_response_from_fr(
2191 frequencies: &[f32],
2192 magnitude_db: &[f32],
2193 phase_deg: &[f32],
2194 sample_rate: f32,
2195) -> (Vec<f32>, Vec<f32>) {
2196 let fft_size = 1024;
2197 let half = fft_size / 2; let freq_bin = sample_rate / fft_size as f32;
2199
2200 let unwrapped_phase = unwrap_phase_deg(phase_deg);
2202
2203 let mut spectrum = vec![Complex::new(0.0_f32, 0.0); fft_size];
2205
2206 for (k, spectrum_bin) in spectrum.iter_mut().enumerate().take(half + 1) {
2207 let f = k as f32 * freq_bin;
2208
2209 let (mag_db, phase_d) = interpolate_fr(frequencies, magnitude_db, &unwrapped_phase, f);
2211
2212 let mag_linear = 10.0_f32.powf(mag_db / 20.0);
2213 let phase_rad = phase_d * PI / 180.0;
2214
2215 *spectrum_bin = Complex::new(mag_linear * phase_rad.cos(), mag_linear * phase_rad.sin());
2216 }
2217
2218 for k in 1..half {
2220 spectrum[fft_size - k] = spectrum[k].conj();
2221 }
2222
2223 let ifft = plan_fft_inverse(fft_size);
2225 ifft.process(&mut spectrum);
2226
2227 let scale = 1.0 / fft_size as f32;
2229 let mut impulse: Vec<f32> = spectrum.iter().map(|c| c.re * scale).collect();
2230
2231 let max_val = impulse.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
2233 if max_val > 0.0 {
2234 for v in &mut impulse {
2235 *v /= max_val;
2236 }
2237 }
2238
2239 let time_step = 1.0 / sample_rate;
2240 let times: Vec<f32> = (0..fft_size)
2241 .map(|i| i as f32 * time_step * 1000.0)
2242 .collect();
2243
2244 (times, impulse)
2245}
2246
2247fn interpolate_fr(
2252 frequencies: &[f32],
2253 magnitude_db: &[f32],
2254 unwrapped_phase_deg: &[f32],
2255 target_freq: f32,
2256) -> (f32, f32) {
2257 if frequencies.is_empty() {
2258 return (0.0, 0.0);
2259 }
2260 if target_freq <= frequencies[0] {
2261 return (magnitude_db[0], unwrapped_phase_deg[0]);
2262 }
2263 let last = frequencies.len() - 1;
2264 if target_freq >= frequencies[last] {
2265 return (magnitude_db[last], unwrapped_phase_deg[last]);
2266 }
2267
2268 let idx = match frequencies.binary_search_by(|f| f.partial_cmp(&target_freq).unwrap()) {
2270 Ok(i) => return (magnitude_db[i], unwrapped_phase_deg[i]),
2271 Err(i) => i, };
2273
2274 let f0 = frequencies[idx - 1];
2275 let f1 = frequencies[idx];
2276 let t = (target_freq - f0) / (f1 - f0);
2277
2278 let mag = magnitude_db[idx - 1] + t * (magnitude_db[idx] - magnitude_db[idx - 1]);
2279 let phase = unwrapped_phase_deg[idx - 1]
2280 + t * (unwrapped_phase_deg[idx] - unwrapped_phase_deg[idx - 1]);
2281 (mag, phase)
2282}
2283
2284fn compute_schroeder_decay(impulse: &[f32]) -> Vec<f32> {
2286 let mut energy = 0.0;
2287 let mut decay = vec![0.0; impulse.len()];
2288
2289 for i in (0..impulse.len()).rev() {
2291 energy += impulse[i] * impulse[i];
2292 decay[i] = energy;
2293 }
2294
2295 let max_energy = decay.first().copied().unwrap_or(1.0);
2297 if max_energy > 0.0 {
2298 for v in &mut decay {
2299 *v /= max_energy;
2300 }
2301 }
2302
2303 decay
2304}
2305
2306#[derive(Debug, Clone, Copy)]
2307enum Rt60FitMethod {
2308 T30,
2309 T20,
2310}
2311
2312#[derive(Debug, Clone, Copy)]
2313struct Rt60Fit {
2314 rt60_seconds: f32,
2315 method: Rt60FitMethod,
2316 r_squared: f32,
2317 fit_start_seconds: f32,
2318 fit_end_seconds: f32,
2319}
2320
2321fn trim_impulse_to_noise_floor(impulse: &[f32], sample_rate: f32) -> &[f32] {
2327 const WINDOW_MS: f32 = 10.0;
2328 const TAIL_FRACTION: f32 = 0.10;
2329 const SNR_THRESHOLD: f32 = 10.0;
2330 const HEADROOM_WINDOWS: usize = 3;
2331 const MIN_LENGTH_MS: f32 = 100.0;
2332
2333 if sample_rate <= 0.0 || impulse.is_empty() {
2334 return impulse;
2335 }
2336
2337 let window_samples = (sample_rate * WINDOW_MS / 1000.0) as usize;
2338 let min_samples = (sample_rate * MIN_LENGTH_MS / 1000.0) as usize;
2339 if window_samples == 0 || impulse.len() < min_samples {
2340 return impulse;
2341 }
2342
2343 let num_windows = impulse.len() / window_samples;
2344 if num_windows < 20 {
2345 return impulse;
2346 }
2347
2348 let energies: Vec<f32> = (0..num_windows)
2349 .map(|window| {
2350 let start = window * window_samples;
2351 let end = start + window_samples;
2352 impulse[start..end]
2353 .iter()
2354 .map(|sample| sample * sample)
2355 .sum::<f32>()
2356 / window_samples as f32
2357 })
2358 .collect();
2359
2360 let tail_count = ((num_windows as f32 * TAIL_FRACTION).ceil() as usize).max(1);
2361 let tail_start = num_windows - tail_count;
2362 let noise_floor = energies[tail_start..].iter().sum::<f32>() / tail_count as f32;
2363 if noise_floor <= 0.0 || !noise_floor.is_finite() {
2364 return impulse;
2365 }
2366
2367 let signal_threshold = noise_floor * SNR_THRESHOLD;
2368 let Some(last_signal_window) = energies
2369 .iter()
2370 .enumerate()
2371 .rev()
2372 .find(|(_, energy)| **energy > signal_threshold)
2373 .map(|(idx, _)| idx)
2374 else {
2375 return impulse;
2376 };
2377
2378 let keep_windows = (last_signal_window + 1 + HEADROOM_WINDOWS).min(num_windows);
2379 let keep_samples = (keep_windows * window_samples).min(impulse.len());
2380 if keep_samples >= impulse.len() {
2381 impulse
2382 } else {
2383 &impulse[..keep_samples]
2384 }
2385}
2386
2387fn fit_rt60_decay(
2388 decay_db: &[f32],
2389 sample_rate: f32,
2390 start_db: f32,
2391 end_db: f32,
2392 method: Rt60FitMethod,
2393) -> Option<Rt60Fit> {
2394 const MIN_FIT_POINTS: usize = 32;
2395 const MIN_FIT_DURATION_SECONDS: f32 = 0.015;
2396 const MIN_R_SQUARED: f32 = 0.97;
2397
2398 let start = decay_db.iter().position(|value| *value <= start_db)?;
2399 let end = decay_db.iter().position(|value| *value <= end_db)?;
2400 if end <= start || end - start + 1 < MIN_FIT_POINTS {
2401 return None;
2402 }
2403
2404 let fit_duration = (end - start) as f32 / sample_rate;
2405 if fit_duration < MIN_FIT_DURATION_SECONDS {
2406 return None;
2407 }
2408
2409 let n = (end - start + 1) as f32;
2410 let mut sum_x = 0.0_f32;
2411 let mut sum_y = 0.0_f32;
2412 let mut sum_xx = 0.0_f32;
2413 let mut sum_xy = 0.0_f32;
2414
2415 for (offset, y) in decay_db[start..=end].iter().enumerate() {
2416 let x = offset as f32 / sample_rate;
2417 sum_x += x;
2418 sum_y += *y;
2419 sum_xx += x * x;
2420 sum_xy += x * *y;
2421 }
2422
2423 let denom = n * sum_xx - sum_x * sum_x;
2424 if denom.abs() <= f32::EPSILON {
2425 return None;
2426 }
2427
2428 let slope = (n * sum_xy - sum_x * sum_y) / denom;
2429 let intercept = (sum_y - slope * sum_x) / n;
2430 if !slope.is_finite() || slope >= 0.0 {
2431 return None;
2432 }
2433
2434 let mean_y = sum_y / n;
2435 let mut ss_total = 0.0_f32;
2436 let mut ss_residual = 0.0_f32;
2437 for (offset, y) in decay_db[start..=end].iter().enumerate() {
2438 let x = offset as f32 / sample_rate;
2439 let fitted = intercept + slope * x;
2440 ss_total += (*y - mean_y).powi(2);
2441 ss_residual += (*y - fitted).powi(2);
2442 }
2443
2444 if ss_total <= f32::EPSILON {
2445 return None;
2446 }
2447
2448 let r_squared = 1.0 - ss_residual / ss_total;
2449 let rt60_seconds = -60.0 / slope;
2450 if !rt60_seconds.is_finite() || rt60_seconds <= 0.0 || r_squared < MIN_R_SQUARED {
2451 return None;
2452 }
2453
2454 Some(Rt60Fit {
2455 rt60_seconds,
2456 method,
2457 r_squared,
2458 fit_start_seconds: start as f32 / sample_rate,
2459 fit_end_seconds: end as f32 / sample_rate,
2460 })
2461}
2462
2463fn estimate_rt60_broadband(impulse: &[f32], sample_rate: f32) -> Option<Rt60Fit> {
2464 if impulse.is_empty() || sample_rate <= 0.0 {
2465 return None;
2466 }
2467
2468 let trimmed = trim_impulse_to_noise_floor(impulse, sample_rate);
2469 let decay = compute_schroeder_decay(trimmed);
2470 let decay_db: Vec<f32> = decay
2471 .iter()
2472 .map(|value| 10.0 * value.max(1e-12).log10())
2473 .collect();
2474
2475 fit_rt60_decay(&decay_db, sample_rate, -5.0, -35.0, Rt60FitMethod::T30)
2476 .or_else(|| fit_rt60_decay(&decay_db, sample_rate, -5.0, -25.0, Rt60FitMethod::T20))
2477}
2478
2479pub fn compute_rt60_broadband(impulse: &[f32], sample_rate: f32) -> f32 {
2482 estimate_rt60_broadband(impulse, sample_rate)
2483 .map(|fit| fit.rt60_seconds)
2484 .unwrap_or(0.0)
2485}
2486
2487pub fn compute_clarity_broadband(impulse: &[f32], sample_rate: f32) -> (f32, f32) {
2490 let mut energy_0_50 = 0.0;
2491 let mut energy_50_inf = 0.0;
2492 let mut energy_0_80 = 0.0;
2493 let mut energy_80_inf = 0.0;
2494
2495 let samp_50ms = (0.050 * sample_rate) as usize;
2496 let samp_80ms = (0.080 * sample_rate) as usize;
2497
2498 for (i, &samp) in impulse.iter().enumerate() {
2499 let sq = samp * samp;
2500
2501 if i < samp_50ms {
2502 energy_0_50 += sq;
2503 } else {
2504 energy_50_inf += sq;
2505 }
2506
2507 if i < samp_80ms {
2508 energy_0_80 += sq;
2509 } else {
2510 energy_80_inf += sq;
2511 }
2512 }
2513
2514 const MAX_CLARITY_DB: f32 = 60.0;
2517
2518 let c50 = if energy_50_inf > 1e-12 && energy_0_50 > 1e-12 {
2519 let ratio = energy_0_50 / energy_50_inf;
2520 (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2521 } else if energy_0_50 > energy_50_inf {
2522 MAX_CLARITY_DB } else {
2524 -MAX_CLARITY_DB };
2526
2527 let c80 = if energy_80_inf > 1e-12 && energy_0_80 > 1e-12 {
2528 let ratio = energy_0_80 / energy_80_inf;
2529 (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2530 } else if energy_80_inf > energy_0_80 {
2531 MAX_CLARITY_DB } else {
2533 -MAX_CLARITY_DB };
2535
2536 (c50, c80)
2537}
2538
2539pub fn compute_rt60_spectrum(impulse: &[f32], sample_rate: f32, frequencies: &[f32]) -> Vec<f32> {
2541 if impulse.is_empty() {
2542 return vec![0.0; frequencies.len()];
2543 }
2544
2545 let centers = [
2547 63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2548 ];
2549 let mut band_rt60s = Vec::with_capacity(centers.len());
2550 let mut valid_centers = Vec::with_capacity(centers.len());
2551 let mut fit_summaries = Vec::with_capacity(centers.len());
2552
2553 for &freq in ¢ers {
2555 if freq >= sample_rate / 2.0 {
2557 continue;
2558 }
2559
2560 let mut biquad = Biquad::new(
2563 BiquadFilterType::Bandpass,
2564 freq as f64,
2565 sample_rate as f64,
2566 1.414,
2567 0.0,
2568 );
2569
2570 let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2572 biquad.process_block(&mut filtered);
2573 let filtered_f32: Vec<f32> = filtered.iter().map(|&x| x as f32).collect();
2574
2575 let fit = estimate_rt60_broadband(&filtered_f32, sample_rate);
2577 let rt60 = fit.map(|fit| fit.rt60_seconds).unwrap_or(0.0);
2578 fit_summaries.push(match fit {
2579 Some(fit) => format!(
2580 "{:.0}Hz:{:.3}s({:?},r2={:.3},{:.0}-{:.0}ms)",
2581 freq,
2582 fit.rt60_seconds,
2583 fit.method,
2584 fit.r_squared,
2585 fit.fit_start_seconds * 1000.0,
2586 fit.fit_end_seconds * 1000.0,
2587 ),
2588 None => format!("{:.0}Hz:invalid", freq),
2589 });
2590
2591 band_rt60s.push(rt60);
2592 valid_centers.push(freq);
2593 }
2594
2595 log::info!("[RT60] Per-band values: {:?}", fit_summaries);
2597
2598 if valid_centers.is_empty() {
2599 return vec![0.0; frequencies.len()];
2600 }
2601
2602 interpolate_log(&valid_centers, &band_rt60s, frequencies)
2604}
2605
2606pub fn compute_clarity_spectrum(
2609 impulse: &[f32],
2610 sample_rate: f32,
2611 frequencies: &[f32],
2612) -> (Vec<f32>, Vec<f32>) {
2613 if impulse.is_empty() || frequencies.is_empty() {
2614 return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2615 }
2616
2617 let centers = [
2619 63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2620 ];
2621 let mut band_c50s = Vec::with_capacity(centers.len());
2622 let mut band_c80s = Vec::with_capacity(centers.len());
2623 let mut valid_centers = Vec::with_capacity(centers.len());
2624
2625 let samp_50ms = (0.050 * sample_rate) as usize;
2627 let samp_80ms = (0.080 * sample_rate) as usize;
2628
2629 for &freq in ¢ers {
2631 if freq >= sample_rate / 2.0 {
2632 continue;
2633 }
2634
2635 let mut biquad1 = Biquad::new(
2637 BiquadFilterType::Bandpass,
2638 freq as f64,
2639 sample_rate as f64,
2640 0.707, 0.0,
2642 );
2643 let mut biquad2 = Biquad::new(
2644 BiquadFilterType::Bandpass,
2645 freq as f64,
2646 sample_rate as f64,
2647 0.707,
2648 0.0,
2649 );
2650
2651 let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2652 biquad1.process_block(&mut filtered);
2653 biquad2.process_block(&mut filtered);
2654
2655 let mut energy_0_50 = 0.0f64;
2657 let mut energy_50_inf = 0.0f64;
2658 let mut energy_0_80 = 0.0f64;
2659 let mut energy_80_inf = 0.0f64;
2660
2661 for (i, &samp) in filtered.iter().enumerate() {
2662 let sq = samp * samp;
2663
2664 if i < samp_50ms {
2665 energy_0_50 += sq;
2666 } else {
2667 energy_50_inf += sq;
2668 }
2669
2670 if i < samp_80ms {
2671 energy_0_80 += sq;
2672 } else {
2673 energy_80_inf += sq;
2674 }
2675 }
2676
2677 const MAX_CLARITY_DB: f32 = 40.0;
2680 const MIN_ENERGY: f64 = 1e-20;
2681
2682 let c50 = if energy_50_inf > MIN_ENERGY && energy_0_50 > MIN_ENERGY {
2683 let ratio = energy_0_50 / energy_50_inf;
2684 (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2685 } else if energy_0_50 > energy_50_inf {
2686 MAX_CLARITY_DB
2687 } else {
2688 -MAX_CLARITY_DB
2689 };
2690
2691 let c80 = if energy_80_inf > MIN_ENERGY && energy_0_80 > MIN_ENERGY {
2692 let ratio = energy_0_80 / energy_80_inf;
2693 (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2694 } else if energy_0_80 > energy_80_inf {
2695 MAX_CLARITY_DB
2696 } else {
2697 -MAX_CLARITY_DB
2698 };
2699
2700 band_c50s.push(c50);
2701 band_c80s.push(c80);
2702 valid_centers.push(freq);
2703 }
2704
2705 log::info!(
2707 "[Clarity] Per-band C50: {:?}",
2708 valid_centers
2709 .iter()
2710 .zip(band_c50s.iter())
2711 .map(|(f, v)| format!("{:.0}Hz:{:.1}dB", f, v))
2712 .collect::<Vec<_>>()
2713 );
2714
2715 if valid_centers.is_empty() {
2716 return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2717 }
2718
2719 let c50_interp = interpolate_log(&valid_centers, &band_c50s, frequencies);
2721 let c80_interp = interpolate_log(&valid_centers, &band_c80s, frequencies);
2722
2723 (c50_interp, c80_interp)
2724}
2725
2726pub fn compute_spectrogram(
2730 impulse: &[f32],
2731 sample_rate: f32,
2732 window_size: usize,
2733 hop_size: usize,
2734) -> (Vec<Vec<f32>>, Vec<f32>, Vec<f32>) {
2735 use rustfft::num_complex::Complex;
2736
2737 if impulse.len() < window_size {
2738 return (Vec::new(), Vec::new(), Vec::new());
2739 }
2740
2741 let num_frames = (impulse.len() - window_size) / hop_size;
2742 let mut spectrogram = Vec::with_capacity(num_frames);
2743 let mut times = Vec::with_capacity(num_frames);
2744
2745 let window: Vec<f32> = (0..window_size)
2747 .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (window_size as f32 - 1.0)).cos()))
2748 .collect();
2749
2750 let fft = plan_fft_forward(window_size);
2752
2753 for i in 0..num_frames {
2754 let start = i * hop_size;
2755 let time_ms = (start as f32 / sample_rate) * 1000.0;
2756 times.push(time_ms);
2757
2758 let mut buffer: Vec<Complex<f32>> = (0..window_size)
2759 .map(|j| {
2760 let sample = impulse.get(start + j).copied().unwrap_or(0.0);
2761 Complex::new(sample * window[j], 0.0)
2762 })
2763 .collect();
2764
2765 fft.process(&mut buffer);
2766
2767 let magnitude_db: Vec<f32> = buffer[..window_size / 2]
2770 .iter()
2771 .map(|c| {
2772 let mag = c.norm();
2773 if mag > 1e-9 {
2774 20.0 * mag.log10()
2775 } else {
2776 -180.0
2777 }
2778 })
2779 .collect();
2780
2781 spectrogram.push(magnitude_db);
2782 }
2783
2784 let num_bins = window_size / 2;
2786 let freq_step = sample_rate / window_size as f32;
2787 let freqs: Vec<f32> = (0..num_bins).map(|i| i as f32 * freq_step).collect();
2788
2789 (spectrogram, freqs, times)
2790}
2791
2792pub fn find_db_point(
2803 frequencies: &[f32],
2804 magnitude_db: &[f32],
2805 target_db: f32,
2806 from_start: bool,
2807) -> Option<f32> {
2808 if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2809 return None;
2810 }
2811
2812 if from_start {
2813 for i in 0..magnitude_db.len() - 1 {
2814 let m0 = magnitude_db[i];
2815 let m1 = magnitude_db[i + 1];
2816
2817 if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2819 let denominator = m1 - m0;
2821 if denominator.abs() < 1e-9 {
2822 return Some(frequencies[i]);
2823 }
2824 let t = (target_db - m0) / denominator;
2825 return Some(frequencies[i] + t * (frequencies[i + 1] - frequencies[i]));
2826 }
2827 }
2828 } else {
2829 for i in (1..magnitude_db.len()).rev() {
2830 let m0 = magnitude_db[i];
2831 let m1 = magnitude_db[i - 1];
2832
2833 if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2835 let denominator = m1 - m0;
2836 if denominator.abs() < 1e-9 {
2837 return Some(frequencies[i]);
2838 }
2839 let t = (target_db - m0) / denominator;
2840 return Some(frequencies[i] + t * (frequencies[i - 1] - frequencies[i]));
2841 }
2842 }
2843 }
2844
2845 None
2846}
2847
2848pub fn compute_average_response(
2862 frequencies: &[f32],
2863 magnitude_db: &[f32],
2864 freq_range: Option<(f32, f32)>,
2865) -> f32 {
2866 if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2867 return magnitude_db.first().copied().unwrap_or(0.0);
2868 }
2869
2870 let (start_freq, end_freq) =
2871 freq_range.unwrap_or((frequencies[0], frequencies[frequencies.len() - 1]));
2872
2873 let mut sum_weighted_db = 0.0;
2874 let mut sum_weights = 0.0;
2875
2876 for i in 0..frequencies.len() - 1 {
2877 let f0 = frequencies[i];
2878 let f1 = frequencies[i + 1];
2879
2880 if f1 < start_freq || f0 > end_freq {
2882 continue;
2883 }
2884
2885 let fa = f0.max(start_freq);
2887 let fb = f1.min(end_freq);
2888
2889 if fb <= fa {
2890 continue;
2891 }
2892
2893 let weight = (fb / fa).log2();
2896
2897 let m0 = magnitude_db[i];
2902 let m1 = magnitude_db[i + 1];
2903 let avg_m = (m0 + m1) / 2.0;
2904
2905 sum_weighted_db += avg_m * weight;
2906 sum_weights += weight;
2907 }
2908
2909 if sum_weights > 0.0 {
2910 sum_weighted_db / sum_weights
2911 } else {
2912 magnitude_db.first().copied().unwrap_or(0.0)
2913 }
2914}
2915
2916pub fn compute_coherence_from_realizations(
2945 realizations: &[Vec<Complex<f32>>],
2946) -> Result<Vec<f32>, String> {
2947 let n = realizations.len();
2948 if n == 0 {
2949 return Err("compute_coherence: empty realizations".to_string());
2950 }
2951 if n < 4 {
2954 return Err(format!(
2955 "compute_coherence: need at least 4 realizations, got {n}"
2956 ));
2957 }
2958 let bins = realizations[0].len();
2959 if bins == 0 {
2960 return Ok(Vec::new());
2961 }
2962 for (i, r) in realizations.iter().enumerate() {
2963 if r.len() != bins {
2964 return Err(format!(
2965 "compute_coherence: realization {i} has {} bins, expected {bins}",
2966 r.len()
2967 ));
2968 }
2969 }
2970
2971 let mut coherence = Vec::with_capacity(bins);
2972 for k in 0..bins {
2973 let mut sum = Complex::new(0.0_f64, 0.0_f64);
2974 let mut sum_sq = 0.0_f64;
2975 for r in realizations {
2976 let h = Complex::new(r[k].re as f64, r[k].im as f64);
2977 sum += h;
2978 sum_sq += h.re * h.re + h.im * h.im;
2979 }
2980 let mean = sum / (n as f64);
2981 let mean_sq = sum_sq / (n as f64);
2982 if mean_sq <= f64::EPSILON {
2983 coherence.push(0.0);
2986 } else {
2987 let coh = (mean.norm_sqr() / mean_sq).clamp(0.0, 1.0);
2988 coherence.push(coh as f32);
2989 }
2990 }
2991
2992 Ok(coherence)
2993}
2994
2995pub fn deconvolve_sweep(
3019 recording: &[f32],
3020 reference: &[f32],
3021 sample_rate: u32,
3022) -> Result<Vec<Complex<f32>>, String> {
3023 if recording.len() != reference.len() {
3024 return Err(format!(
3025 "deconvolve_sweep: recording len {} != reference len {}",
3026 recording.len(),
3027 reference.len()
3028 ));
3029 }
3030 if recording.is_empty() {
3031 return Err("deconvolve_sweep: empty input".to_string());
3032 }
3033 if sample_rate == 0 {
3034 return Err("deconvolve_sweep: zero sample_rate".to_string());
3035 }
3036
3037 let n = recording.len();
3038 let fft_size = n.next_power_of_two();
3039
3040 let mut y: Vec<Complex<f32>> = recording.iter().map(|&s| Complex::new(s, 0.0)).collect();
3041 y.resize(fft_size, Complex::new(0.0, 0.0));
3042 let mut x: Vec<Complex<f32>> = reference.iter().map(|&s| Complex::new(s, 0.0)).collect();
3043 x.resize(fft_size, Complex::new(0.0, 0.0));
3044
3045 let fft = plan_fft_forward(fft_size);
3046 fft.process(&mut y);
3047 fft.process(&mut x);
3048
3049 let x_peak = x
3051 .iter()
3052 .map(|c| c.norm())
3053 .fold(0.0_f32, f32::max)
3054 .max(1e-20);
3055 let epsilon = x_peak * 1e-3; let eps_sq = epsilon * epsilon;
3057
3058 let spectrum_size = fft_size / 2 + 1;
3059 let mut h = Vec::with_capacity(spectrum_size);
3060 for k in 0..spectrum_size {
3061 let yk = y[k];
3064 let xk = x[k];
3065 let num = yk * xk.conj();
3066 let den = xk.norm_sqr() + eps_sq;
3067 h.push(num / den);
3068 }
3069 Ok(h)
3070}
3071
3072pub fn estimate_noise_floor_db_from_silence(silence: &[f32], _sample_rate: u32) -> Vec<f32> {
3088 if silence.is_empty() {
3089 return Vec::new();
3090 }
3091 let n = silence.len();
3092 let fft_size = n.next_power_of_two();
3093 let spectrum_size = fft_size / 2 + 1;
3094
3095 let mut buf: Vec<Complex<f32>> = silence
3097 .iter()
3098 .enumerate()
3099 .map(|(k, &s)| {
3100 let w = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * k as f32 / (n as f32 - 1.0)).cos());
3101 Complex::new(s * w, 0.0)
3102 })
3103 .collect();
3104 buf.resize(fft_size, Complex::new(0.0, 0.0));
3105
3106 let fft = plan_fft_forward(fft_size);
3107 fft.process(&mut buf);
3108
3109 let norm = 4.0 / n as f32;
3116
3117 buf.into_iter()
3118 .take(spectrum_size)
3119 .map(|c| {
3120 let mag = c.norm() * norm;
3121 if mag > 1e-20 {
3122 20.0 * mag.log10()
3123 } else {
3124 -400.0 }
3126 })
3127 .collect()
3128}
3129
3130#[cfg(test)]
3131mod gd_1c_tests {
3132 use super::*;
3133 use std::f32::consts::PI;
3134
3135 #[test]
3136 fn coherence_single_realization_is_unity() {
3137 let h = vec![
3139 Complex::new(1.0, 0.0),
3140 Complex::new(0.5, 0.5),
3141 Complex::new(0.0, 1.0),
3142 ];
3143 let result = compute_coherence_from_realizations(&[h]);
3144 assert!(result.is_err(), "N=1 should error, not return γ² = 1");
3145 let err = result.unwrap_err();
3146 assert!(
3147 err.contains("at least 4 realizations"),
3148 "error should mention N≥4 requirement, got: {err}"
3149 );
3150 }
3151
3152 #[test]
3153 fn coherence_too_few_realizations_errors() {
3154 let r = vec![Complex::new(1.0, 0.0)];
3155 for n in [2usize, 3] {
3156 let realizations: Vec<_> = (0..n).map(|_| r.clone()).collect();
3157 let result = compute_coherence_from_realizations(&realizations);
3158 assert!(result.is_err(), "N={n} should error, not return γ² = 1");
3159 }
3160 }
3161
3162 #[test]
3163 fn coherence_identical_realizations_is_unity() {
3164 let r = vec![
3165 Complex::new(0.8, 0.2),
3166 Complex::new(0.0, 1.0),
3167 Complex::new(-0.5, 0.5),
3168 ];
3169 let realizations = vec![r.clone(), r.clone(), r.clone(), r];
3170 let coh = compute_coherence_from_realizations(&realizations).unwrap();
3171 for c in coh {
3172 assert!(
3173 (c - 1.0).abs() < 1e-6,
3174 "identical realizations → γ² = 1, got {c}"
3175 );
3176 }
3177 }
3178
3179 #[test]
3180 fn coherence_random_realizations_is_zero() {
3181 let bins = 3;
3184 let r0: Vec<Complex<f32>> = (0..bins).map(|_| Complex::new(1.0, 0.0)).collect();
3185 let r1: Vec<Complex<f32>> = (0..bins).map(|_| Complex::new(-1.0, 0.0)).collect();
3186 let r2: Vec<Complex<f32>> = (0..bins).map(|_| Complex::new(0.0, 1.0)).collect();
3187 let r3: Vec<Complex<f32>> = (0..bins).map(|_| Complex::new(0.0, -1.0)).collect();
3188 let coh = compute_coherence_from_realizations(&[r0, r1, r2, r3]).unwrap();
3189 for c in coh {
3190 assert!(c < 1e-6, "canceling-phase realizations → γ² ≈ 0, got {c}");
3191 }
3192 }
3193
3194 #[test]
3195 fn coherence_rejects_mismatched_lengths() {
3196 let r0 = vec![Complex::new(1.0_f32, 0.0); 3];
3197 let r1 = vec![Complex::new(1.0_f32, 0.0); 4];
3198 let r2 = vec![Complex::new(1.0_f32, 0.0); 3];
3199 let r3 = vec![Complex::new(1.0_f32, 0.0); 4];
3200 let err = compute_coherence_from_realizations(&[r0, r1, r2, r3]).unwrap_err();
3201 assert!(err.contains("has 4 bins, expected 3"), "got: {err}");
3202 }
3203
3204 #[test]
3205 fn coherence_empty_input_errors() {
3206 let err = compute_coherence_from_realizations(&[]).unwrap_err();
3207 assert!(err.contains("empty"), "got: {err}");
3208 }
3209
3210 #[test]
3211 fn deconvolve_matches_unity_system() {
3212 let n: usize = 1024;
3215 let sr = 48_000_u32;
3216 let sweep: Vec<f32> = (0..n)
3217 .map(|k| {
3218 let t = k as f32 / sr as f32;
3219 let f = 100.0 * (10.0_f32).powf(3.0 * t / (n as f32 / sr as f32));
3220 (2.0 * PI * f * t).sin() * 0.5
3221 })
3222 .collect();
3223 let recording = sweep.clone();
3224 let h = deconvolve_sweep(&recording, &sweep, sr).unwrap();
3225 assert_eq!(h.len(), n.next_power_of_two() / 2 + 1);
3226 let mid_slice = &h[10..50];
3230 for (i, c) in mid_slice.iter().enumerate() {
3231 let mag = c.norm();
3232 assert!(
3233 mag > 0.1 && mag < 10.0,
3234 "bin {} magnitude {mag} out of expected range",
3235 i + 10
3236 );
3237 }
3238 }
3239
3240 #[test]
3241 fn deconvolve_rejects_length_mismatch() {
3242 let a = vec![0.0_f32; 10];
3243 let b = vec![0.0_f32; 11];
3244 let err = deconvolve_sweep(&a, &b, 48_000).unwrap_err();
3245 assert!(err.contains("!="), "got: {err}");
3246 }
3247
3248 #[test]
3249 fn noise_floor_pure_silence_is_very_low() {
3250 let silence = vec![0.0_f32; 4096];
3251 let nf = estimate_noise_floor_db_from_silence(&silence, 48_000);
3252 assert_eq!(nf.len(), 4096 / 2 + 1);
3253 for (i, v) in nf.iter().enumerate() {
3254 assert!(
3255 *v < -200.0,
3256 "pure silence bin {i} should report extremely low dB, got {v}",
3257 );
3258 }
3259 }
3260
3261 #[test]
3262 fn noise_floor_tone_peaks_at_exact_bin() {
3263 let sr = 48_000_u32;
3269 let n: usize = 4096;
3270 let target_bin = 100_usize;
3271 let freq = (target_bin as f32 * sr as f32) / n as f32; let amp_db = -40.0_f32;
3273 let amp = 10.0_f32.powf(amp_db / 20.0);
3274 let tone: Vec<f32> = (0..n)
3275 .map(|k| amp * (2.0 * PI * freq * k as f32 / sr as f32).sin())
3276 .collect();
3277 let nf = estimate_noise_floor_db_from_silence(&tone, sr);
3278 let mut peak_db = f32::NEG_INFINITY;
3280 let mut peak_bin = 0;
3281 for (k, v) in nf
3282 .iter()
3283 .enumerate()
3284 .take(target_bin + 3)
3285 .skip(target_bin - 2)
3286 {
3287 if *v > peak_db {
3288 peak_db = *v;
3289 peak_bin = k;
3290 }
3291 }
3292 assert_eq!(
3293 peak_bin, target_bin,
3294 "peak bin should be at the tone frequency"
3295 );
3296 assert!(
3297 (peak_db - amp_db).abs() < 1.5,
3298 "peak dB {peak_db} should be within ±1.5 dB of target {amp_db}"
3299 );
3300 }
3301}
3302
3303#[cfg(test)]
3304mod tests {
3305 use super::*;
3306
3307 #[test]
3308 fn test_next_power_of_two() {
3309 assert_eq!(next_power_of_two(1), 1);
3310 assert_eq!(next_power_of_two(2), 2);
3311 assert_eq!(next_power_of_two(3), 4);
3312 assert_eq!(next_power_of_two(1000), 1024);
3313 assert_eq!(next_power_of_two(1024), 1024);
3314 assert_eq!(next_power_of_two(1025), 2048);
3315 }
3316
3317 #[test]
3318 fn test_hann_window() {
3319 let signal = vec![1.0; 100];
3320 let windowed = apply_hann_window(&signal);
3321
3322 assert!(windowed[0].abs() < 0.01);
3324 assert!(windowed[99].abs() < 0.01);
3325
3326 assert!((windowed[50] - 1.0).abs() < 0.01);
3328 }
3329
3330 #[test]
3331 fn test_estimate_lag_zero() {
3332 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
3334 let lag = estimate_lag(&signal, &signal).unwrap();
3335 assert_eq!(lag, 0);
3336 }
3337
3338 #[test]
3339 fn test_estimate_lag_positive() {
3340 let mut reference = vec![0.0; 100];
3343 let mut recorded = vec![0.0; 100];
3344
3345 for (j, val) in reference[10..20].iter_mut().enumerate() {
3347 *val = j as f32 / 10.0;
3348 }
3349 for (j, val) in recorded[15..25].iter_mut().enumerate() {
3351 *val = j as f32 / 10.0;
3352 }
3353
3354 let lag = estimate_lag(&reference, &recorded).unwrap();
3355 assert_eq!(lag, 5, "Recorded signal is delayed by 5 samples");
3356 }
3357
3358 #[test]
3359 fn test_identical_signals_have_zero_lag() {
3360 let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
3363 let lag = estimate_lag(&signal, &signal).unwrap();
3364 assert_eq!(lag, 0, "Identical signals should have zero lag");
3365 }
3366
3367 fn write_test_wav(path: &std::path::Path, samples: &[f32], sample_rate: u32) {
3369 let spec = hound::WavSpec {
3370 channels: 1,
3371 sample_rate,
3372 bits_per_sample: 32,
3373 sample_format: hound::SampleFormat::Float,
3374 };
3375 let mut writer = hound::WavWriter::create(path, spec).unwrap();
3376 for &s in samples {
3377 writer.write_sample(s).unwrap();
3378 }
3379 writer.finalize().unwrap();
3380 }
3381
3382 fn generate_test_sweep(
3384 start_freq: f32,
3385 end_freq: f32,
3386 duration_secs: f32,
3387 sample_rate: u32,
3388 amplitude: f32,
3389 ) -> Vec<f32> {
3390 let num_samples = (duration_secs * sample_rate as f32) as usize;
3391 let mut signal = Vec::with_capacity(num_samples);
3392 let ln_ratio = (end_freq / start_freq).ln();
3393 for i in 0..num_samples {
3394 let t = i as f32 / sample_rate as f32;
3395 let phase = 2.0 * PI * start_freq * duration_secs / ln_ratio
3396 * ((t / duration_secs * ln_ratio).exp() - 1.0);
3397 signal.push(amplitude * phase.sin());
3398 }
3399 signal
3400 }
3401
3402 #[test]
3403 fn test_analyze_recording_normal_channel() {
3404 let sample_rate = 48000;
3407 let duration = 1.0;
3408 let reference = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
3409
3410 let delay = 100;
3412 let attenuation = 0.5;
3413 let mut recorded = vec![0.0_f32; reference.len() + delay];
3414 for (i, &s) in reference.iter().enumerate() {
3415 recorded[i + delay] = s * attenuation;
3416 }
3417
3418 let dir = std::env::temp_dir().join(format!("sotf_test_normal_{}", std::process::id()));
3419 std::fs::create_dir_all(&dir).unwrap();
3420 let wav_path = dir.join("test_normal.wav");
3421 write_test_wav(&wav_path, &recorded, sample_rate);
3422
3423 let result = analyze_recording(&wav_path, &reference, sample_rate, None).unwrap();
3424 std::fs::remove_dir_all(&dir).ok();
3425
3426 let mut sum = 0.0_f32;
3428 let mut count = 0;
3429 for (&freq, &db) in result.frequencies.iter().zip(result.spl_db.iter()) {
3430 if (200.0..=10000.0).contains(&freq) {
3431 sum += db;
3432 count += 1;
3433 }
3434 }
3435 let avg_db = sum / count as f32;
3436
3437 assert!(
3440 avg_db > -12.0 && avg_db < 0.0,
3441 "Normal channel avg SPL should be near -6 dB, got {:.1} dB",
3442 avg_db
3443 );
3444
3445 let max_db = result
3447 .spl_db
3448 .iter()
3449 .zip(result.frequencies.iter())
3450 .filter(|&(_, &f)| (200.0..=10000.0).contains(&f))
3451 .map(|(&db, _)| db)
3452 .fold(f32::NEG_INFINITY, f32::max);
3453 assert!(
3454 max_db < 6.0,
3455 "Normal channel should not have bins above +6 dB, got {:.1} dB",
3456 max_db
3457 );
3458 }
3459
3460 #[test]
3461 fn test_analyze_recording_silent_channel() {
3462 let sample_rate = 48000;
3465 let duration = 1.0;
3466 let reference = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
3467
3468 let noise_amplitude = 0.001;
3470 let num_samples = reference.len();
3471 let mut recorded = Vec::with_capacity(num_samples);
3472 for i in 0..num_samples {
3474 let pseudo_noise =
3475 noise_amplitude * (((i as f32 * 0.1).sin() + (i as f32 * 0.37).cos()) * 0.5);
3476 recorded.push(pseudo_noise);
3477 }
3478
3479 let dir = std::env::temp_dir().join(format!("sotf_test_silent_{}", std::process::id()));
3480 std::fs::create_dir_all(&dir).unwrap();
3481 let wav_path = dir.join("test_silent.wav");
3482 write_test_wav(&wav_path, &recorded, sample_rate);
3483
3484 let result = analyze_recording(&wav_path, &reference, sample_rate, None).unwrap();
3485 std::fs::remove_dir_all(&dir).ok();
3486
3487 let max_db = result
3490 .spl_db
3491 .iter()
3492 .zip(result.frequencies.iter())
3493 .filter(|&(_, &f)| (100.0..=10000.0).contains(&f))
3494 .map(|(&db, _)| db)
3495 .fold(f32::NEG_INFINITY, f32::max);
3496
3497 assert!(
3498 max_db < 0.0,
3499 "Silent/disconnected channel should not have positive dB values, got max {:.1} dB",
3500 max_db
3501 );
3502 }
3503
3504 #[test]
3505 fn test_analyze_recording_lfe_narrow_sweep_same_point_count() {
3506 let sample_rate = 48000;
3511 let duration = 1.0;
3512
3513 let ref_full = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
3515 let ref_lfe = generate_test_sweep(20.0, 500.0, duration, sample_rate, 0.5);
3517
3518 let delay = 50;
3520 let atten = 0.3;
3521
3522 let mut rec_full = vec![0.0_f32; ref_full.len() + delay];
3523 for (i, &s) in ref_full.iter().enumerate() {
3524 rec_full[i + delay] = s * atten;
3525 }
3526
3527 let mut rec_lfe = vec![0.0_f32; ref_lfe.len() + delay];
3528 for (i, &s) in ref_lfe.iter().enumerate() {
3529 rec_lfe[i + delay] = s * atten;
3530 }
3531
3532 let dir = std::env::temp_dir().join(format!("sotf_test_lfe_points_{}", std::process::id()));
3533 std::fs::create_dir_all(&dir).unwrap();
3534
3535 let wav_full = dir.join("main.wav");
3536 let wav_lfe = dir.join("lfe.wav");
3537 write_test_wav(&wav_full, &rec_full, sample_rate);
3538 write_test_wav(&wav_lfe, &rec_lfe, sample_rate);
3539
3540 let result_full = analyze_recording(&wav_full, &ref_full, sample_rate, None).unwrap();
3541 let result_lfe = analyze_recording(&wav_lfe, &ref_lfe, sample_rate, None).unwrap();
3542 std::fs::remove_dir_all(&dir).ok();
3543
3544 assert_eq!(
3546 result_full.frequencies.len(),
3547 result_lfe.frequencies.len(),
3548 "Main ({}) and LFE ({}) must have the same number of frequency points",
3549 result_full.frequencies.len(),
3550 result_lfe.frequencies.len()
3551 );
3552 assert_eq!(
3553 result_full.spl_db.len(),
3554 result_lfe.spl_db.len(),
3555 "SPL arrays must match in length"
3556 );
3557
3558 let lfe_valid_count = result_lfe
3560 .spl_db
3561 .iter()
3562 .zip(result_lfe.frequencies.iter())
3563 .filter(|&(&db, &f)| f <= 500.0 && db > -100.0)
3564 .count();
3565 assert!(
3566 lfe_valid_count > 100,
3567 "LFE should have valid data below 500 Hz, got {} points",
3568 lfe_valid_count
3569 );
3570
3571 let lfe_above_500_max = result_lfe
3572 .spl_db
3573 .iter()
3574 .zip(result_lfe.frequencies.iter())
3575 .filter(|&(_, &f)| f > 1000.0)
3576 .map(|(&db, _)| db)
3577 .fold(f32::NEG_INFINITY, f32::max);
3578 assert!(
3579 lfe_above_500_max <= -100.0,
3580 "LFE above 1 kHz should be at noise floor, got {:.1} dB",
3581 lfe_above_500_max
3582 );
3583 }
3584
3585 #[test]
3586 fn test_cross_correlate_envelope_known_delay() {
3587 let n = 4096;
3589 let sr = 48000_u32;
3590 let probe = crate::signals::gen_narrowband_probe(n, sr, 0.5, 42, 800.0, 2000.0);
3591
3592 let delay = 240_usize;
3594 let attenuation = 0.3;
3595 let mut recorded = vec![0.0_f32; n + delay + 1000];
3596 for (i, &s) in probe.iter().enumerate() {
3597 recorded[i + delay] += s * attenuation;
3598 }
3599
3600 let result = cross_correlate_envelope(&probe, &recorded, sr).unwrap();
3601
3602 let detected_samples = result.peak_sample;
3604 assert!(
3605 (detected_samples as isize - delay as isize).unsigned_abs() <= 2,
3606 "Expected delay ~{} samples, got {}",
3607 delay,
3608 detected_samples
3609 );
3610
3611 assert!(
3613 (result.arrival_ms - 5.0).abs() < 0.1,
3614 "Expected ~5.0 ms, got {:.3} ms",
3615 result.arrival_ms
3616 );
3617 }
3618
3619 #[test]
3620 fn test_cross_correlate_envelope_with_noise() {
3621 let n = 4096;
3623 let sr = 48000_u32;
3624 let probe = crate::signals::gen_narrowband_probe(n, sr, 0.5, 42, 800.0, 2000.0);
3625
3626 let delay = 480_usize; let mut recorded = vec![0.0_f32; n + delay + 1000];
3628 for (i, &s) in probe.iter().enumerate() {
3629 recorded[i + delay] += s * 0.5;
3630 }
3631 let noise = crate::signals::gen_white_noise(0.1, sr, recorded.len() as f32 / sr as f32);
3633 for (r, &n_s) in recorded.iter_mut().zip(noise.iter()) {
3634 *r += n_s;
3635 }
3636
3637 let result = cross_correlate_envelope(&probe, &recorded, sr).unwrap();
3638
3639 assert!(
3640 (result.peak_sample as isize - delay as isize).unsigned_abs() <= 2,
3641 "Expected delay ~{}, got {} (with noise)",
3642 delay,
3643 result.peak_sample
3644 );
3645 }
3646
3647 #[test]
3648 fn test_windowed_fr_synthetic() {
3649 let sr = 48000;
3653 let mut ir = vec![0.0f32; 4096];
3654 ir[0] = 1.0; ir[240] = 0.5; let result = compute_windowed_fr(&ir, 240, 1920, sr, 200).unwrap();
3658
3659 assert!(!result.direct_sound_spl.is_empty());
3661 assert!(!result.early_reflections_spl.is_empty());
3662 assert!(!result.late_reverb_spl.is_empty());
3663
3664 assert_eq!(result.direct_sound_freq.len(), 200);
3666 assert_eq!(result.early_reflections_freq.len(), 200);
3667 assert_eq!(result.late_reverb_freq.len(), 200);
3668
3669 assert!((result.direct_end_ms - 5.0).abs() < 0.01);
3671 assert!((result.early_end_ms - 40.0).abs() < 0.01);
3672
3673 let mid_hf: Vec<f32> = result
3677 .direct_sound_freq
3678 .iter()
3679 .zip(result.direct_sound_spl.iter())
3680 .filter(|&(&f, _)| f > 500.0 && f < 18000.0)
3681 .map(|(_, &spl)| spl)
3682 .collect();
3683 if mid_hf.len() > 2 {
3684 let max = mid_hf.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
3685 let min = mid_hf.iter().fold(f32::INFINITY, |a, &b| a.min(b));
3686 let range = max - min;
3687 assert!(
3688 range < 12.0,
3689 "Direct sound mid-HF range too large: {:.1} dB",
3690 range
3691 );
3692 }
3693 }
3694
3695 #[test]
3696 fn test_windowed_fr_empty_window() {
3697 let sr = 48000;
3699 let mut ir = vec![0.0f32; 2048];
3700 ir[50] = 1.0;
3702
3703 let result = compute_windowed_fr(&ir, 200, 200, sr, 200).unwrap();
3704
3705 assert_eq!(result.early_reflections_spl.len(), 200);
3707 for &spl in &result.early_reflections_spl {
3708 assert!(
3709 spl <= -199.0,
3710 "Expected silent early reflections, got {:.1} dB",
3711 spl
3712 );
3713 }
3714
3715 let direct_max = result
3717 .direct_sound_spl
3718 .iter()
3719 .fold(f32::NEG_INFINITY, |a, &b| a.max(b));
3720 assert!(
3721 direct_max > -100.0,
3722 "Direct sound should have content, max was {:.1} dB",
3723 direct_max
3724 );
3725 }
3726
3727 #[test]
3728 fn test_thd_window_min_is_frequency_dependent() {
3729 let sr = 48000.0f32;
3733 let start_freq = 20.0f32;
3734 let end_freq = 20000.0f32;
3735 let duration = 10.0f32; let n = 65536usize;
3737 let mut ir = vec![0.0f32; n];
3738 ir[n / 2] = 1.0;
3740
3741 let freqs = vec![1000.0f32];
3742 let fund_db = vec![0.0f32];
3743 let (thd, _harmonics) =
3744 compute_thd_from_ir(&ir, sr, &freqs, &fund_db, start_freq, end_freq, duration);
3745 assert!(
3749 thd[0] >= 0.0 && thd[0] <= 100.0,
3750 "THD should be in [0, 100], got {}",
3751 thd[0]
3752 );
3753 }
3754}