Skip to main content

math_audio_dsp/
analysis.rs

1//! FFT-based frequency analysis for recorded signals
2//!
3//! This module provides functions to analyze recorded audio signals and extract:
4//! - Frequency spectrum (magnitude in dBFS)
5//! - Phase spectrum (compensated for latency)
6//! - Latency estimation via cross-correlation
7//! - Microphone compensation for calibrated measurements
8//! - Standalone WAV buffer analysis (wav2csv functionality)
9
10use 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
20/// Spectrum result: (frequencies, magnitudes_db, phases_deg)
21type 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
27/// Get a cached forward FFT plan for the given size (f32).
28///
29/// Uses a thread-local planner so repeated calls with the same size
30/// return the same plan without recomputing twiddle factors.
31pub 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
35/// Get a cached inverse FFT plan for the given size (f32).
36pub 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/// Microphone compensation data (frequency response correction)
41#[derive(Debug, Clone)]
42pub struct MicrophoneCompensation {
43    /// Frequency points in Hz
44    pub frequencies: Vec<f32>,
45    /// SPL deviation in dB (positive = mic is louder, negative = mic is quieter)
46    pub spl_db: Vec<f32>,
47}
48
49impl MicrophoneCompensation {
50    /// Apply pre-compensation to a sweep signal
51    ///
52    /// For log sweeps, this modulates the amplitude based on the instantaneous frequency
53    /// to pre-compensate for the microphone's response.
54    ///
55    /// # Arguments
56    /// * `signal` - The sweep signal to compensate
57    /// * `start_freq` - Start frequency of the sweep in Hz
58    /// * `end_freq` - End frequency of the sweep in Hz
59    /// * `sample_rate` - Sample rate in Hz
60    /// * `inverse` - If true, applies inverse compensation (boost where mic is weak)
61    ///
62    /// # Returns
63    /// Pre-compensated signal
64    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        // Debug: print some sample points
76        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            // Compute instantaneous frequency for log sweep
82            // f(t) = f0 * exp(t * ln(f1/f0) / T)
83            let freq = start_freq * ((t * (end_freq / start_freq).ln()) / duration).exp();
84
85            // Get compensation at this frequency (in dB)
86            let comp_db = self.interpolate_at(freq);
87
88            // Apply inverse or direct compensation
89            let gain_db = if inverse { -comp_db } else { comp_db };
90
91            // Convert dB to linear gain
92            let gain = 10_f32.powf(gain_db / 20.0);
93
94            // Debug output for sample points
95            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    /// Load microphone compensation from a CSV or TXT file
118    ///
119    /// File format:
120    /// - CSV: frequency_hz,spl_db (with or without header, comma-separated)
121    /// - TXT: freq spl (space/tab-separated, no header assumed)
122    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        // Determine if this is a .txt file (no header expected)
133        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            // Skip empty lines and comments
153            if line.is_empty() || line.starts_with('#') {
154                continue;
155            }
156
157            // For CSV files, skip header line
158            if !is_txt_file && line.starts_with("frequency") {
159                continue;
160            }
161
162            // For TXT files, skip lines that don't start with a number
163            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            // Parse based on file type with auto-detection for TXT
176            let parts: Vec<&str> = if is_txt_file {
177                // TXT: Try to auto-detect separator
178                // First, try comma (in case it's mislabeled CSV)
179                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                    // Try tab
187                    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                        // Fall back to whitespace
195                        line.split_whitespace().collect()
196                    }
197                }
198            } else {
199                // CSV: comma separated
200                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        // Validate that frequencies are sorted
235        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    /// Interpolate compensation value at a given frequency
265    ///
266    /// Uses linear interpolation in dB domain.
267    /// Returns 0.0 for frequencies outside the calibration range.
268    pub fn interpolate_at(&self, freq: f32) -> f32 {
269        if freq < self.frequencies[0] || freq > self.frequencies[self.frequencies.len() - 1] {
270            // Outside calibration range - no compensation
271            return 0.0;
272        }
273
274        // Find the two nearest points
275        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], // Exact match
280            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        // Linear interpolation
291        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// ============================================================================
302// WAV Buffer Analysis (wav2csv functionality)
303// ============================================================================
304
305/// Configuration for standalone WAV buffer analysis
306#[derive(Debug, Clone)]
307pub struct WavAnalysisConfig {
308    /// Number of output frequency points (default: 2000)
309    pub num_points: usize,
310    /// Minimum frequency in Hz (default: 20)
311    pub min_freq: f32,
312    /// Maximum frequency in Hz (default: 20000)
313    pub max_freq: f32,
314    /// FFT size (if None, auto-computed based on signal length and mode)
315    pub fft_size: Option<usize>,
316    /// Window overlap ratio for Welch's method (0.0-1.0, default: 0.5)
317    pub overlap: f32,
318    /// Use single FFT instead of Welch's method (better for sweeps and impulse responses)
319    pub single_fft: bool,
320    /// Apply pink compensation (-3dB/octave) for log sweeps
321    pub pink_compensation: bool,
322    /// Use rectangular window instead of Hann
323    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    /// Create config optimized for log sweep analysis
343    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    /// Create config optimized for impulse response analysis
353    pub fn for_impulse_response() -> Self {
354        Self {
355            single_fft: true,
356            ..Default::default()
357        }
358    }
359
360    /// Create config for stationary signals (music, noise)
361    pub fn for_stationary() -> Self {
362        Self::default()
363    }
364}
365
366/// Result of standalone WAV buffer analysis
367#[derive(Debug, Clone)]
368pub struct WavAnalysisOutput {
369    /// Frequency points in Hz (log-spaced)
370    pub frequencies: Vec<f32>,
371    /// Magnitude in dB
372    pub magnitude_db: Vec<f32>,
373    /// Phase in degrees
374    pub phase_deg: Vec<f32>,
375}
376
377/// Analyze a buffer of audio samples and return frequency response
378///
379/// # Arguments
380/// * `samples` - Mono audio samples (f32, -1.0 to 1.0)
381/// * `sample_rate` - Sample rate in Hz
382/// * `config` - Analysis configuration
383///
384/// # Returns
385/// Analysis result with frequency, magnitude, and phase data
386pub 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    // Determine FFT size
396    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    // Compute spectrum
405    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    // Generate logarithmically spaced frequency points
412    let log_freqs = generate_log_frequencies(config.num_points, config.min_freq, config.max_freq);
413
414    // Interpolate magnitude and phase at log frequencies
415    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    // Apply pink compensation if requested (for log sweeps)
419    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
436/// Analyze a WAV file and return frequency response
437///
438/// # Arguments
439/// * `path` - Path to WAV file
440/// * `config` - Analysis configuration
441///
442/// # Returns
443/// Analysis result with frequency, magnitude, and phase data
444pub 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
452/// Load WAV file as mono and return samples with sample rate
453fn 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    // Convert to mono by averaging channels
475    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
487/// Write WAV analysis result to CSV file
488///
489/// # Arguments
490/// * `result` - Analysis output
491/// * `path` - Path to output CSV file
492pub 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
511/// Compute spectrum using Welch's method (averaged periodograms) - internal version
512fn 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    // Precompute symmetric Hann window (N-1 divisor for spectral analysis)
537    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        // Apply window
553        for i in 0..window_len {
554            windowed[i] = signal[start + i] * hann_window[i];
555        }
556        // Zero-pad the rest if necessary
557        windowed[window_len..fft_size].fill(0.0);
558
559        // Convert to complex
560        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
599/// Compute spectrum using a single FFT - internal version
600fn 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
662/// Next power of two for wav analysis (capped at 1M)
663fn 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
671/// Generate logarithmically spaced frequencies
672fn 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
682/// Logarithmic interpolation
683fn 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
708/// Logarithmic interpolation for phase data (degrees).
709/// Uses circular interpolation to correctly handle ±180° wrap boundaries.
710fn 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            // Circular interpolation: find shortest arc between the two angles
730            let p0 = phase_deg[idx - 1];
731            let p1 = phase_deg[idx];
732            let mut diff = p1 - p0;
733            // Wrap diff to [-180, 180]
734            diff -= 360.0 * (diff / 360.0).round();
735            p0 + t * diff
736        })
737        .collect()
738}
739
740// ============================================================================
741// Recording Analysis (reference vs recorded comparison)
742// ============================================================================
743
744/// Result of FFT analysis
745#[derive(Debug, Clone)]
746pub struct AnalysisResult {
747    /// Frequency bins in Hz
748    pub frequencies: Vec<f32>,
749    /// Magnitude in dBFS
750    pub spl_db: Vec<f32>,
751    /// Phase in degrees (compensated for latency)
752    pub phase_deg: Vec<f32>,
753    /// Estimated latency in samples
754    pub estimated_lag_samples: isize,
755    /// Impulse response (time domain)
756    pub impulse_response: Vec<f32>,
757    /// Time vector for impulse response in ms
758    pub impulse_time_ms: Vec<f32>,
759    /// Excess group delay in ms
760    pub excess_group_delay_ms: Vec<f32>,
761    /// Total Harmonic Distortion + Noise (%)
762    pub thd_percent: Vec<f32>,
763    /// Harmonic distortion curves (2nd, 3rd, etc) in dB
764    pub harmonic_distortion_db: Vec<Vec<f32>>,
765    /// RT60 decay time in ms
766    pub rt60_ms: Vec<f32>,
767    /// Clarity C50 in dB
768    pub clarity_c50_db: Vec<f32>,
769    /// Clarity C80 in dB
770    pub clarity_c80_db: Vec<f32>,
771    /// Spectrogram (Time x Freq magnitude in dB)
772    pub spectrogram_db: Vec<Vec<f32>>,
773}
774
775/// Analyze a recorded WAV file against a reference signal
776///
777/// # Arguments
778/// * `recorded_path` - Path to the recorded WAV file
779/// * `reference_signal` - Reference signal (should match the signal used for playback)
780/// * `sample_rate` - Sample rate in Hz
781/// * `sweep_range` - Optional (start_freq, end_freq) if the signal is a log sweep
782///
783/// # Returns
784/// Analysis result with frequency, SPL, and phase data
785pub 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    // Load recorded WAV
792    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    // Don't truncate yet - we need full signals for lag estimation
811    let recorded = &recorded[..];
812    let reference = reference_signal;
813
814    // Debug: Check signal statistics (guarded to skip O(n) computation when disabled)
815    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    // Estimate lag using cross-correlation
866    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    // Time-align the signals before FFT
875    // If recorded is delayed (positive lag), skip the lag samples in recorded
876    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        // Capture full tail
882        (reference, &recorded[lag_usize..])
883    } else {
884        // Recorded leads reference - rare
885        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        // Pad reference start? No, just slice reference
890        (&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    // Compute FFT size to include the longer of the two (usually rec with tail)
900    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    // Generate 2000 log-spaced frequency points between 20 Hz and 20 kHz
906    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; // Single-sided spectrum
916
917    // Compute regularization threshold relative to the peak reference energy.
918    // Bins where the reference has very little energy (e.g., disconnected speaker
919    // with a misaligned sweep) produce unreliable transfer functions — division by
920    // near-zero gives spurious high-dB peaks. We skip bins where the reference
921    // energy is more than 60 dB below the peak.
922    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    // 60 dB below peak = 10^(-6) in power
927    let ref_regularization_threshold = ref_peak_mag_sq * 1e-6;
928
929    // Apply 1/24 octave smoothing for each target frequency
930    let mut skipped_count = 0;
931    for i in 0..num_output_points {
932        // Log-spaced target frequency
933        let target_freq =
934            (log_start + (log_end - log_start) * i as f32 / (num_output_points - 1) as f32).exp();
935
936        // 1/24 octave bandwidth: +/- 1/48 octave around target frequency
937        // Lower and upper frequency bounds: f * 2^(+/- 1/48)
938        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        // Find FFT bins within this frequency range
943        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            // Output noise-floor placeholder so all channels produce the same
958            // number of frequency points (prevents ndarray shape mismatches).
959            frequencies.push(target_freq);
960            spl_db.push(-200.0);
961            phase_deg.push(0.0);
962            continue;
963        }
964
965        // Average transfer function magnitude and phase across bins in the smoothing range
966        let mut sum_magnitude = 0.0;
967        let mut sum_sin = 0.0; // For circular averaging of phase
968        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            // Compute transfer function: H(f) = recorded / reference
977            // This gives the system response (for loopback, should be ~1.0 or 0 dB)
978            // Skip bins where the reference energy is too low (>60 dB below peak):
979            // dividing by near-zero produces unreliable, spuriously high values
980            // (e.g., disconnected speaker where the recording is just noise).
981            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            // Phase from cross-spectrum (signals are already time-aligned)
989            let cross_spectrum = ref_spectrum[k].conj() * rec_spectrum[k];
990            let phase_rad = cross_spectrum.arg();
991
992            // Accumulate for averaging
993            sum_magnitude += magnitude;
994            sum_sin += phase_rad.sin();
995            sum_cos += phase_rad.cos();
996            bin_count += 1;
997        }
998
999        // When no valid bins contribute (reference energy too low at this frequency,
1000        // e.g., LFE sweep above 500 Hz), output a noise-floor value instead of skipping.
1001        // Skipping would produce fewer output points than other channels, causing
1002        // ndarray shape mismatches when curves are combined downstream.
1003        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        // Average phase using circular mean
1020        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    // --- Compute Impulse Response ---
1049    // H(f) = Recorded(f) / Reference(f)
1050    let mut transfer_function = vec![Complex::new(0.0, 0.0); fft_size];
1051    for k in 0..fft_size {
1052        // Handle DC and Nyquist specially if needed, but for complex FFT it's just bins
1053        // Avoid division by zero
1054        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    // IFFT to get Impulse Response
1061    let ifft = plan_fft_inverse(fft_size);
1062    ifft.process(&mut transfer_function);
1063
1064    // Normalize and take real part (input was real, so output should be real-ish)
1065    // Scale by 1.0/N is done by IFFT? rustfft typically does NOT scale.
1066    // Standard IFFT definition: sum(X[k] * exp(...)) / N?
1067    // RustFFT inverse is unnormalized sum. So we divide by N.
1068    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    // Find the peak and shift the IR so the peak is near the beginning
1072    // This is necessary because the IFFT result has the peak at an arbitrary position
1073    // due to the phase of the transfer function (system latency)
1074    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    // Shift the IR so peak is at a small offset (e.g., 5ms for pre-ringing visibility)
1082    let pre_ring_samples = (0.005 * sample_rate as f32) as usize; // 5ms pre-ring buffer
1083    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    // Generate time vector for IR (0 to duration)
1095    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    // --- Compute THD if sweep range is provided ---
1101    let (thd_percent, harmonic_distortion_db) = if let Some((start, end)) = sweep_range {
1102        // Assume sweep duration is same as impulse length (circular convolution)
1103        // or derived from reference signal length
1104        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    // --- Compute Excess Group Delay ---
1119    // (Placeholder)
1120    let excess_group_delay_ms = vec![0.0; frequencies.len()];
1121
1122    // --- Compute Acoustic Metrics ---
1123    // Debug: Log impulse response stats
1124    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    // Debug: Log computed metrics
1138    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    // Compute Spectrogram
1160    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
1180/// Compute Total Harmonic Distortion (THD) from Impulse Response
1181///
1182/// Uses Farina's method to extract harmonics from the impulse response of a log sweep.
1183fn 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; // Compute 2nd, 3rd, 4th, 5th
1202    // Initialize to -120 dB (very low but not absurdly so)
1203    let mut harmonics_db = vec![vec![-120.0; frequencies.len()]; num_harmonics];
1204
1205    // Find main peak index (t=0)
1206    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    // Compute harmonics
1225    for (k_idx, harmonic_db) in harmonics_db.iter_mut().enumerate().take(num_harmonics) {
1226        let harmonic_order = k_idx + 2; // 2nd harmonic is k=2
1227
1228        // Calculate delay for this harmonic
1229        // dt = T * ln(k) / ln(f2/f1)
1230        let dt = duration * (harmonic_order as f32).ln() / sweep_ratio.ln();
1231        let dn = (dt * sample_rate).round() as isize;
1232
1233        // Center of harmonic impulse (negative time wraps to end of array)
1234        let center = peak_idx as isize - dn;
1235        let center_wrapped = center.rem_euclid(n as isize) as usize;
1236
1237        // Window size logic: distance to next harmonic * 0.8 to avoid overlap.
1238        // Issue #6: the old hardcoded floor of 256 samples did not scale with
1239        // sample rate or fundamental frequency.  We now use at least 3 periods
1240        // of the lowest harmonic (start of sweep), capped at 16 samples minimum.
1241        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        // Extract windowed harmonic IR
1248        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            // Apply Hann window
1254            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        // Compute spectrum
1272        let fft_size = next_power_of_two(win_len);
1273        let nyquist_bin = fft_size / 2; // Only use positive frequency bins
1274        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                // Only access positive frequency bins (0 to nyquist)
1280                if bin < nyquist_bin && bin < spectrum.len() {
1281                    // compute_fft_padded already applies 1/N normalization, matching
1282                    // the scale of fundamental_db (derived from transfer function ratios)
1283                    let mag = spectrum[bin].norm();
1284                    // Convert to dB (threshold at -120 dB to avoid log of tiny values)
1285                    if mag > 1e-6 {
1286                        harmonic_db[i] = 20.0 * mag.log10();
1287                    }
1288                }
1289            }
1290        }
1291    }
1292
1293    // Log a summary of detected harmonic levels
1294    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    // Compute THD %
1308    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        // THD = sqrt(sum(harmonics^2)) / fundamental
1319        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    // Log THD summary
1328    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
1337/// Write analysis results to CSV file with optional microphone compensation
1338///
1339/// # Arguments
1340/// * `result` - Analysis result
1341/// * `output_path` - Path to output CSV file
1342/// * `compensation` - Optional microphone compensation to apply (inverse)
1343///
1344/// When compensation is provided, the inverse is applied: the microphone's
1345/// SPL deviation is subtracted from the measured SPL to get the true SPL.
1346///
1347/// CSV format includes all analysis metrics:
1348/// frequency_hz, spl_db, phase_deg, thd_percent, rt60_ms, c50_db, c80_db, group_delay_ms
1349pub 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    // Write header with all metrics
1378    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    // Write data with compensation applied
1385    for i in 0..result.frequencies.len() {
1386        let freq = result.frequencies[i];
1387        let mut spl = result.spl_db[i];
1388
1389        // Apply inverse compensation: subtract microphone deviation
1390        // If mic reads +2dB at this frequency, the true level is 2dB lower
1391        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
1419/// Read analysis results from CSV file
1420///
1421/// Parses CSV with columns: frequency_hz, spl_db, phase_deg, thd_percent, rt60_ms, c50_db, c80_db, group_delay_ms
1422/// Also supports legacy format with just: frequency_hz, spl_db, phase_deg
1423pub 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    // Read header
1432    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    // If legacy format, fill with zeros
1475    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/// Window function type for FFT
1502#[derive(Debug, Clone, Copy)]
1503enum WindowType {
1504    Hann,
1505    Tukey(f32), // alpha parameter (0.0-1.0)
1506}
1507
1508/// Estimate lag between reference and recorded signals using cross-correlation
1509///
1510/// Uses FFT-based cross-correlation for efficiency
1511///
1512/// # Arguments
1513/// * `reference` - Reference signal
1514/// * `recorded` - Recorded signal
1515///
1516/// # Returns
1517/// Estimated lag in samples (negative means recorded leads)
1518fn estimate_lag(reference: &[f32], recorded: &[f32]) -> Result<isize, String> {
1519    let len = reference.len().min(recorded.len());
1520
1521    // Zero-pad to avoid circular correlation artifacts
1522    let fft_size = next_power_of_two(len * 2);
1523
1524    // Use Hann window for correlation to suppress edge effects
1525    let ref_fft = compute_fft(reference, fft_size, WindowType::Hann)?;
1526    let rec_fft = compute_fft(recorded, fft_size, WindowType::Hann)?;
1527
1528    // Cross-correlation in frequency domain: conj(X) * Y
1529    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    // IFFT to get cross-correlation in time domain
1536    let ifft = plan_fft_inverse(fft_size);
1537    ifft.process(&mut cross_corr_fft);
1538
1539    // Find peak
1540    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    // Convert index to lag (handle wrap-around)
1552    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/// Result of cross-correlation with analytic envelope detection.
1560///
1561/// The envelope peak corresponds to the probe's arrival time, detected
1562/// via Hilbert transform of the cross-correlation.
1563#[derive(Debug, Clone)]
1564pub struct CrossCorrelationEnvelopeResult {
1565    /// Analytic envelope of the cross-correlation
1566    pub envelope: Vec<f32>,
1567    /// Sample index of the peak (integer arrival time)
1568    pub peak_sample: usize,
1569    /// Sub-sample refined peak position via parabolic interpolation
1570    pub peak_sample_refined: f64,
1571    /// Peak envelope value (proportional to channel gain)
1572    pub peak_value: f32,
1573    /// Arrival time in milliseconds (sub-sample precision)
1574    pub arrival_ms: f64,
1575}
1576
1577/// Cross-correlate a probe with a recording and compute the analytic envelope.
1578///
1579/// Uses FFT-based cross-correlation followed by the Hilbert transform
1580/// (via `analytic_signal`) to extract a smooth envelope whose peak
1581/// indicates the arrival time with sub-sample precision.
1582///
1583/// This is the matched-filter approach recommended by Johnston (AES):
1584/// narrowband probes give excellent noise rejection, and the analytic
1585/// envelope provides a clean, unambiguous peak even in reverberant rooms.
1586///
1587/// # Arguments
1588/// * `probe` - The known probe signal that was played
1589/// * `recorded` - The recorded signal from the microphone
1590/// * `sample_rate` - Sample rate in Hz
1591pub 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    // Zero-pad to avoid circular correlation artifacts
1601    let fft_size = next_power_of_two(probe.len() + recorded.len());
1602
1603    // Raw FFT (no normalization) — we handle normalization once after IFFT.
1604    // Using unnormalized FFT avoids the scale-dependent gain errors that
1605    // occur when compute_fft_padded's 1/N normalization interacts with IFFT.
1606    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    // Cross-correlation: conj(Probe) * Recorded
1621    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    // IFFT to get cross-correlation in time domain
1628    let ifft = plan_fft_inverse(fft_size);
1629    ifft.process(&mut cross_fft);
1630
1631    // Single 1/N normalization (standard for round-trip FFT→IFFT)
1632    let norm = 1.0 / fft_size as f32;
1633    let xcorr: Vec<f32> = cross_fft.iter().map(|c| c.re * norm).collect();
1634
1635    // Compute analytic envelope via Hilbert transform
1636    let analytic = crate::instantaneous_frequency::analytic_signal(&xcorr);
1637    let envelope: Vec<f32> = analytic.iter().map(|c| c.norm()).collect();
1638
1639    // Find peak in the causal part (first half — positive lags only)
1640    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    // Parabolic interpolation for sub-sample precision
1651    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/// Frequency responses computed from different time windows of an impulse response.
1677///
1678/// Direct sound, early reflections, and late reverb each have different
1679/// perceptual roles (Toole, Johnston) and should be corrected differently.
1680#[derive(Debug, Clone)]
1681pub struct WindowedFrequencyResponse {
1682    /// Direct sound frequency response (frequencies in Hz, SPL in dB)
1683    pub direct_sound_freq: Vec<f32>,
1684    pub direct_sound_spl: Vec<f32>,
1685    /// Early reflections frequency response
1686    pub early_reflections_freq: Vec<f32>,
1687    pub early_reflections_spl: Vec<f32>,
1688    /// Late/reverberant field frequency response
1689    pub late_reverb_freq: Vec<f32>,
1690    pub late_reverb_spl: Vec<f32>,
1691    /// Time boundaries used (in ms)
1692    pub direct_end_ms: f64,
1693    pub early_end_ms: f64,
1694}
1695
1696/// Compute frequency responses for different time windows of the impulse response.
1697///
1698/// Uses SSIR segmentation boundaries to separate:
1699/// - Direct sound: \[0, first_reflection_onset)
1700/// - Early reflections: \[first_reflection_onset, mixing_time)
1701/// - Late reverb: \[mixing_time, end)
1702///
1703/// Each window gets a half-Hann fade at edges to avoid spectral leakage,
1704/// then FFT -> magnitude -> 1/24 octave smoothing.
1705pub 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    // Fade length: 1ms or half the window, whichever is smaller
1727    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            // Return silence at the output frequencies
1733            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        // Extract and fade the window edges to reduce spectral leakage.
1747        // Skip fade-in at the physical start of the IR (start==0) to avoid
1748        // attenuating the direct sound impulse.
1749        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        // Zero-pad to next power of 2
1757        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        // Normalize by FFT size
1767        let norm = 1.0 / fft_size as f32;
1768
1769        // Generate log-spaced output frequencies and compute magnitude in dB
1770        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            // Map to nearest FFT bin
1785            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        // Apply 1/24 octave smoothing
1796        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
1816/// Compute FFT of a signal with specified windowing
1817///
1818/// # Arguments
1819/// * `signal` - Input signal
1820/// * `fft_size` - FFT size (should be power of 2)
1821/// * `window_type` - Type of window to apply
1822///
1823/// # Returns
1824/// Complex FFT spectrum
1825fn compute_fft(
1826    signal: &[f32],
1827    fft_size: usize,
1828    window_type: WindowType,
1829) -> Result<Vec<Complex<f32>>, String> {
1830    // Apply window
1831    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
1839/// Compute FFT with zero-padding
1840fn compute_fft_padded(signal: &[f32], fft_size: usize) -> Result<Vec<Complex<f32>>, String> {
1841    // Single allocation at final size; trailing elements are already zero-padded
1842    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    // Compute FFT
1848    let fft = plan_fft_forward(fft_size);
1849    fft.process(&mut buffer);
1850
1851    // Normalize by FFT size (standard FFT normalization)
1852    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
1860/// Apply Hann window to a signal
1861fn 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
1876/// Apply Tukey window to a signal
1877///
1878/// Tukey window is a "tapered cosine" window.
1879/// alpha=0.0 is rectangular, alpha=1.0 is Hann.
1880fn 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                // Fade in (Half-Hann)
1899                0.5 * (1.0 - (PI * i as f32 / limit as f32).cos())
1900            } else if i >= len - limit {
1901                // Fade out (Half-Hann)
1902                let n = len - 1 - i;
1903                0.5 * (1.0 - (PI * n as f32 / limit as f32).cos())
1904            } else {
1905                // Flat top
1906                1.0
1907            };
1908            x * w
1909        })
1910        .collect()
1911}
1912
1913/// Find the next power of two greater than or equal to n
1914fn next_power_of_two(n: usize) -> usize {
1915    if n == 0 {
1916        return 1;
1917    }
1918    n.next_power_of_two()
1919}
1920
1921/// Load a mono WAV file and convert to f32 samples
1922/// Load a WAV file and extract a specific channel or convert to mono
1923///
1924/// # Arguments
1925/// * `path` - Path to WAV file
1926/// * `channel_index` - Optional channel index to extract (0-based). If None, will average all channels for mono
1927fn 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    // Read all samples and convert to f32
1942    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    // Handle mono file - return as-is
1957    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    // Handle multi-channel file
1966    if let Some(ch_idx) = channel_index {
1967        // Extract specific channel
1968        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        // Average all channels to mono
1985        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
1996/// Load a WAV file as mono (averages channels if multi-channel)
1997fn load_wav_mono(path: &Path) -> Result<Vec<f32>, String> {
1998    load_wav_mono_channel(path, None)
1999}
2000
2001// ============================================================================
2002// DSP Utilities (Moved from frontend dsp.rs)
2003// ============================================================================
2004
2005/// Apply octave smoothing to frequency response data (f64 version)
2006///
2007/// Frequencies must be sorted in ascending order (as from FFT or log-spaced grids).
2008/// If the input slices are mismatched, only paired frequency/value samples are
2009/// smoothed and returned.
2010/// Uses a prefix sum with two-pointer sliding window for O(n) complexity.
2011pub 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    // Prefix sum for O(1) range averages
2019    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, &center_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        // Advance lo past frequencies below the window
2040        while lo < paired_len && frequencies[lo] < low_freq {
2041            lo += 1;
2042        }
2043        // Advance hi to include frequencies within the window
2044        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
2059/// Apply octave smoothing to frequency response data (f32 version)
2060///
2061/// Frequencies must be sorted in ascending order (as from FFT or log-spaced grids).
2062/// If the input slices are mismatched, only paired frequency/value samples are
2063/// smoothed and returned.
2064/// Uses a prefix sum with two-pointer sliding window for O(n) complexity.
2065pub 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    // Prefix sum for O(1) range averages (accumulate in f64 to avoid precision loss)
2073    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, &center_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        // Advance lo past frequencies below the window
2094        while lo < paired_len && frequencies[lo] < low_freq {
2095            lo += 1;
2096        }
2097        // Advance hi to include frequencies within the window
2098        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
2113/// Compute group delay from phase data
2114/// Group delay = -d(phase)/d(frequency) / (2*pi)
2115///
2116/// Phase is unwrapped before differentiation to avoid spurious spikes
2117/// at ±180° wrap boundaries.
2118pub 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    // Unwrap phase to remove ±180° discontinuities before differentiation
2124    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            // Forward difference at start
2131            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 // Convert to ms
2135            } else {
2136                0.0
2137            }
2138        } else if i == frequencies.len() - 1 {
2139            // Backward difference at end
2140            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            // Central difference
2149            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
2163/// Unwrap phase in degrees to produce a continuous phase curve.
2164/// Wraps each inter-sample difference to [-180, 180] and accumulates,
2165/// handling arbitrarily large jumps (not just single ±360° wraps).
2166fn 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
2183/// Compute impulse response from frequency response via inverse FFT.
2184///
2185/// The input frequency/magnitude/phase data (possibly irregularly spaced) is
2186/// interpolated onto a uniform FFT frequency grid, assembled into a complex
2187/// spectrum with Hermitian symmetry, and transformed with an inverse FFT.
2188///
2189/// Returns (times_ms, impulse) where impulse is peak-normalized to [-1, 1].
2190pub 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; // Number of positive-frequency bins (excluding DC)
2198    let freq_bin = sample_rate / fft_size as f32;
2199
2200    // Unwrap phase before interpolation to avoid discontinuities
2201    let unwrapped_phase = unwrap_phase_deg(phase_deg);
2202
2203    // Build complex spectrum on uniform FFT grid via linear interpolation
2204    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        // Interpolate magnitude (dB) and phase (deg) at this bin frequency
2210        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    // Enforce Hermitian symmetry: X[N-k] = conj(X[k])
2219    for k in 1..half {
2220        spectrum[fft_size - k] = spectrum[k].conj();
2221    }
2222
2223    // Inverse FFT (uses thread-local cached planner)
2224    let ifft = plan_fft_inverse(fft_size);
2225    ifft.process(&mut spectrum);
2226
2227    // Extract real part and scale by 1/N (rustfft doesn't normalize)
2228    let scale = 1.0 / fft_size as f32;
2229    let mut impulse: Vec<f32> = spectrum.iter().map(|c| c.re * scale).collect();
2230
2231    // Normalize to [-1, 1]
2232    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
2247/// Linearly interpolate magnitude and phase at a target frequency.
2248/// Clamps to the nearest endpoint if `target_freq` is outside the data range.
2249///
2250/// Phase must be pre-unwrapped (continuous) for correct interpolation.
2251fn 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    // Binary search for the interval containing target_freq
2269    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, // target_freq is between frequencies[i-1] and frequencies[i]
2272    };
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
2284/// Compute Schroeder energy decay curve
2285fn compute_schroeder_decay(impulse: &[f32]) -> Vec<f32> {
2286    let mut energy = 0.0;
2287    let mut decay = vec![0.0; impulse.len()];
2288
2289    // Backward integration
2290    for i in (0..impulse.len()).rev() {
2291        energy += impulse[i] * impulse[i];
2292        decay[i] = energy;
2293    }
2294
2295    // Normalize to 0dB max (1.0 linear)
2296    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
2321/// Trim late steady-state noise before Schroeder integration.
2322///
2323/// This is a lightweight Lundeby-style guard: estimate the tail noise from the
2324/// last 10% of 10 ms windows, keep the last window that is at least 10 dB above
2325/// that floor, and leave a short headroom after it.
2326fn 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
2479/// Compute RT60 from Impulse Response (Broadband)
2480/// Uses Schroeder backward integration and least-squares T30/T20 extrapolation.
2481pub 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
2487/// Compute Clarity (C50, C80) from Impulse Response (Broadband)
2488/// Returns (C50_dB, C80_dB)
2489pub 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    // When late energy is negligible, clarity is very high (capped at 60 dB)
2515    // When early energy is negligible, clarity is very low (capped at -60 dB)
2516    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 // Early energy dominates - excellent clarity
2523    } else {
2524        -MAX_CLARITY_DB // Late energy dominates - poor clarity
2525    };
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 // Early energy dominates - excellent clarity
2532    } else {
2533        -MAX_CLARITY_DB // Late energy dominates - poor clarity
2534    };
2535
2536    (c50, c80)
2537}
2538
2539/// Compute RT60 spectrum using octave band filtering
2540pub 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    // Octave band center frequencies
2546    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    // Compute RT60 for each band
2554    for &freq in &centers {
2555        // Skip if frequency is too high for sample rate
2556        if freq >= sample_rate / 2.0 {
2557            continue;
2558        }
2559
2560        // Apply bandpass filter
2561        // Q=1.414 (sqrt(2)) gives approx 1 octave bandwidth
2562        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        // Process in f64
2571        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        // Compute RT60 for this band
2576        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 per-band values
2596    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 to output frequencies
2603    interpolate_log(&valid_centers, &band_rt60s, frequencies)
2604}
2605
2606/// Compute Clarity spectrum (C50, C80) using octave band filtering
2607/// Returns (C50_vec, C80_vec)
2608pub 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    // Octave band center frequencies
2618    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    // Time boundaries for clarity calculation
2626    let samp_50ms = (0.050 * sample_rate) as usize;
2627    let samp_80ms = (0.080 * sample_rate) as usize;
2628
2629    // Compute Clarity for each band using cascaded bandpass for better selectivity
2630    for &freq in &centers {
2631        if freq >= sample_rate / 2.0 {
2632            continue;
2633        }
2634
2635        // Use cascaded biquads for sharper filter response (reduces filter ringing effects)
2636        let mut biquad1 = Biquad::new(
2637            BiquadFilterType::Bandpass,
2638            freq as f64,
2639            sample_rate as f64,
2640            0.707, // Lower Q per stage, cascaded gives Q ~ 1.0
2641            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        // Compute energy in early and late windows directly
2656        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        // Compute C50 and C80 with proper handling
2678        // When late energy is very small, clarity is high (capped at 40 dB for display)
2679        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 per-band values
2706    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    // Interpolate to output frequency grid
2720    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
2726/// Compute Spectrogram from Impulse Response
2727/// Returns (spectrogram_matrix_db, frequency_bins, time_bins)
2728/// `window_size` samples (e.g. 512), `hop_size` samples (e.g. 128).
2729pub 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    // Precompute Hann window
2746    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    // Setup FFT
2751    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        // Take magnitude of first half (up to Nyquist)
2768        // Store as dB
2769        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    // Generate frequency bins
2785    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
2792/// Find a frequency point where the magnitude reaches a specific dB level
2793///
2794/// # Arguments
2795/// * `frequencies` - Frequency points in Hz
2796/// * `magnitude_db` - Magnitude in dB
2797/// * `target_db` - The target level to find (e.g., -3.0)
2798/// * `from_start` - If true, search from the beginning of the curve. If false, search from the end.
2799///
2800/// # Returns
2801/// The interpolated frequency where the target dB is reached, or None if not found.
2802pub 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            // Check if target_db is between m0 and m1
2818            if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2819                // Linear interpolation: m0 + t * (m1 - m0) = target_db
2820                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            // Check if target_db is between m0 and m1
2834            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
2848/// Compute a log-frequency weighted reference response level in dB.
2849///
2850/// # Arguments
2851/// * `frequencies` - Frequency points in Hz
2852/// * `magnitude_db` - Magnitude in dB
2853/// * `freq_range` - Optional (start_freq, end_freq) to limit the averaging range.
2854///   If None, averages over the full bandwidth.
2855///
2856/// # Returns
2857/// The log-frequency weighted average in the dB domain.
2858///
2859/// This is intended as a stable acoustic reference level for comparison and
2860/// normalization. It is not a pressure- or energy-domain average.
2861pub 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        // Check if this segment overlaps with the target range
2881        if f1 < start_freq || f0 > end_freq {
2882            continue;
2883        }
2884
2885        // Clamp segment to target range
2886        let fa = f0.max(start_freq);
2887        let fb = f1.min(end_freq);
2888
2889        if fb <= fa {
2890            continue;
2891        }
2892
2893        // For acoustic data, we weight by log frequency (octaves)
2894        // weight = log2(fb/fa)
2895        let weight = (fb / fa).log2();
2896
2897        // Average magnitude in this segment
2898        // We'll use the midpoint value of the segment (or average of endpoints)
2899        // If the segment is partially outside start_freq/end_freq, we should interpolate
2900        // but for many points simple average of endpoints in the segment is fine.
2901        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
2916// ---------------------------------------------------------------------------
2917// GD-Opt v2 Phase GD-1c — multi-sweep coherence + noise-floor primitives
2918//
2919// These three pure functions let callers (sotf-engine's
2920// `record_multi_sweep`) turn a batch of N recorded sweeps + a
2921// pre-silence noise-floor window into the extended `Curve` columns
2922// `coherence` and `noise_floor_db` documented in §2.3 / §2.4 of
2923// `docs/gd_opt_v2_plan.md`.
2924// ---------------------------------------------------------------------------
2925
2926/// Magnitude-squared coherence γ²(f) across N complex spectra that
2927/// should be measurements of the same deterministic transfer
2928/// function. Per `docs/gd_opt_v2_plan.md` §2.2:
2929///
2930/// ```text
2931///    γ²(f) = |H̄(f)|² / ⟨|H(f)|²⟩
2932///    H̄(f) = (1/N) Σ_i H_i(f)              (complex average)
2933///    ⟨|H(f)|²⟩ = (1/N) Σ_i |H_i(f)|²       (power average)
2934/// ```
2935///
2936/// Returns a vector of per-bin γ² in `[0, 1]`. For N = 1 γ² ≡ 1 by
2937/// construction — a single measurement is trivially "consistent with
2938/// itself" so callers must enforce the "at least 4 sweeps" rule at
2939/// a higher level (the `"insufficient_bass_duration"` advisory in
2940/// GD-1g).
2941///
2942/// Returns `Err` iff `realizations` is empty or any realization has
2943/// a different length than the first.
2944pub 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    // Issue #7: a single realization is trivially "coherent with itself"
2952    // but the result is statistically meaningless.  Require N ≥ 4.
2953    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            // Dead bin — report as zero-confidence rather than NaN so
2984            // downstream thresholds just drop it.
2985            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
2995/// Deconvolve a single recorded log sweep by dividing the recording's
2996/// spectrum by the emitted sweep's spectrum, producing a complex
2997/// frequency response on the FFT grid `[0, Nyquist]`.
2998///
2999/// The inverse-filter approach is the standard log-sweep
3000/// deconvolution:
3001///
3002/// ```text
3003///    H(f) = Y(f) / X(f)
3004/// ```
3005///
3006/// where Y is the recording and X is the emitted sweep. A small
3007/// regularisation term ε is added to the denominator to keep out-
3008/// of-band bins from blowing up — 60 dB below the sweep's peak is a
3009/// safe default.
3010///
3011/// The returned spectrum has `recording.len().next_power_of_two() / 2 + 1`
3012/// complex bins, indexed so bin k corresponds to frequency
3013/// `k * sample_rate / fft_size`.
3014///
3015/// Callers that want multiple realisations pass each captured sweep
3016/// through this function in turn and feed the collected `Vec<Vec<_>>`
3017/// to [`compute_coherence_from_realizations`].
3018pub 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    // Regularisation: 60 dB below the sweep's peak bin magnitude.
3050    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; // 60 dB below peak
3056    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        // H = Y / X with Tikhonov-style regularisation:
3062        //   H = (Y · conj(X)) / (|X|² + ε²)
3063        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
3072/// Estimate per-bin noise floor in dB from a silence window.
3073///
3074/// Takes the pre-silence samples captured before the sweep starts,
3075/// windows the FFT the same way the sweep analysis does, and returns
3076/// one dB value per positive-frequency bin (including DC and Nyquist,
3077/// i.e. `fft_size / 2 + 1` values). Result is reference-to-full-scale
3078/// (i.e., a silence bin at 0.001 linear amplitude maps to -60 dB).
3079///
3080/// The FFT size is taken as `silence.len().next_power_of_two()` so
3081/// the bin grid matches [`deconvolve_sweep`] when the silence window
3082/// is the same length as the sweep.
3083///
3084/// A Hann window is applied before the FFT to reduce spectral
3085/// leakage that would otherwise push DC noise into every other bin
3086/// and make bass SNR look healthier than it is.
3087pub 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    // Hann-window the silence before FFT.
3096    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    // Windowed amplitude normalisation for a real sinusoid on a bin
3110    // centre. The FFT of `sin(2π·m·k/N)` has magnitude `N/2` at bin
3111    // `m`, and Hann windowing multiplies that by its coherent gain
3112    // of `0.5` — so the windowed peak is `N/4`. Multiply by `4/N` to
3113    // recover the underlying sinusoid amplitude (and let
3114    // `20·log10(mag)` match the tone's dBFS).
3115    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 // effectively "nothing"; avoids -inf leaking downstream
3125            }
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        // Issue #7: N < 4 now returns an error instead of misleading γ² = 1.
3138        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        // Four realizations whose phases cancel out on average:
3182        // ±1 and ±i. The complex mean is 0, so γ² = 0.
3183        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        // If the recorded signal IS the emitted sweep, H should be
3213        // approximately 1 across the passband.
3214        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        // Mid-band bins should be ≈ 1 (within the regularisation
3227        // floor). Check bins 10..50 — avoids DC where the sweep has
3228        // no energy and the Nyquist edge where the log sweep dies out.
3229        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        // Pick a frequency that lands exactly on an FFT bin centre
3264        // so there's no inter-bin leakage. Hann windowing still
3265        // splits ~half the peak energy into the two adjacent bins by
3266        // design; at the exact centre the main-lobe peak returns
3267        // within ~1 dB of the target.
3268        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; // 1171.875 Hz
3272        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        // Find the peak bin in a small bracket around the target.
3279        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        // First and last samples should be near zero
3323        assert!(windowed[0].abs() < 0.01);
3324        assert!(windowed[99].abs() < 0.01);
3325
3326        // Middle sample should be near 1.0
3327        assert!((windowed[50] - 1.0).abs() < 0.01);
3328    }
3329
3330    #[test]
3331    fn test_estimate_lag_zero() {
3332        // Identical signals should have zero lag
3333        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        // Reference leads recorded (recorded is delayed)
3341        // Use longer signals for reliable FFT-based cross-correlation
3342        let mut reference = vec![0.0; 100];
3343        let mut recorded = vec![0.0; 100];
3344
3345        // Create a pulse pattern that will correlate well
3346        for (j, val) in reference[10..20].iter_mut().enumerate() {
3347            *val = j as f32 / 10.0;
3348        }
3349        // Same pattern but delayed by 5 samples
3350        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        // When signals are truly identical (like in the bug case),
3361        // lag should be exactly zero
3362        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    /// Write a mono f32 WAV file for testing
3368    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    /// Generate a log sweep signal (same as the recording system uses)
3383    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        // Simulate a normal speaker: reference sweep played back and recorded
3405        // with some attenuation and small delay
3406        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        // Simulate recording: attenuate by ~-6dB (factor 0.5) and delay by 100 samples
3411        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        // Compute average SPL in the passband (200 Hz - 10 kHz)
3427        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        // Expected: ~-6 dB (attenuation factor 0.5)
3438        // Allow generous tolerance for windowing/FFT artifacts
3439        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        // No bin should exceed +6 dB (physically implausible for passive attenuation)
3446        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        // Simulate a disconnected speaker: reference sweep played but recording
3463        // is just low-level noise (no speaker output)
3464        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        // Recording is pure noise at -60 dBFS (amplitude 0.001)
3469        let noise_amplitude = 0.001;
3470        let num_samples = reference.len();
3471        let mut recorded = Vec::with_capacity(num_samples);
3472        // Use deterministic "noise" (alternating small values)
3473        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        // For a disconnected channel, the transfer function should be very low
3488        // (noise / sweep ≈ noise floor). It must NOT show spurious high-dB peaks.
3489        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        // Simulate a 5.1 scenario: LFE uses a narrow sweep (20-500 Hz) while
3507        // main channels use the full range (20-20000 Hz). Both must produce
3508        // the same number of output frequency points to avoid ndarray shape
3509        // mismatches when curves are combined in the optimizer.
3510        let sample_rate = 48000;
3511        let duration = 1.0;
3512
3513        // Full-range reference (main channel)
3514        let ref_full = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
3515        // Narrow reference (LFE)
3516        let ref_lfe = generate_test_sweep(20.0, 500.0, duration, sample_rate, 0.5);
3517
3518        // Simulate recordings: attenuated copies with delay
3519        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        // Both must produce the same number of frequency points
3545        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        // LFE should have valid data below ~500 Hz and noise floor above
3559        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        // Generate a narrowband probe, delay it, and verify detection
3588        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        // Simulate recording: delay by 240 samples (~5ms) + attenuation
3593        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        // Peak should be near the known delay
3603        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        // Arrival time should be ~5ms
3612        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        // Probe detection should work even with additive noise
3622        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; // 10ms
3627        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        // Add noise
3632        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        // Create synthetic IR: impulse at sample 0 + delayed impulse at sample 240 (5ms)
3650        // Direct window [0, 240) should show flat response
3651        // Early window [240, 1920) should show the reflection's response
3652        let sr = 48000;
3653        let mut ir = vec![0.0f32; 4096];
3654        ir[0] = 1.0; // direct sound
3655        ir[240] = 0.5; // reflection at 5ms, -6dB
3656
3657        let result = compute_windowed_fr(&ir, 240, 1920, sr, 200).unwrap();
3658
3659        // Direct window should have content
3660        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        // All frequency vectors should have the requested number of points
3665        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        // Time boundaries should match
3670        assert!((result.direct_end_ms - 5.0).abs() < 0.01);
3671        assert!((result.early_end_ms - 40.0).abs() < 0.01);
3672
3673        // Direct sound should be roughly flat above the resolution limit.
3674        // Short window = poor LF resolution, but mid-HF should be flat.
3675        // Filter to frequencies above 500 Hz where the 240-sample window has resolution
3676        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        // If early_end == direct_end (no early reflections), that window should be empty/silent
3698        let sr = 48000;
3699        let mut ir = vec![0.0f32; 2048];
3700        // Place impulse away from window edges so fading doesn't zero it out
3701        ir[50] = 1.0;
3702
3703        let result = compute_windowed_fr(&ir, 200, 200, sr, 200).unwrap();
3704
3705        // Early reflections window is zero-length — SPL should be very low
3706        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        // Direct and late should still have content
3716        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        // Issue #6: for a low start_freq the old hardcoded 256-sample floor
3730        // could be far smaller than 3 periods of the harmonic.  With the fix
3731        // the window must be at least 3 periods.
3732        let sr = 48000.0f32;
3733        let start_freq = 20.0f32;
3734        let end_freq = 20000.0f32;
3735        let duration = 10.0f32; // 10-second sweep
3736        let n = 65536usize;
3737        let mut ir = vec![0.0f32; n];
3738        // Synthetic peak at the centre
3739        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        // Should complete without panicking; THD of a pure impulse is high
3746        // because there is no harmonic structure, but the point is that the
3747        // window sizing does not overflow or underflow.
3748        assert!(
3749            thd[0] >= 0.0 && thd[0] <= 100.0,
3750            "THD should be in [0, 100], got {}",
3751            thd[0]
3752        );
3753    }
3754}