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
27fn plan_fft_forward(size: usize) -> Arc<dyn rustfft::Fft<f32>> {
28    FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_forward(size))
29}
30
31fn plan_fft_inverse(size: usize) -> Arc<dyn rustfft::Fft<f32>> {
32    FFT_PLANNER.with(|p| p.borrow_mut().plan_fft_inverse(size))
33}
34
35/// Microphone compensation data (frequency response correction)
36#[derive(Debug, Clone)]
37pub struct MicrophoneCompensation {
38    /// Frequency points in Hz
39    pub frequencies: Vec<f32>,
40    /// SPL deviation in dB (positive = mic is louder, negative = mic is quieter)
41    pub spl_db: Vec<f32>,
42}
43
44impl MicrophoneCompensation {
45    /// Apply pre-compensation to a sweep signal
46    ///
47    /// For log sweeps, this modulates the amplitude based on the instantaneous frequency
48    /// to pre-compensate for the microphone's response.
49    ///
50    /// # Arguments
51    /// * `signal` - The sweep signal to compensate
52    /// * `start_freq` - Start frequency of the sweep in Hz
53    /// * `end_freq` - End frequency of the sweep in Hz
54    /// * `sample_rate` - Sample rate in Hz
55    /// * `inverse` - If true, applies inverse compensation (boost where mic is weak)
56    ///
57    /// # Returns
58    /// Pre-compensated signal
59    pub fn apply_to_sweep(
60        &self,
61        signal: &[f32],
62        start_freq: f32,
63        end_freq: f32,
64        sample_rate: u32,
65        inverse: bool,
66    ) -> Vec<f32> {
67        let duration = signal.len() as f32 / sample_rate as f32;
68        let mut compensated = Vec::with_capacity(signal.len());
69
70        // Debug: print some sample points
71        let debug_points = [0, signal.len() / 4, signal.len() / 2, 3 * signal.len() / 4];
72
73        for (i, &sample) in signal.iter().enumerate() {
74            let t = i as f32 / sample_rate as f32;
75
76            // Compute instantaneous frequency for log sweep
77            // f(t) = f0 * exp(t * ln(f1/f0) / T)
78            let freq = start_freq * ((t * (end_freq / start_freq).ln()) / duration).exp();
79
80            // Get compensation at this frequency (in dB)
81            let comp_db = self.interpolate_at(freq);
82
83            // Apply inverse or direct compensation
84            let gain_db = if inverse { -comp_db } else { comp_db };
85
86            // Convert dB to linear gain
87            let gain = 10_f32.powf(gain_db / 20.0);
88
89            // Debug output for sample points
90            if debug_points.contains(&i) {
91                log::debug!(
92                    "[apply_to_sweep] t={:.3}s, freq={:.1}Hz, comp_db={:.2}dB, gain_db={:.2}dB, gain={:.3}x",
93                    t,
94                    freq,
95                    comp_db,
96                    gain_db,
97                    gain
98                );
99            }
100
101            compensated.push(sample * gain);
102        }
103
104        log::debug!(
105            "[apply_to_sweep] Processed {} samples, duration={:.2}s",
106            signal.len(),
107            duration
108        );
109        compensated
110    }
111
112    /// Load microphone compensation from a CSV or TXT file
113    ///
114    /// File format:
115    /// - CSV: frequency_hz,spl_db (with or without header, comma-separated)
116    /// - TXT: freq spl (space/tab-separated, no header assumed)
117    pub fn from_file(path: &Path) -> Result<Self, String> {
118        use std::fs::File;
119        use std::io::{BufRead, BufReader};
120
121        log::debug!("[MicrophoneCompensation] Loading from {:?}", path);
122
123        let file = File::open(path)
124            .map_err(|e| format!("Failed to open compensation file {:?}: {}", path, e))?;
125        let reader = BufReader::new(file);
126
127        // Determine if this is a .txt file (no header expected)
128        let is_txt_file = path
129            .extension()
130            .and_then(|e| e.to_str())
131            .map(|e| e.to_lowercase() == "txt")
132            .unwrap_or(false);
133
134        if is_txt_file {
135            log::info!(
136                "[MicrophoneCompensation] Detected .txt file - assuming space/tab-separated without header"
137            );
138        }
139
140        let mut frequencies = Vec::new();
141        let mut spl_db = Vec::new();
142
143        for (line_num, line) in reader.lines().enumerate() {
144            let line = line.map_err(|e| format!("Failed to read line {}: {}", line_num + 1, e))?;
145            let line = line.trim();
146
147            // Skip empty lines and comments
148            if line.is_empty() || line.starts_with('#') {
149                continue;
150            }
151
152            // For CSV files, skip header line
153            if !is_txt_file && line.starts_with("frequency") {
154                continue;
155            }
156
157            // For TXT files, skip lines that don't start with a number
158            if is_txt_file {
159                let first_char = line.chars().next().unwrap_or(' ');
160                if !first_char.is_ascii_digit() && first_char != '-' && first_char != '+' {
161                    log::info!(
162                        "[MicrophoneCompensation] Skipping non-numeric line {}: '{}'",
163                        line_num + 1,
164                        line
165                    );
166                    continue;
167                }
168            }
169
170            // Parse based on file type with auto-detection for TXT
171            let parts: Vec<&str> = if is_txt_file {
172                // TXT: Try to auto-detect separator
173                // First, try comma (in case it's mislabeled CSV)
174                let comma_parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
175                if comma_parts.len() >= 2
176                    && comma_parts[0].parse::<f32>().is_ok()
177                    && comma_parts[1].parse::<f32>().is_ok()
178                {
179                    comma_parts
180                } else {
181                    // Try tab
182                    let tab_parts: Vec<&str> = line.split('\t').map(|s| s.trim()).collect();
183                    if tab_parts.len() >= 2
184                        && tab_parts[0].parse::<f32>().is_ok()
185                        && tab_parts[1].parse::<f32>().is_ok()
186                    {
187                        tab_parts
188                    } else {
189                        // Fall back to whitespace
190                        line.split_whitespace().collect()
191                    }
192                }
193            } else {
194                // CSV: comma separated
195                line.split(',').collect()
196            };
197
198            if parts.len() < 2 {
199                let separator = if is_txt_file {
200                    "separator (comma/tab/space)"
201                } else {
202                    "comma"
203                };
204                return Err(format!(
205                    "Invalid format at line {}: expected {} with 2+ values but got '{}'",
206                    line_num + 1,
207                    separator,
208                    line
209                ));
210            }
211
212            let freq: f32 = parts[0]
213                .trim()
214                .parse()
215                .map_err(|e| format!("Invalid frequency at line {}: {}", line_num + 1, e))?;
216            let spl: f32 = parts[1]
217                .trim()
218                .parse()
219                .map_err(|e| format!("Invalid SPL at line {}: {}", line_num + 1, e))?;
220
221            frequencies.push(freq);
222            spl_db.push(spl);
223        }
224
225        if frequencies.is_empty() {
226            return Err(format!("No compensation data found in {:?}", path));
227        }
228
229        // Validate that frequencies are sorted
230        for i in 1..frequencies.len() {
231            if frequencies[i] <= frequencies[i - 1] {
232                return Err(format!(
233                    "Frequencies must be strictly increasing: found {} after {} at line {}",
234                    frequencies[i],
235                    frequencies[i - 1],
236                    i + 1
237                ));
238            }
239        }
240
241        log::info!(
242            "[MicrophoneCompensation] Loaded {} calibration points: {:.1} Hz - {:.1} Hz",
243            frequencies.len(),
244            frequencies[0],
245            frequencies[frequencies.len() - 1]
246        );
247        log::info!(
248            "[MicrophoneCompensation] SPL range: {:.2} dB to {:.2} dB",
249            spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b)),
250            spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b))
251        );
252
253        Ok(Self {
254            frequencies,
255            spl_db,
256        })
257    }
258
259    /// Interpolate compensation value at a given frequency
260    ///
261    /// Uses linear interpolation in dB domain.
262    /// Returns 0.0 for frequencies outside the calibration range.
263    pub fn interpolate_at(&self, freq: f32) -> f32 {
264        if freq < self.frequencies[0] || freq > self.frequencies[self.frequencies.len() - 1] {
265            // Outside calibration range - no compensation
266            return 0.0;
267        }
268
269        // Find the two nearest points
270        let idx = match self
271            .frequencies
272            .binary_search_by(|f| f.partial_cmp(&freq).unwrap_or(std::cmp::Ordering::Equal))
273        {
274            Ok(i) => return self.spl_db[i], // Exact match
275            Err(i) => i,
276        };
277
278        if idx == 0 {
279            return self.spl_db[0];
280        }
281        if idx >= self.frequencies.len() {
282            return self.spl_db[self.frequencies.len() - 1];
283        }
284
285        // Linear interpolation
286        let f0 = self.frequencies[idx - 1];
287        let f1 = self.frequencies[idx];
288        let s0 = self.spl_db[idx - 1];
289        let s1 = self.spl_db[idx];
290
291        let t = (freq - f0) / (f1 - f0);
292        s0 + t * (s1 - s0)
293    }
294}
295
296// ============================================================================
297// WAV Buffer Analysis (wav2csv functionality)
298// ============================================================================
299
300/// Configuration for standalone WAV buffer analysis
301#[derive(Debug, Clone)]
302pub struct WavAnalysisConfig {
303    /// Number of output frequency points (default: 2000)
304    pub num_points: usize,
305    /// Minimum frequency in Hz (default: 20)
306    pub min_freq: f32,
307    /// Maximum frequency in Hz (default: 20000)
308    pub max_freq: f32,
309    /// FFT size (if None, auto-computed based on signal length and mode)
310    pub fft_size: Option<usize>,
311    /// Window overlap ratio for Welch's method (0.0-1.0, default: 0.5)
312    pub overlap: f32,
313    /// Use single FFT instead of Welch's method (better for sweeps and impulse responses)
314    pub single_fft: bool,
315    /// Apply pink compensation (-3dB/octave) for log sweeps
316    pub pink_compensation: bool,
317    /// Use rectangular window instead of Hann
318    pub no_window: bool,
319}
320
321impl Default for WavAnalysisConfig {
322    fn default() -> Self {
323        Self {
324            num_points: 2000,
325            min_freq: 20.0,
326            max_freq: 20000.0,
327            fft_size: None,
328            overlap: 0.5,
329            single_fft: false,
330            pink_compensation: false,
331            no_window: false,
332        }
333    }
334}
335
336impl WavAnalysisConfig {
337    /// Create config optimized for log sweep analysis
338    pub fn for_log_sweep() -> Self {
339        Self {
340            single_fft: true,
341            pink_compensation: true,
342            no_window: true,
343            ..Default::default()
344        }
345    }
346
347    /// Create config optimized for impulse response analysis
348    pub fn for_impulse_response() -> Self {
349        Self {
350            single_fft: true,
351            ..Default::default()
352        }
353    }
354
355    /// Create config for stationary signals (music, noise)
356    pub fn for_stationary() -> Self {
357        Self::default()
358    }
359}
360
361/// Result of standalone WAV buffer analysis
362#[derive(Debug, Clone)]
363pub struct WavAnalysisOutput {
364    /// Frequency points in Hz (log-spaced)
365    pub frequencies: Vec<f32>,
366    /// Magnitude in dB
367    pub magnitude_db: Vec<f32>,
368    /// Phase in degrees
369    pub phase_deg: Vec<f32>,
370}
371
372/// Analyze a buffer of audio samples and return frequency response
373///
374/// # Arguments
375/// * `samples` - Mono audio samples (f32, -1.0 to 1.0)
376/// * `sample_rate` - Sample rate in Hz
377/// * `config` - Analysis configuration
378///
379/// # Returns
380/// Analysis result with frequency, magnitude, and phase data
381pub fn analyze_wav_buffer(
382    samples: &[f32],
383    sample_rate: u32,
384    config: &WavAnalysisConfig,
385) -> Result<WavAnalysisOutput, String> {
386    if samples.is_empty() {
387        return Err("Signal is empty".to_string());
388    }
389
390    // Determine FFT size
391    let fft_size = if config.single_fft {
392        config
393            .fft_size
394            .unwrap_or_else(|| wav_next_power_of_two(samples.len()))
395    } else {
396        config.fft_size.unwrap_or(16384)
397    };
398
399    // Compute spectrum
400    let (freqs, magnitudes_db, phases_deg) = if config.single_fft {
401        compute_single_fft_spectrum_internal(samples, sample_rate, fft_size, config.no_window)?
402    } else {
403        compute_welch_spectrum_internal(samples, sample_rate, fft_size, config.overlap)?
404    };
405
406    // Generate logarithmically spaced frequency points
407    let log_freqs = generate_log_frequencies(config.num_points, config.min_freq, config.max_freq);
408
409    // Interpolate magnitude and phase at log frequencies
410    let mut interp_mag = interpolate_log(&freqs, &magnitudes_db, &log_freqs);
411    let interp_phase = interpolate_log_phase(&freqs, &phases_deg, &log_freqs);
412
413    // Apply pink compensation if requested (for log sweeps)
414    if config.pink_compensation {
415        let ref_freq = 1000.0;
416        for (i, freq) in log_freqs.iter().enumerate() {
417            if *freq > 0.0 {
418                let correction = 10.0 * (freq / ref_freq).log10();
419                interp_mag[i] += correction;
420            }
421        }
422    }
423
424    Ok(WavAnalysisOutput {
425        frequencies: log_freqs,
426        magnitude_db: interp_mag,
427        phase_deg: interp_phase,
428    })
429}
430
431/// Analyze a WAV file and return frequency response
432///
433/// # Arguments
434/// * `path` - Path to WAV file
435/// * `config` - Analysis configuration
436///
437/// # Returns
438/// Analysis result with frequency, magnitude, and phase data
439pub fn analyze_wav_file(
440    path: &Path,
441    config: &WavAnalysisConfig,
442) -> Result<WavAnalysisOutput, String> {
443    let (samples, sample_rate) = load_wav_mono_with_rate(path)?;
444    analyze_wav_buffer(&samples, sample_rate, config)
445}
446
447/// Load WAV file as mono and return samples with sample rate
448fn load_wav_mono_with_rate(path: &Path) -> Result<(Vec<f32>, u32), String> {
449    let mut reader =
450        WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
451
452    let spec = reader.spec();
453    let sample_rate = spec.sample_rate;
454    let channels = spec.channels as usize;
455
456    let samples: Result<Vec<f32>, _> = match spec.sample_format {
457        hound::SampleFormat::Float => reader.samples::<f32>().collect(),
458        hound::SampleFormat::Int => {
459            let max_val = (1_i64 << (spec.bits_per_sample - 1)) as f32;
460            reader
461                .samples::<i32>()
462                .map(|s| s.map(|v| v as f32 / max_val))
463                .collect()
464        }
465    };
466
467    let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
468
469    // Convert to mono by averaging channels
470    let mono = if channels == 1 {
471        samples
472    } else {
473        samples
474            .chunks(channels)
475            .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
476            .collect()
477    };
478
479    Ok((mono, sample_rate))
480}
481
482/// Write WAV analysis result to CSV file
483///
484/// # Arguments
485/// * `result` - Analysis output
486/// * `path` - Path to output CSV file
487pub fn write_wav_analysis_csv(result: &WavAnalysisOutput, path: &Path) -> Result<(), String> {
488    let mut file =
489        std::fs::File::create(path).map_err(|e| format!("Failed to create CSV: {}", e))?;
490
491    writeln!(file, "frequency_hz,spl_db,phase_deg")
492        .map_err(|e| format!("Failed to write CSV header: {}", e))?;
493
494    for i in 0..result.frequencies.len() {
495        writeln!(
496            file,
497            "{:.2},{:.2},{:.2}",
498            result.frequencies[i], result.magnitude_db[i], result.phase_deg[i]
499        )
500        .map_err(|e| format!("Failed to write CSV row: {}", e))?;
501    }
502
503    Ok(())
504}
505
506/// Compute spectrum using Welch's method (averaged periodograms) - internal version
507fn compute_welch_spectrum_internal(
508    signal: &[f32],
509    sample_rate: u32,
510    fft_size: usize,
511    overlap: f32,
512) -> SpectrumResult {
513    if signal.is_empty() {
514        return Err("Signal is empty".to_string());
515    }
516
517    let overlap_samples = (fft_size as f32 * overlap.clamp(0.0, 0.95)) as usize;
518    let hop_size = fft_size - overlap_samples;
519
520    let num_windows = if signal.len() >= fft_size {
521        1 + (signal.len() - fft_size) / hop_size
522    } else {
523        1
524    };
525
526    let num_bins = fft_size / 2;
527    let mut magnitude_sum = vec![0.0_f32; num_bins];
528    let mut phase_real_sum = vec![0.0_f32; num_bins];
529    let mut phase_imag_sum = vec![0.0_f32; num_bins];
530
531    // Precompute Hann window
532    let hann_window: Vec<f32> = (0..fft_size)
533        .map(|i| 0.5 * (1.0 - ((2.0 * PI * i as f32) / (fft_size as f32 - 1.0)).cos()))
534        .collect();
535
536    let window_power: f32 = hann_window.iter().map(|&w| w * w).sum();
537    let scale_factor = 2.0 / window_power;
538
539    let fft = plan_fft_forward(fft_size);
540
541    let mut windowed = vec![0.0_f32; fft_size];
542    let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
543
544    for window_idx in 0..num_windows {
545        let start = window_idx * hop_size;
546        let end = (start + fft_size).min(signal.len());
547        let window_len = end - start;
548
549        // Apply window
550        for i in 0..window_len {
551            windowed[i] = signal[start + i] * hann_window[i];
552        }
553        // Zero-pad the rest if necessary
554        windowed[window_len..fft_size].fill(0.0);
555
556        // Convert to complex
557        for (i, &val) in windowed.iter().enumerate() {
558            buffer[i] = Complex::new(val, 0.0);
559        }
560
561        fft.process(&mut buffer);
562
563        for i in 0..num_bins {
564            let mag = buffer[i].norm() * scale_factor.sqrt();
565            magnitude_sum[i] += mag * mag;
566            phase_real_sum[i] += buffer[i].re;
567            phase_imag_sum[i] += buffer[i].im;
568        }
569    }
570
571    let magnitudes_db: Vec<f32> = magnitude_sum
572        .iter()
573        .map(|&mag_sq| {
574            let mag = (mag_sq / num_windows as f32).sqrt();
575            if mag > 1e-10 {
576                20.0 * mag.log10()
577            } else {
578                -200.0
579            }
580        })
581        .collect();
582
583    let phases_deg: Vec<f32> = phase_real_sum
584        .iter()
585        .zip(phase_imag_sum.iter())
586        .map(|(&re, &im)| (im / num_windows as f32).atan2(re / num_windows as f32) * 180.0 / PI)
587        .collect();
588
589    let freqs: Vec<f32> = (0..num_bins)
590        .map(|i| i as f32 * sample_rate as f32 / fft_size as f32)
591        .collect();
592
593    Ok((freqs, magnitudes_db, phases_deg))
594}
595
596/// Compute spectrum using a single FFT - internal version
597fn compute_single_fft_spectrum_internal(
598    signal: &[f32],
599    sample_rate: u32,
600    fft_size: usize,
601    no_window: bool,
602) -> SpectrumResult {
603    if signal.is_empty() {
604        return Err("Signal is empty".to_string());
605    }
606
607    let mut windowed = vec![0.0_f32; fft_size];
608    let copy_len = signal.len().min(fft_size);
609    windowed[..copy_len].copy_from_slice(&signal[..copy_len]);
610
611    let window_scale_factor = if no_window {
612        1.0
613    } else {
614        let hann_window: Vec<f32> = (0..fft_size)
615            .map(|i| 0.5 * (1.0 - ((2.0 * PI * i as f32) / (fft_size as f32 - 1.0)).cos()))
616            .collect();
617
618        for (i, sample) in windowed.iter_mut().enumerate() {
619            *sample *= hann_window[i];
620        }
621
622        hann_window.iter().map(|&w| w * w).sum::<f32>()
623    };
624
625    let mut buffer: Vec<Complex<f32>> = windowed.iter().map(|&x| Complex::new(x, 0.0)).collect();
626
627    let fft = plan_fft_forward(fft_size);
628    fft.process(&mut buffer);
629
630    let scale_factor = if no_window {
631        (2.0 / fft_size as f32).sqrt()
632    } else {
633        (2.0 / window_scale_factor).sqrt()
634    };
635
636    let num_bins = fft_size / 2;
637    let magnitudes_db: Vec<f32> = buffer[..num_bins]
638        .iter()
639        .map(|c| {
640            let mag = c.norm() * scale_factor;
641            if mag > 1e-10 {
642                20.0 * mag.log10()
643            } else {
644                -200.0
645            }
646        })
647        .collect();
648
649    let phases_deg: Vec<f32> = buffer[..num_bins]
650        .iter()
651        .map(|c| c.arg() * 180.0 / PI)
652        .collect();
653
654    let freqs: Vec<f32> = (0..num_bins)
655        .map(|i| i as f32 * sample_rate as f32 / fft_size as f32)
656        .collect();
657
658    Ok((freqs, magnitudes_db, phases_deg))
659}
660
661/// Next power of two for wav analysis (capped at 1M)
662fn wav_next_power_of_two(n: usize) -> usize {
663    let mut p = 1;
664    while p < n {
665        p *= 2;
666    }
667    p.min(1048576)
668}
669
670/// Generate logarithmically spaced frequencies
671fn generate_log_frequencies(num_points: usize, min_freq: f32, max_freq: f32) -> Vec<f32> {
672    let log_min = min_freq.ln();
673    let log_max = max_freq.ln();
674    let step = (log_max - log_min) / (num_points - 1) as f32;
675
676    (0..num_points)
677        .map(|i| (log_min + i as f32 * step).exp())
678        .collect()
679}
680
681/// Logarithmic interpolation
682fn interpolate_log(x: &[f32], y: &[f32], x_new: &[f32]) -> Vec<f32> {
683    x_new
684        .iter()
685        .map(|&freq| {
686            let idx = x.partition_point(|&f| f < freq).min(x.len() - 1);
687
688            if idx == 0 {
689                return y[0];
690            }
691
692            let x0 = x[idx - 1];
693            let x1 = x[idx];
694            let y0 = y[idx - 1];
695            let y1 = y[idx];
696
697            if x1 <= x0 {
698                return y0;
699            }
700
701            let t = (freq - x0) / (x1 - x0);
702            y0 + t * (y1 - y0)
703        })
704        .collect()
705}
706
707/// Logarithmic interpolation for phase data (degrees).
708/// Uses circular interpolation to correctly handle ±180° wrap boundaries.
709fn interpolate_log_phase(x: &[f32], phase_deg: &[f32], x_new: &[f32]) -> Vec<f32> {
710    x_new
711        .iter()
712        .map(|&freq| {
713            let idx = x.partition_point(|&f| f < freq).min(x.len() - 1);
714
715            if idx == 0 {
716                return phase_deg[0];
717            }
718
719            let x0 = x[idx - 1];
720            let x1 = x[idx];
721
722            if x1 <= x0 {
723                return phase_deg[idx - 1];
724            }
725
726            let t = (freq - x0) / (x1 - x0);
727
728            // Circular interpolation: find shortest arc between the two angles
729            let p0 = phase_deg[idx - 1];
730            let p1 = phase_deg[idx];
731            let mut diff = p1 - p0;
732            // Wrap diff to [-180, 180]
733            diff -= 360.0 * (diff / 360.0).round();
734            p0 + t * diff
735        })
736        .collect()
737}
738
739// ============================================================================
740// Recording Analysis (reference vs recorded comparison)
741// ============================================================================
742
743/// Result of FFT analysis
744#[derive(Debug, Clone)]
745pub struct AnalysisResult {
746    /// Frequency bins in Hz
747    pub frequencies: Vec<f32>,
748    /// Magnitude in dBFS
749    pub spl_db: Vec<f32>,
750    /// Phase in degrees (compensated for latency)
751    pub phase_deg: Vec<f32>,
752    /// Estimated latency in samples
753    pub estimated_lag_samples: isize,
754    /// Impulse response (time domain)
755    pub impulse_response: Vec<f32>,
756    /// Time vector for impulse response in ms
757    pub impulse_time_ms: Vec<f32>,
758    /// Excess group delay in ms
759    pub excess_group_delay_ms: Vec<f32>,
760    /// Total Harmonic Distortion + Noise (%)
761    pub thd_percent: Vec<f32>,
762    /// Harmonic distortion curves (2nd, 3rd, etc) in dB
763    pub harmonic_distortion_db: Vec<Vec<f32>>,
764    /// RT60 decay time in ms
765    pub rt60_ms: Vec<f32>,
766    /// Clarity C50 in dB
767    pub clarity_c50_db: Vec<f32>,
768    /// Clarity C80 in dB
769    pub clarity_c80_db: Vec<f32>,
770    /// Spectrogram (Time x Freq magnitude in dB)
771    pub spectrogram_db: Vec<Vec<f32>>,
772}
773
774/// Analyze a recorded WAV file against a reference signal
775///
776/// # Arguments
777/// * `recorded_path` - Path to the recorded WAV file
778/// * `reference_signal` - Reference signal (should match the signal used for playback)
779/// * `sample_rate` - Sample rate in Hz
780/// * `sweep_range` - Optional (start_freq, end_freq) if the signal is a log sweep
781///
782/// # Returns
783/// Analysis result with frequency, SPL, and phase data
784pub fn analyze_recording(
785    recorded_path: &Path,
786    reference_signal: &[f32],
787    sample_rate: u32,
788    sweep_range: Option<(f32, f32)>,
789) -> Result<AnalysisResult, String> {
790    // Load recorded WAV
791    log::debug!("[FFT Analysis] Loading recorded file: {:?}", recorded_path);
792    let recorded = load_wav_mono(recorded_path)?;
793    log::debug!(
794        "[FFT Analysis] Loaded {} samples from recording",
795        recorded.len()
796    );
797    log::debug!(
798        "[FFT Analysis] Reference has {} samples",
799        reference_signal.len()
800    );
801
802    if recorded.is_empty() {
803        return Err("Recorded signal is empty!".to_string());
804    }
805    if reference_signal.is_empty() {
806        return Err("Reference signal is empty!".to_string());
807    }
808
809    // Don't truncate yet - we need full signals for lag estimation
810    let recorded = &recorded[..];
811    let reference = reference_signal;
812
813    // Debug: Check signal statistics (guarded to skip O(n) computation when disabled)
814    if log::log_enabled!(log::Level::Debug) {
815        let ref_max = reference
816            .iter()
817            .map(|&x| x.abs())
818            .fold(0.0_f32, |a, b| a.max(b));
819        let rec_max = recorded
820            .iter()
821            .map(|&x| x.abs())
822            .fold(0.0_f32, |a, b| a.max(b));
823        let ref_rms =
824            (reference.iter().map(|&x| x * x).sum::<f32>() / reference.len() as f32).sqrt();
825        let rec_rms = (recorded.iter().map(|&x| x * x).sum::<f32>() / recorded.len() as f32).sqrt();
826
827        log::debug!(
828            "[FFT Analysis] Reference: max={:.4}, RMS={:.4}",
829            ref_max,
830            ref_rms
831        );
832        log::debug!(
833            "[FFT Analysis] Recorded:  max={:.4}, RMS={:.4}",
834            rec_max,
835            rec_rms
836        );
837        log::debug!(
838            "[FFT Analysis] First 5 reference samples: {:?}",
839            &reference[..5.min(reference.len())]
840        );
841        log::debug!(
842            "[FFT Analysis] First 5 recorded samples:  {:?}",
843            &recorded[..5.min(recorded.len())]
844        );
845
846        let check_len = reference.len().min(recorded.len());
847        let mut identical_count = 0;
848        for (r, c) in reference[..check_len]
849            .iter()
850            .zip(recorded[..check_len].iter())
851        {
852            if (r - c).abs() < 1e-6 {
853                identical_count += 1;
854            }
855        }
856        log::debug!(
857            "[FFT Analysis] Identical samples: {}/{} ({:.1}%)",
858            identical_count,
859            check_len,
860            identical_count as f32 * 100.0 / check_len as f32
861        );
862    }
863
864    // Estimate lag using cross-correlation
865    let lag = estimate_lag(reference, recorded)?;
866
867    log::debug!(
868        "[FFT Analysis] Estimated lag: {} samples ({:.2} ms)",
869        lag,
870        lag as f32 * 1000.0 / sample_rate as f32
871    );
872
873    // Time-align the signals before FFT
874    // If recorded is delayed (positive lag), skip the lag samples in recorded
875    let (aligned_ref, aligned_rec) = if lag >= 0 {
876        let lag_usize = lag as usize;
877        if lag_usize >= recorded.len() {
878            return Err("Lag is larger than recorded signal length".to_string());
879        }
880        // Capture full tail
881        (reference, &recorded[lag_usize..])
882    } else {
883        // Recorded leads reference - rare
884        let lag_usize = (-lag) as usize;
885        if lag_usize >= reference.len() {
886            return Err("Negative lag is larger than reference signal length".to_string());
887        }
888        // Pad reference start? No, just slice reference
889        (&reference[lag_usize..], recorded)
890    };
891
892    log::debug!(
893        "[FFT Analysis] Aligned lengths: ref={}, rec={} (tail included)",
894        aligned_ref.len(),
895        aligned_rec.len()
896    );
897
898    // Compute FFT size to include the longer of the two (usually rec with tail)
899    let fft_size = next_power_of_two(aligned_ref.len().max(aligned_rec.len()));
900
901    let ref_spectrum = compute_fft(aligned_ref, fft_size, WindowType::Tukey(0.1))?;
902    let rec_spectrum = compute_fft(aligned_rec, fft_size, WindowType::Tukey(0.1))?;
903
904    // Generate 2000 log-spaced frequency points between 20 Hz and 20 kHz
905    let num_output_points = 2000;
906    let log_start = 20.0_f32.ln();
907    let log_end = 20000.0_f32.ln();
908
909    let mut frequencies = Vec::with_capacity(num_output_points);
910    let mut spl_db = Vec::with_capacity(num_output_points);
911    let mut phase_deg = Vec::with_capacity(num_output_points);
912
913    let freq_resolution = sample_rate as f32 / fft_size as f32;
914    let num_bins = fft_size / 2; // Single-sided spectrum
915
916    // Compute regularization threshold relative to the peak reference energy.
917    // Bins where the reference has very little energy (e.g., disconnected speaker
918    // with a misaligned sweep) produce unreliable transfer functions — division by
919    // near-zero gives spurious high-dB peaks. We skip bins where the reference
920    // energy is more than 60 dB below the peak.
921    let ref_peak_mag_sq = ref_spectrum[1..num_bins.min(ref_spectrum.len())]
922        .iter()
923        .map(|c| c.norm_sqr())
924        .fold(0.0_f32, |a, b| a.max(b));
925    // 60 dB below peak = 10^(-6) in power
926    let ref_regularization_threshold = ref_peak_mag_sq * 1e-6;
927
928    // Apply 1/24 octave smoothing for each target frequency
929    let mut skipped_count = 0;
930    for i in 0..num_output_points {
931        // Log-spaced target frequency
932        let target_freq =
933            (log_start + (log_end - log_start) * i as f32 / (num_output_points - 1) as f32).exp();
934
935        // 1/24 octave bandwidth: +/- 1/48 octave around target frequency
936        // Lower and upper frequency bounds: f * 2^(+/- 1/48)
937        let octave_fraction = 1.0 / 48.0;
938        let freq_lower = target_freq * 2.0_f32.powf(-octave_fraction);
939        let freq_upper = target_freq * 2.0_f32.powf(octave_fraction);
940
941        // Find FFT bins within this frequency range
942        let bin_lower = ((freq_lower / freq_resolution).floor() as usize).max(1);
943        let bin_upper = ((freq_upper / freq_resolution).ceil() as usize).min(num_bins);
944
945        if bin_lower > bin_upper || bin_upper >= ref_spectrum.len() {
946            if skipped_count < 5 {
947                log::debug!(
948                    "[FFT Analysis] Skipping freq {:.1} Hz: bin_lower={}, bin_upper={}, ref_spectrum.len()={}",
949                    target_freq,
950                    bin_lower,
951                    bin_upper,
952                    ref_spectrum.len()
953                );
954            }
955            skipped_count += 1;
956            // Output noise-floor placeholder so all channels produce the same
957            // number of frequency points (prevents ndarray shape mismatches).
958            frequencies.push(target_freq);
959            spl_db.push(-200.0);
960            phase_deg.push(0.0);
961            continue;
962        }
963
964        // Average transfer function magnitude and phase across bins in the smoothing range
965        let mut sum_magnitude = 0.0;
966        let mut sum_sin = 0.0; // For circular averaging of phase
967        let mut sum_cos = 0.0;
968        let mut bin_count = 0;
969
970        for k in bin_lower..=bin_upper {
971            if k >= ref_spectrum.len() {
972                break;
973            }
974
975            // Compute transfer function: H(f) = recorded / reference
976            // This gives the system response (for loopback, should be ~1.0 or 0 dB)
977            // Skip bins where the reference energy is too low (>60 dB below peak):
978            // dividing by near-zero produces unreliable, spuriously high values
979            // (e.g., disconnected speaker where the recording is just noise).
980            let ref_mag_sq = ref_spectrum[k].norm_sqr();
981            if ref_mag_sq <= ref_regularization_threshold {
982                continue;
983            }
984            let transfer_function = rec_spectrum[k] / ref_spectrum[k];
985            let magnitude = transfer_function.norm();
986
987            // Phase from cross-spectrum (signals are already time-aligned)
988            let cross_spectrum = ref_spectrum[k].conj() * rec_spectrum[k];
989            let phase_rad = cross_spectrum.arg();
990
991            // Accumulate for averaging
992            sum_magnitude += magnitude;
993            sum_sin += phase_rad.sin();
994            sum_cos += phase_rad.cos();
995            bin_count += 1;
996        }
997
998        // When no valid bins contribute (reference energy too low at this frequency,
999        // e.g., LFE sweep above 500 Hz), output a noise-floor value instead of skipping.
1000        // Skipping would produce fewer output points than other channels, causing
1001        // ndarray shape mismatches when curves are combined downstream.
1002        let (avg_magnitude, db) = if bin_count == 0 {
1003            (0.0, -200.0)
1004        } else {
1005            let avg = sum_magnitude / bin_count as f32;
1006            (avg, 20.0 * avg.max(1e-10).log10())
1007        };
1008
1009        if frequencies.len() < 5 {
1010            log::debug!(
1011                "[FFT Analysis] freq={:.1} Hz: avg_magnitude={:.6}, dB={:.2}",
1012                target_freq,
1013                avg_magnitude,
1014                db
1015            );
1016        }
1017
1018        // Average phase using circular mean
1019        let avg_phase_rad = sum_sin.atan2(sum_cos);
1020        let phase = avg_phase_rad * 180.0 / PI;
1021
1022        frequencies.push(target_freq);
1023        spl_db.push(db);
1024        phase_deg.push(phase);
1025    }
1026
1027    log::debug!(
1028        "[FFT Analysis] Generated {} frequency points for CSV output",
1029        frequencies.len()
1030    );
1031    log::debug!(
1032        "[FFT Analysis] Skipped {} frequency points (out of {})",
1033        skipped_count,
1034        num_output_points
1035    );
1036
1037    if log::log_enabled!(log::Level::Debug) && !spl_db.is_empty() {
1038        let min_spl = spl_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1039        let max_spl = spl_db.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1040        log::debug!(
1041            "[FFT Analysis] SPL range: {:.2} dB to {:.2} dB",
1042            min_spl,
1043            max_spl
1044        );
1045    }
1046
1047    // --- Compute Impulse Response ---
1048    // H(f) = Recorded(f) / Reference(f)
1049    let mut transfer_function = vec![Complex::new(0.0, 0.0); fft_size];
1050    for k in 0..fft_size {
1051        // Handle DC and Nyquist specially if needed, but for complex FFT it's just bins
1052        // Avoid division by zero
1053        let ref_mag_sq = ref_spectrum[k].norm_sqr();
1054        if ref_mag_sq > 1e-20 {
1055            transfer_function[k] = rec_spectrum[k] / ref_spectrum[k];
1056        }
1057    }
1058
1059    // IFFT to get Impulse Response
1060    let ifft = plan_fft_inverse(fft_size);
1061    ifft.process(&mut transfer_function);
1062
1063    // Normalize and take real part (input was real, so output should be real-ish)
1064    // Scale by 1.0/N is done by IFFT? rustfft typically does NOT scale.
1065    // Standard IFFT definition: sum(X[k] * exp(...)) / N?
1066    // RustFFT inverse is unnormalized sum. So we divide by N.
1067    let norm = 1.0 / fft_size as f32;
1068    let mut impulse_response: Vec<f32> = transfer_function.iter().map(|c| c.re * norm).collect();
1069
1070    // Find the peak and shift the IR so the peak is near the beginning
1071    // This is necessary because the IFFT result has the peak at an arbitrary position
1072    // due to the phase of the transfer function (system latency)
1073    let peak_idx = impulse_response
1074        .iter()
1075        .enumerate()
1076        .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1077        .map(|(i, _)| i)
1078        .unwrap_or(0);
1079
1080    // Shift the IR so peak is at a small offset (e.g., 5ms for pre-ringing visibility)
1081    let pre_ring_samples = (0.005 * sample_rate as f32) as usize; // 5ms pre-ring buffer
1082    let shift_amount = peak_idx.saturating_sub(pre_ring_samples);
1083
1084    if shift_amount > 0 {
1085        impulse_response.rotate_left(shift_amount);
1086        log::info!(
1087            "[FFT Analysis] IR peak was at index {}, shifted by {} samples to put peak near beginning",
1088            peak_idx,
1089            shift_amount
1090        );
1091    }
1092
1093    // Generate time vector for IR (0 to duration)
1094    let _ir_duration_sec = fft_size as f32 / sample_rate as f32;
1095    let impulse_time_ms: Vec<f32> = (0..fft_size)
1096        .map(|i| i as f32 / sample_rate as f32 * 1000.0)
1097        .collect();
1098
1099    // --- Compute THD if sweep range is provided ---
1100    let (thd_percent, harmonic_distortion_db) = if let Some((start, end)) = sweep_range {
1101        // Assume sweep duration is same as impulse length (circular convolution)
1102        // or derived from reference signal length
1103        let duration = reference_signal.len() as f32 / sample_rate as f32;
1104        compute_thd_from_ir(
1105            &impulse_response,
1106            sample_rate as f32,
1107            &frequencies,
1108            &spl_db,
1109            start,
1110            end,
1111            duration,
1112        )
1113    } else {
1114        (vec![0.0; frequencies.len()], Vec::new())
1115    };
1116
1117    // --- Compute Excess Group Delay ---
1118    // (Placeholder)
1119    let excess_group_delay_ms = vec![0.0; frequencies.len()];
1120
1121    // --- Compute Acoustic Metrics ---
1122    // Debug: Log impulse response stats
1123    let ir_max = impulse_response.iter().fold(0.0f32, |a, &b| a.max(b.abs()));
1124    let ir_len = impulse_response.len();
1125    log::info!(
1126        "[Analysis] Impulse response: len={}, max_abs={:.6}, sample_rate={}",
1127        ir_len,
1128        ir_max,
1129        sample_rate
1130    );
1131
1132    let rt60_ms = compute_rt60_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1133    let (clarity_c50_db, clarity_c80_db) =
1134        compute_clarity_spectrum(&impulse_response, sample_rate as f32, &frequencies);
1135
1136    // Debug: Log computed metrics
1137    if !rt60_ms.is_empty() {
1138        let rt60_min = rt60_ms.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1139        let rt60_max = rt60_ms.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1140        log::info!(
1141            "[Analysis] RT60 range: {:.1} - {:.1} ms",
1142            rt60_min,
1143            rt60_max
1144        );
1145    }
1146    if !clarity_c50_db.is_empty() {
1147        let c50_min = clarity_c50_db.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1148        let c50_max = clarity_c50_db
1149            .iter()
1150            .fold(f32::NEG_INFINITY, |a, &b| a.max(b));
1151        log::info!(
1152            "[Analysis] Clarity C50 range: {:.1} - {:.1} dB",
1153            c50_min,
1154            c50_max
1155        );
1156    }
1157
1158    // Compute Spectrogram
1159    let (spectrogram_db, _, _) =
1160        compute_spectrogram(&impulse_response, sample_rate as f32, 512, 128);
1161
1162    Ok(AnalysisResult {
1163        frequencies,
1164        spl_db,
1165        phase_deg,
1166        estimated_lag_samples: lag,
1167        impulse_response,
1168        impulse_time_ms,
1169        excess_group_delay_ms,
1170        thd_percent,
1171        harmonic_distortion_db,
1172        rt60_ms,
1173        clarity_c50_db,
1174        clarity_c80_db,
1175        spectrogram_db,
1176    })
1177}
1178
1179/// Compute Total Harmonic Distortion (THD) from Impulse Response
1180///
1181/// Uses Farina's method to extract harmonics from the impulse response of a log sweep.
1182fn compute_thd_from_ir(
1183    impulse: &[f32],
1184    sample_rate: f32,
1185    frequencies: &[f32],
1186    fundamental_db: &[f32],
1187    start_freq: f32,
1188    end_freq: f32,
1189    duration: f32,
1190) -> (Vec<f32>, Vec<Vec<f32>>) {
1191    if frequencies.is_empty() {
1192        return (Vec::new(), Vec::new());
1193    }
1194
1195    let n = impulse.len();
1196    if n == 0 {
1197        return (vec![0.0; frequencies.len()], Vec::new());
1198    }
1199
1200    let num_harmonics = 4; // Compute 2nd, 3rd, 4th, 5th
1201    // Initialize to -120 dB (very low but not absurdly so)
1202    let mut harmonics_db = vec![vec![-120.0; frequencies.len()]; num_harmonics];
1203
1204    // Find main peak index (t=0)
1205    let peak_idx = impulse
1206        .iter()
1207        .enumerate()
1208        .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
1209        .map(|(i, _)| i)
1210        .unwrap_or(0);
1211
1212    let sweep_ratio = end_freq / start_freq;
1213    log::debug!(
1214        "[THD] Impulse len={}, peak_idx={}, duration={:.3}s, sweep {:.0}-{:.0} Hz (ratio {:.1})",
1215        n,
1216        peak_idx,
1217        duration,
1218        start_freq,
1219        end_freq,
1220        sweep_ratio
1221    );
1222
1223    // Compute harmonics
1224    for (k_idx, harmonic_db) in harmonics_db.iter_mut().enumerate().take(num_harmonics) {
1225        let harmonic_order = k_idx + 2; // 2nd harmonic is k=2
1226
1227        // Calculate delay for this harmonic
1228        // dt = T * ln(k) / ln(f2/f1)
1229        let dt = duration * (harmonic_order as f32).ln() / sweep_ratio.ln();
1230        let dn = (dt * sample_rate).round() as isize;
1231
1232        // Center of harmonic impulse (negative time wraps to end of array)
1233        let center = peak_idx as isize - dn;
1234        let center_wrapped = center.rem_euclid(n as isize) as usize;
1235
1236        // Window size logic: distance to next harmonic * 0.8 to avoid overlap
1237        let dt_next_rel = duration
1238            * ((harmonic_order as f32 + 1.0).ln() - (harmonic_order as f32).ln())
1239            / sweep_ratio.ln();
1240        let win_len = ((dt_next_rel * sample_rate * 0.8).max(256.0) as usize).min(n / 2);
1241
1242        // Extract windowed harmonic IR
1243        let mut harmonic_ir = vec![0.0f32; win_len];
1244        let mut max_harmonic_sample = 0.0f32;
1245        for (i, harmonic_ir_val) in harmonic_ir.iter_mut().enumerate() {
1246            let src_idx =
1247                (center - (win_len as isize / 2) + i as isize).rem_euclid(n as isize) as usize;
1248            // Apply Hann window
1249            let w = 0.5 * (1.0 - (2.0 * PI * i as f32 / (win_len as f32 - 1.0)).cos());
1250            *harmonic_ir_val = impulse[src_idx] * w;
1251            max_harmonic_sample = max_harmonic_sample.max(harmonic_ir_val.abs());
1252        }
1253
1254        if k_idx == 0 {
1255            log::debug!(
1256                "[THD] H{}: dt={:.3}s, dn={}, center_wrapped={}, win_len={}, max_sample={:.2e}",
1257                harmonic_order,
1258                dt,
1259                dn,
1260                center_wrapped,
1261                win_len,
1262                max_harmonic_sample
1263            );
1264        }
1265
1266        // Compute spectrum
1267        let fft_size = next_power_of_two(win_len);
1268        let nyquist_bin = fft_size / 2; // Only use positive frequency bins
1269        if let Ok(spectrum) = compute_fft_padded(&harmonic_ir, fft_size) {
1270            let freq_resolution = sample_rate / fft_size as f32;
1271
1272            for (i, &f) in frequencies.iter().enumerate() {
1273                let bin = (f / freq_resolution).round() as usize;
1274                // Only access positive frequency bins (0 to nyquist)
1275                if bin < nyquist_bin && bin < spectrum.len() {
1276                    // compute_fft_padded already applies 1/N normalization, matching
1277                    // the scale of fundamental_db (derived from transfer function ratios)
1278                    let mag = spectrum[bin].norm();
1279                    // Convert to dB (threshold at -120 dB to avoid log of tiny values)
1280                    if mag > 1e-6 {
1281                        harmonic_db[i] = 20.0 * mag.log10();
1282                    }
1283                }
1284            }
1285        }
1286    }
1287
1288    // Log a summary of detected harmonic levels
1289    if !frequencies.is_empty() {
1290        let mid_idx = frequencies.len() / 2;
1291        log::debug!(
1292            "[THD] Harmonic levels at {:.0} Hz: H2={:.1}dB, H3={:.1}dB, H4={:.1}dB, H5={:.1}dB, fundamental={:.1}dB",
1293            frequencies[mid_idx],
1294            harmonics_db[0][mid_idx],
1295            harmonics_db[1][mid_idx],
1296            harmonics_db[2][mid_idx],
1297            harmonics_db[3][mid_idx],
1298            fundamental_db[mid_idx]
1299        );
1300    }
1301
1302    // Compute THD %
1303    let mut thd_percent = Vec::with_capacity(frequencies.len());
1304    for i in 0..frequencies.len() {
1305        let fundamental = 10.0f32.powf(fundamental_db[i] / 20.0);
1306        let mut harmonic_sum_sq = 0.0;
1307
1308        for harmonic_db in harmonics_db.iter().take(num_harmonics) {
1309            let h_mag = 10.0f32.powf(harmonic_db[i] / 20.0);
1310            harmonic_sum_sq += h_mag * h_mag;
1311        }
1312
1313        // THD = sqrt(sum(harmonics^2)) / fundamental
1314        let thd = if fundamental > 1e-9 {
1315            (harmonic_sum_sq.sqrt() / fundamental) * 100.0
1316        } else {
1317            0.0
1318        };
1319        thd_percent.push(thd);
1320    }
1321
1322    // Log THD summary
1323    if !thd_percent.is_empty() {
1324        let max_thd = thd_percent.iter().fold(0.0f32, |a, &b| a.max(b));
1325        let min_thd = thd_percent.iter().fold(f32::INFINITY, |a, &b| a.min(b));
1326        log::debug!("[THD] THD range: {:.4}% to {:.4}%", min_thd, max_thd);
1327    }
1328
1329    (thd_percent, harmonics_db)
1330}
1331
1332/// Write analysis results to CSV file with optional microphone compensation
1333///
1334/// # Arguments
1335/// * `result` - Analysis result
1336/// * `output_path` - Path to output CSV file
1337/// * `compensation` - Optional microphone compensation to apply (inverse)
1338///
1339/// When compensation is provided, the inverse is applied: the microphone's
1340/// SPL deviation is subtracted from the measured SPL to get the true SPL.
1341///
1342/// CSV format includes all analysis metrics:
1343/// frequency_hz, spl_db, phase_deg, thd_percent, rt60_ms, c50_db, c80_db, group_delay_ms
1344pub fn write_analysis_csv(
1345    result: &AnalysisResult,
1346    output_path: &Path,
1347    compensation: Option<&MicrophoneCompensation>,
1348) -> Result<(), String> {
1349    use std::fs::File;
1350    use std::io::Write;
1351
1352    log::info!(
1353        "[write_analysis_csv] Writing {} frequency points to {:?}",
1354        result.frequencies.len(),
1355        output_path
1356    );
1357
1358    if let Some(comp) = compensation {
1359        log::info!(
1360            "[write_analysis_csv] Applying inverse microphone compensation ({} calibration points)",
1361            comp.frequencies.len()
1362        );
1363    }
1364
1365    if result.frequencies.is_empty() {
1366        return Err("Cannot write CSV: Analysis result has no frequency points!".to_string());
1367    }
1368
1369    let mut file =
1370        File::create(output_path).map_err(|e| format!("Failed to create CSV file: {}", e))?;
1371
1372    // Write header with all metrics
1373    writeln!(
1374        file,
1375        "frequency_hz,spl_db,phase_deg,thd_percent,rt60_ms,c50_db,c80_db,group_delay_ms"
1376    )
1377    .map_err(|e| format!("Failed to write header: {}", e))?;
1378
1379    // Write data with compensation applied
1380    for i in 0..result.frequencies.len() {
1381        let freq = result.frequencies[i];
1382        let mut spl = result.spl_db[i];
1383
1384        // Apply inverse compensation: subtract microphone deviation
1385        // If mic reads +2dB at this frequency, the true level is 2dB lower
1386        if let Some(comp) = compensation {
1387            let mic_deviation = comp.interpolate_at(freq);
1388            spl -= mic_deviation;
1389        }
1390
1391        let phase = result.phase_deg[i];
1392        let thd = result.thd_percent.get(i).copied().unwrap_or(0.0);
1393        let rt60 = result.rt60_ms.get(i).copied().unwrap_or(0.0);
1394        let c50 = result.clarity_c50_db.get(i).copied().unwrap_or(0.0);
1395        let c80 = result.clarity_c80_db.get(i).copied().unwrap_or(0.0);
1396        let gd = result.excess_group_delay_ms.get(i).copied().unwrap_or(0.0);
1397
1398        writeln!(
1399            file,
1400            "{:.6},{:.3},{:.6},{:.6},{:.3},{:.3},{:.3},{:.6}",
1401            freq, spl, phase, thd, rt60, c50, c80, gd
1402        )
1403        .map_err(|e| format!("Failed to write data: {}", e))?;
1404    }
1405
1406    log::info!(
1407        "[write_analysis_csv] Successfully wrote {} data rows to CSV",
1408        result.frequencies.len()
1409    );
1410
1411    Ok(())
1412}
1413
1414/// Read analysis results from CSV file
1415///
1416/// Parses CSV with columns: frequency_hz, spl_db, phase_deg, thd_percent, rt60_ms, c50_db, c80_db, group_delay_ms
1417/// Also supports legacy format with just: frequency_hz, spl_db, phase_deg
1418pub fn read_analysis_csv(csv_path: &Path) -> Result<AnalysisResult, String> {
1419    use std::fs::File;
1420    use std::io::{BufRead, BufReader};
1421
1422    let file = File::open(csv_path).map_err(|e| format!("Failed to open CSV: {}", e))?;
1423    let reader = BufReader::new(file);
1424    let mut lines = reader.lines();
1425
1426    // Read header
1427    let header = lines
1428        .next()
1429        .ok_or("Empty CSV file")?
1430        .map_err(|e| format!("Failed to read header: {}", e))?;
1431
1432    let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
1433    let has_extended_format = columns.len() >= 8;
1434
1435    let mut frequencies = Vec::new();
1436    let mut spl_db = Vec::new();
1437    let mut phase_deg = Vec::new();
1438    let mut thd_percent = Vec::new();
1439    let mut rt60_ms = Vec::new();
1440    let mut clarity_c50_db = Vec::new();
1441    let mut clarity_c80_db = Vec::new();
1442    let mut excess_group_delay_ms = Vec::new();
1443
1444    for line in lines {
1445        let line = line.map_err(|e| format!("Failed to read line: {}", e))?;
1446        let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
1447
1448        if parts.len() < 3 {
1449            continue;
1450        }
1451
1452        let freq: f32 = parts[0].parse().unwrap_or(0.0);
1453        let spl: f32 = parts[1].parse().unwrap_or(0.0);
1454        let phase: f32 = parts[2].parse().unwrap_or(0.0);
1455
1456        frequencies.push(freq);
1457        spl_db.push(spl);
1458        phase_deg.push(phase);
1459
1460        if has_extended_format && parts.len() >= 8 {
1461            thd_percent.push(parts[3].parse().unwrap_or(0.0));
1462            rt60_ms.push(parts[4].parse().unwrap_or(0.0));
1463            clarity_c50_db.push(parts[5].parse().unwrap_or(0.0));
1464            clarity_c80_db.push(parts[6].parse().unwrap_or(0.0));
1465            excess_group_delay_ms.push(parts[7].parse().unwrap_or(0.0));
1466        }
1467    }
1468
1469    // If legacy format, fill with zeros
1470    let n = frequencies.len();
1471    if thd_percent.is_empty() {
1472        thd_percent = vec![0.0; n];
1473        rt60_ms = vec![0.0; n];
1474        clarity_c50_db = vec![0.0; n];
1475        clarity_c80_db = vec![0.0; n];
1476        excess_group_delay_ms = vec![0.0; n];
1477    }
1478
1479    Ok(AnalysisResult {
1480        frequencies,
1481        spl_db,
1482        phase_deg,
1483        estimated_lag_samples: 0,
1484        impulse_response: Vec::new(),
1485        impulse_time_ms: Vec::new(),
1486        thd_percent,
1487        harmonic_distortion_db: Vec::new(),
1488        rt60_ms,
1489        clarity_c50_db,
1490        clarity_c80_db,
1491        excess_group_delay_ms,
1492        spectrogram_db: Vec::new(),
1493    })
1494}
1495
1496/// Window function type for FFT
1497#[derive(Debug, Clone, Copy)]
1498enum WindowType {
1499    Hann,
1500    Tukey(f32), // alpha parameter (0.0-1.0)
1501}
1502
1503/// Estimate lag between reference and recorded signals using cross-correlation
1504///
1505/// Uses FFT-based cross-correlation for efficiency
1506///
1507/// # Arguments
1508/// * `reference` - Reference signal
1509/// * `recorded` - Recorded signal
1510///
1511/// # Returns
1512/// Estimated lag in samples (negative means recorded leads)
1513fn estimate_lag(reference: &[f32], recorded: &[f32]) -> Result<isize, String> {
1514    let len = reference.len().min(recorded.len());
1515
1516    // Zero-pad to avoid circular correlation artifacts
1517    let fft_size = next_power_of_two(len * 2);
1518
1519    // Use Hann window for correlation to suppress edge effects
1520    let ref_fft = compute_fft(reference, fft_size, WindowType::Hann)?;
1521    let rec_fft = compute_fft(recorded, fft_size, WindowType::Hann)?;
1522
1523    // Cross-correlation in frequency domain: conj(X) * Y
1524    let mut cross_corr_fft: Vec<Complex<f32>> = ref_fft
1525        .iter()
1526        .zip(rec_fft.iter())
1527        .map(|(x, y)| x.conj() * y)
1528        .collect();
1529
1530    // IFFT to get cross-correlation in time domain
1531    let ifft = plan_fft_inverse(fft_size);
1532    ifft.process(&mut cross_corr_fft);
1533
1534    // Find peak
1535    let mut max_val = 0.0;
1536    let mut max_idx = 0;
1537
1538    for (i, &val) in cross_corr_fft.iter().enumerate() {
1539        let magnitude = val.norm();
1540        if magnitude > max_val {
1541            max_val = magnitude;
1542            max_idx = i;
1543        }
1544    }
1545
1546    // Convert index to lag (handle wrap-around)
1547    Ok(if max_idx <= fft_size / 2 {
1548        max_idx as isize
1549    } else {
1550        max_idx as isize - fft_size as isize
1551    })
1552}
1553
1554/// Compute FFT of a signal with specified windowing
1555///
1556/// # Arguments
1557/// * `signal` - Input signal
1558/// * `fft_size` - FFT size (should be power of 2)
1559/// * `window_type` - Type of window to apply
1560///
1561/// # Returns
1562/// Complex FFT spectrum
1563fn compute_fft(
1564    signal: &[f32],
1565    fft_size: usize,
1566    window_type: WindowType,
1567) -> Result<Vec<Complex<f32>>, String> {
1568    // Apply window
1569    let windowed = match window_type {
1570        WindowType::Hann => apply_hann_window(signal),
1571        WindowType::Tukey(alpha) => apply_tukey_window(signal, alpha),
1572    };
1573
1574    compute_fft_padded(&windowed, fft_size)
1575}
1576
1577/// Compute FFT with zero-padding
1578fn compute_fft_padded(signal: &[f32], fft_size: usize) -> Result<Vec<Complex<f32>>, String> {
1579    // Single allocation at final size; trailing elements are already zero-padded
1580    let mut buffer = vec![Complex::new(0.0, 0.0); fft_size];
1581    for (dst, &src) in buffer.iter_mut().zip(signal.iter()) {
1582        dst.re = src;
1583    }
1584
1585    // Compute FFT
1586    let fft = plan_fft_forward(fft_size);
1587    fft.process(&mut buffer);
1588
1589    // Normalize by FFT size (standard FFT normalization)
1590    let norm_factor = 1.0 / fft_size as f32;
1591    for val in buffer.iter_mut() {
1592        *val *= norm_factor;
1593    }
1594
1595    Ok(buffer)
1596}
1597
1598/// Apply Hann window to a signal
1599fn apply_hann_window(signal: &[f32]) -> Vec<f32> {
1600    let len = signal.len();
1601    if len < 2 {
1602        return signal.to_vec();
1603    }
1604    signal
1605        .iter()
1606        .enumerate()
1607        .map(|(i, &x)| {
1608            let window = 0.5 * (1.0 - (2.0 * PI * i as f32 / (len - 1) as f32).cos());
1609            x * window
1610        })
1611        .collect()
1612}
1613
1614/// Apply Tukey window to a signal
1615///
1616/// Tukey window is a "tapered cosine" window.
1617/// alpha=0.0 is rectangular, alpha=1.0 is Hann.
1618fn apply_tukey_window(signal: &[f32], alpha: f32) -> Vec<f32> {
1619    let len = signal.len();
1620    if len < 2 {
1621        return signal.to_vec();
1622    }
1623
1624    let alpha = alpha.clamp(0.0, 1.0);
1625    let limit = (alpha * (len as f32 - 1.0) / 2.0).round() as usize;
1626
1627    if limit == 0 {
1628        return signal.to_vec();
1629    }
1630
1631    signal
1632        .iter()
1633        .enumerate()
1634        .map(|(i, &x)| {
1635            let w = if i < limit {
1636                // Fade in (Half-Hann)
1637                0.5 * (1.0 - (PI * i as f32 / limit as f32).cos())
1638            } else if i >= len - limit {
1639                // Fade out (Half-Hann)
1640                let n = len - 1 - i;
1641                0.5 * (1.0 - (PI * n as f32 / limit as f32).cos())
1642            } else {
1643                // Flat top
1644                1.0
1645            };
1646            x * w
1647        })
1648        .collect()
1649}
1650
1651/// Find the next power of two greater than or equal to n
1652fn next_power_of_two(n: usize) -> usize {
1653    if n == 0 {
1654        return 1;
1655    }
1656    n.next_power_of_two()
1657}
1658
1659/// Load a mono WAV file and convert to f32 samples
1660/// Load a WAV file and extract a specific channel or convert to mono
1661///
1662/// # Arguments
1663/// * `path` - Path to WAV file
1664/// * `channel_index` - Optional channel index to extract (0-based). If None, will average all channels for mono
1665fn load_wav_mono_channel(path: &Path, channel_index: Option<usize>) -> Result<Vec<f32>, String> {
1666    let mut reader =
1667        WavReader::open(path).map_err(|e| format!("Failed to open WAV file: {}", e))?;
1668
1669    let spec = reader.spec();
1670    let channels = spec.channels as usize;
1671
1672    log::info!(
1673        "[load_wav_mono_channel] WAV file: {} channels, {} Hz, {:?} format",
1674        channels,
1675        spec.sample_rate,
1676        spec.sample_format
1677    );
1678
1679    // Read all samples and convert to f32
1680    let samples: Result<Vec<f32>, _> = match spec.sample_format {
1681        hound::SampleFormat::Float => reader.samples::<f32>().collect(),
1682        hound::SampleFormat::Int => reader
1683            .samples::<i32>()
1684            .map(|s| s.map(|v| v as f32 / i32::MAX as f32))
1685            .collect(),
1686    };
1687
1688    let samples = samples.map_err(|e| format!("Failed to read samples: {}", e))?;
1689    log::info!(
1690        "[load_wav_mono_channel] Read {} total samples",
1691        samples.len()
1692    );
1693
1694    // Handle mono file - return as-is
1695    if channels == 1 {
1696        log::info!(
1697            "[load_wav_mono_channel] File is already mono, returning {} samples",
1698            samples.len()
1699        );
1700        return Ok(samples);
1701    }
1702
1703    // Handle multi-channel file
1704    if let Some(ch_idx) = channel_index {
1705        // Extract specific channel
1706        if ch_idx >= channels {
1707            return Err(format!(
1708                "Channel index {} out of range (file has {} channels)",
1709                ch_idx, channels
1710            ));
1711        }
1712        log::info!(
1713            "[load_wav_mono_channel] Extracting channel {} from {} channels",
1714            ch_idx,
1715            channels
1716        );
1717        Ok(samples
1718            .chunks(channels)
1719            .map(|chunk| chunk[ch_idx])
1720            .collect())
1721    } else {
1722        // Average all channels to mono
1723        log::info!(
1724            "[load_wav_mono_channel] Averaging {} channels to mono",
1725            channels
1726        );
1727        Ok(samples
1728            .chunks(channels)
1729            .map(|chunk| chunk.iter().sum::<f32>() / channels as f32)
1730            .collect())
1731    }
1732}
1733
1734/// Load a WAV file as mono (averages channels if multi-channel)
1735fn load_wav_mono(path: &Path) -> Result<Vec<f32>, String> {
1736    load_wav_mono_channel(path, None)
1737}
1738
1739// ============================================================================
1740// DSP Utilities (Moved from frontend dsp.rs)
1741// ============================================================================
1742
1743/// Apply octave smoothing to frequency response data (f64 version)
1744///
1745/// Frequencies must be sorted in ascending order (as from FFT or log-spaced grids).
1746/// Uses a prefix sum with two-pointer sliding window for O(n) complexity.
1747pub fn smooth_response_f64(frequencies: &[f64], values: &[f64], octaves: f64) -> Vec<f64> {
1748    if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
1749        return values.to_vec();
1750    }
1751
1752    let n = values.len();
1753
1754    // Prefix sum for O(1) range averages
1755    let mut prefix = Vec::with_capacity(n + 1);
1756    prefix.push(0.0);
1757    for &v in values {
1758        prefix.push(prefix.last().unwrap() + v);
1759    }
1760
1761    let ratio = 2.0_f64.powf(octaves / 2.0);
1762    let mut smoothed = Vec::with_capacity(n);
1763    let mut lo = 0usize;
1764    let mut hi = 0usize;
1765
1766    for (i, &center_freq) in frequencies.iter().enumerate() {
1767        if center_freq <= 0.0 {
1768            smoothed.push(values[i]);
1769            continue;
1770        }
1771
1772        let low_freq = center_freq / ratio;
1773        let high_freq = center_freq * ratio;
1774
1775        // Advance lo past frequencies below the window
1776        while lo < n && frequencies[lo] < low_freq {
1777            lo += 1;
1778        }
1779        // Advance hi to include frequencies within the window
1780        while hi < n && frequencies[hi] <= high_freq {
1781            hi += 1;
1782        }
1783
1784        let count = hi - lo;
1785        if count > 0 {
1786            smoothed.push((prefix[hi] - prefix[lo]) / count as f64);
1787        } else {
1788            smoothed.push(values[i]);
1789        }
1790    }
1791
1792    smoothed
1793}
1794
1795/// Apply octave smoothing to frequency response data (f32 version)
1796///
1797/// Frequencies must be sorted in ascending order (as from FFT or log-spaced grids).
1798/// Uses a prefix sum with two-pointer sliding window for O(n) complexity.
1799pub fn smooth_response_f32(frequencies: &[f32], values: &[f32], octaves: f32) -> Vec<f32> {
1800    if octaves <= 0.0 || frequencies.is_empty() || values.is_empty() {
1801        return values.to_vec();
1802    }
1803
1804    let n = values.len();
1805
1806    // Prefix sum for O(1) range averages (accumulate in f64 to avoid precision loss)
1807    let mut prefix = Vec::with_capacity(n + 1);
1808    prefix.push(0.0_f64);
1809    for &v in values {
1810        prefix.push(prefix.last().unwrap() + v as f64);
1811    }
1812
1813    let ratio = 2.0_f32.powf(octaves / 2.0);
1814    let mut smoothed = Vec::with_capacity(n);
1815    let mut lo = 0usize;
1816    let mut hi = 0usize;
1817
1818    for (i, &center_freq) in frequencies.iter().enumerate() {
1819        if center_freq <= 0.0 {
1820            smoothed.push(values[i]);
1821            continue;
1822        }
1823
1824        let low_freq = center_freq / ratio;
1825        let high_freq = center_freq * ratio;
1826
1827        // Advance lo past frequencies below the window
1828        while lo < n && frequencies[lo] < low_freq {
1829            lo += 1;
1830        }
1831        // Advance hi to include frequencies within the window
1832        while hi < n && frequencies[hi] <= high_freq {
1833            hi += 1;
1834        }
1835
1836        let count = hi - lo;
1837        if count > 0 {
1838            smoothed.push(((prefix[hi] - prefix[lo]) / count as f64) as f32);
1839        } else {
1840            smoothed.push(values[i]);
1841        }
1842    }
1843
1844    smoothed
1845}
1846
1847/// Compute group delay from phase data
1848/// Group delay = -d(phase)/d(frequency) / (2*pi)
1849///
1850/// Phase is unwrapped before differentiation to avoid spurious spikes
1851/// at ±180° wrap boundaries.
1852pub fn compute_group_delay(frequencies: &[f32], phase_deg: &[f32]) -> Vec<f32> {
1853    if frequencies.len() < 2 {
1854        return vec![0.0; frequencies.len()];
1855    }
1856
1857    // Unwrap phase to remove ±180° discontinuities before differentiation
1858    let unwrapped = unwrap_phase_deg(phase_deg);
1859
1860    let mut group_delay_ms = Vec::with_capacity(frequencies.len());
1861
1862    for i in 0..frequencies.len() {
1863        let delay = if i == 0 {
1864            // Forward difference at start
1865            let df = frequencies[1] - frequencies[0];
1866            let dp = unwrapped[1] - unwrapped[0];
1867            if df.abs() > 1e-6 {
1868                -dp / df / 360.0 * 1000.0 // Convert to ms
1869            } else {
1870                0.0
1871            }
1872        } else if i == frequencies.len() - 1 {
1873            // Backward difference at end
1874            let df = frequencies[i] - frequencies[i - 1];
1875            let dp = unwrapped[i] - unwrapped[i - 1];
1876            if df.abs() > 1e-6 {
1877                -dp / df / 360.0 * 1000.0
1878            } else {
1879                0.0
1880            }
1881        } else {
1882            // Central difference
1883            let df = frequencies[i + 1] - frequencies[i - 1];
1884            let dp = unwrapped[i + 1] - unwrapped[i - 1];
1885            if df.abs() > 1e-6 {
1886                -dp / df / 360.0 * 1000.0
1887            } else {
1888                0.0
1889            }
1890        };
1891        group_delay_ms.push(delay);
1892    }
1893
1894    group_delay_ms
1895}
1896
1897/// Unwrap phase in degrees to produce a continuous phase curve.
1898/// Wraps each inter-sample difference to [-180, 180] and accumulates,
1899/// handling arbitrarily large jumps (not just single ±360° wraps).
1900fn unwrap_phase_deg(phase_deg: &[f32]) -> Vec<f32> {
1901    if phase_deg.is_empty() {
1902        return Vec::new();
1903    }
1904
1905    let mut unwrapped = Vec::with_capacity(phase_deg.len());
1906    unwrapped.push(phase_deg[0]);
1907
1908    for i in 1..phase_deg.len() {
1909        let diff = phase_deg[i] - phase_deg[i - 1];
1910        let wrapped_diff = diff - 360.0 * (diff / 360.0).round();
1911        unwrapped.push(unwrapped[i - 1] + wrapped_diff);
1912    }
1913
1914    unwrapped
1915}
1916
1917/// Compute impulse response from frequency response via inverse FFT.
1918///
1919/// The input frequency/magnitude/phase data (possibly irregularly spaced) is
1920/// interpolated onto a uniform FFT frequency grid, assembled into a complex
1921/// spectrum with Hermitian symmetry, and transformed with an inverse FFT.
1922///
1923/// Returns (times_ms, impulse) where impulse is peak-normalized to [-1, 1].
1924pub fn compute_impulse_response_from_fr(
1925    frequencies: &[f32],
1926    magnitude_db: &[f32],
1927    phase_deg: &[f32],
1928    sample_rate: f32,
1929) -> (Vec<f32>, Vec<f32>) {
1930    let fft_size = 1024;
1931    let half = fft_size / 2; // Number of positive-frequency bins (excluding DC)
1932    let freq_bin = sample_rate / fft_size as f32;
1933
1934    // Unwrap phase before interpolation to avoid discontinuities
1935    let unwrapped_phase = unwrap_phase_deg(phase_deg);
1936
1937    // Build complex spectrum on uniform FFT grid via linear interpolation
1938    let mut spectrum = vec![Complex::new(0.0_f32, 0.0); fft_size];
1939
1940    for (k, spectrum_bin) in spectrum.iter_mut().enumerate().take(half + 1) {
1941        let f = k as f32 * freq_bin;
1942
1943        // Interpolate magnitude (dB) and phase (deg) at this bin frequency
1944        let (mag_db, phase_d) = interpolate_fr(frequencies, magnitude_db, &unwrapped_phase, f);
1945
1946        let mag_linear = 10.0_f32.powf(mag_db / 20.0);
1947        let phase_rad = phase_d * PI / 180.0;
1948
1949        *spectrum_bin = Complex::new(mag_linear * phase_rad.cos(), mag_linear * phase_rad.sin());
1950    }
1951
1952    // Enforce Hermitian symmetry: X[N-k] = conj(X[k])
1953    for k in 1..half {
1954        spectrum[fft_size - k] = spectrum[k].conj();
1955    }
1956
1957    // Inverse FFT (uses thread-local cached planner)
1958    let ifft = plan_fft_inverse(fft_size);
1959    ifft.process(&mut spectrum);
1960
1961    // Extract real part and scale by 1/N (rustfft doesn't normalize)
1962    let scale = 1.0 / fft_size as f32;
1963    let mut impulse: Vec<f32> = spectrum.iter().map(|c| c.re * scale).collect();
1964
1965    // Normalize to [-1, 1]
1966    let max_val = impulse.iter().map(|v| v.abs()).fold(0.0_f32, f32::max);
1967    if max_val > 0.0 {
1968        for v in &mut impulse {
1969            *v /= max_val;
1970        }
1971    }
1972
1973    let time_step = 1.0 / sample_rate;
1974    let times: Vec<f32> = (0..fft_size)
1975        .map(|i| i as f32 * time_step * 1000.0)
1976        .collect();
1977
1978    (times, impulse)
1979}
1980
1981/// Linearly interpolate magnitude and phase at a target frequency.
1982/// Clamps to the nearest endpoint if `target_freq` is outside the data range.
1983///
1984/// Phase must be pre-unwrapped (continuous) for correct interpolation.
1985fn interpolate_fr(
1986    frequencies: &[f32],
1987    magnitude_db: &[f32],
1988    unwrapped_phase_deg: &[f32],
1989    target_freq: f32,
1990) -> (f32, f32) {
1991    if frequencies.is_empty() {
1992        return (0.0, 0.0);
1993    }
1994    if target_freq <= frequencies[0] {
1995        return (magnitude_db[0], unwrapped_phase_deg[0]);
1996    }
1997    let last = frequencies.len() - 1;
1998    if target_freq >= frequencies[last] {
1999        return (magnitude_db[last], unwrapped_phase_deg[last]);
2000    }
2001
2002    // Binary search for the interval containing target_freq
2003    let idx = match frequencies.binary_search_by(|f| f.partial_cmp(&target_freq).unwrap()) {
2004        Ok(i) => return (magnitude_db[i], unwrapped_phase_deg[i]),
2005        Err(i) => i, // target_freq is between frequencies[i-1] and frequencies[i]
2006    };
2007
2008    let f0 = frequencies[idx - 1];
2009    let f1 = frequencies[idx];
2010    let t = (target_freq - f0) / (f1 - f0);
2011
2012    let mag = magnitude_db[idx - 1] + t * (magnitude_db[idx] - magnitude_db[idx - 1]);
2013    let phase = unwrapped_phase_deg[idx - 1]
2014        + t * (unwrapped_phase_deg[idx] - unwrapped_phase_deg[idx - 1]);
2015    (mag, phase)
2016}
2017
2018/// Compute Schroeder energy decay curve
2019fn compute_schroeder_decay(impulse: &[f32]) -> Vec<f32> {
2020    let mut energy = 0.0;
2021    let mut decay = vec![0.0; impulse.len()];
2022
2023    // Backward integration
2024    for i in (0..impulse.len()).rev() {
2025        energy += impulse[i] * impulse[i];
2026        decay[i] = energy;
2027    }
2028
2029    // Normalize to 0dB max (1.0 linear)
2030    let max_energy = decay.first().copied().unwrap_or(1.0);
2031    if max_energy > 0.0 {
2032        for v in &mut decay {
2033            *v /= max_energy;
2034        }
2035    }
2036
2037    decay
2038}
2039
2040/// Compute RT60 from Impulse Response (Broadband)
2041/// Uses T20 (-5dB to -25dB) extrapolation
2042pub fn compute_rt60_broadband(impulse: &[f32], sample_rate: f32) -> f32 {
2043    let decay = compute_schroeder_decay(impulse);
2044    let decay_db: Vec<f32> = decay.iter().map(|&v| 10.0 * v.max(1e-9).log10()).collect();
2045
2046    // Find -5dB and -25dB points
2047    let t_minus_5 = decay_db.iter().position(|&v| v < -5.0);
2048    let t_minus_25 = decay_db.iter().position(|&v| v < -25.0);
2049
2050    match (t_minus_5, t_minus_25) {
2051        (Some(start), Some(end)) => {
2052            if end > start {
2053                let dt = (end - start) as f32 / sample_rate; // Time for 20dB decay
2054                dt * 3.0 // Extrapolate to 60dB (T20 * 3)
2055            } else {
2056                0.0
2057            }
2058        }
2059        _ => 0.0,
2060    }
2061}
2062
2063/// Compute Clarity (C50, C80) from Impulse Response (Broadband)
2064/// Returns (C50_dB, C80_dB)
2065pub fn compute_clarity_broadband(impulse: &[f32], sample_rate: f32) -> (f32, f32) {
2066    let mut energy_0_50 = 0.0;
2067    let mut energy_50_inf = 0.0;
2068    let mut energy_0_80 = 0.0;
2069    let mut energy_80_inf = 0.0;
2070
2071    let samp_50ms = (0.050 * sample_rate) as usize;
2072    let samp_80ms = (0.080 * sample_rate) as usize;
2073
2074    for (i, &samp) in impulse.iter().enumerate() {
2075        let sq = samp * samp;
2076
2077        if i < samp_50ms {
2078            energy_0_50 += sq;
2079        } else {
2080            energy_50_inf += sq;
2081        }
2082
2083        if i < samp_80ms {
2084            energy_0_80 += sq;
2085        } else {
2086            energy_80_inf += sq;
2087        }
2088    }
2089
2090    // When late energy is negligible, clarity is very high (capped at 60 dB)
2091    // When early energy is negligible, clarity is very low (capped at -60 dB)
2092    const MAX_CLARITY_DB: f32 = 60.0;
2093
2094    let c50 = if energy_50_inf > 1e-12 && energy_0_50 > 1e-12 {
2095        let ratio = energy_0_50 / energy_50_inf;
2096        (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2097    } else if energy_0_50 > energy_50_inf {
2098        MAX_CLARITY_DB // Early energy dominates - excellent clarity
2099    } else {
2100        -MAX_CLARITY_DB // Late energy dominates - poor clarity
2101    };
2102
2103    let c80 = if energy_80_inf > 1e-12 && energy_0_80 > 1e-12 {
2104        let ratio = energy_0_80 / energy_80_inf;
2105        (10.0 * ratio.log10()).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2106    } else if energy_80_inf > energy_0_80 {
2107        MAX_CLARITY_DB // Early energy dominates - excellent clarity
2108    } else {
2109        -MAX_CLARITY_DB // Late energy dominates - poor clarity
2110    };
2111
2112    (c50, c80)
2113}
2114
2115/// Compute RT60 spectrum using octave band filtering
2116pub fn compute_rt60_spectrum(impulse: &[f32], sample_rate: f32, frequencies: &[f32]) -> Vec<f32> {
2117    if impulse.is_empty() {
2118        return vec![0.0; frequencies.len()];
2119    }
2120
2121    // Octave band center frequencies
2122    let centers = [
2123        63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2124    ];
2125    let mut band_rt60s = Vec::with_capacity(centers.len());
2126    let mut valid_centers = Vec::with_capacity(centers.len());
2127
2128    // Compute RT60 for each band
2129    for &freq in &centers {
2130        // Skip if frequency is too high for sample rate
2131        if freq >= sample_rate / 2.0 {
2132            continue;
2133        }
2134
2135        // Apply bandpass filter
2136        // Q=1.414 (sqrt(2)) gives approx 1 octave bandwidth
2137        let mut biquad = Biquad::new(
2138            BiquadFilterType::Bandpass,
2139            freq as f64,
2140            sample_rate as f64,
2141            1.414,
2142            0.0,
2143        );
2144
2145        // Process in f64
2146        let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2147        biquad.process_block(&mut filtered);
2148        let filtered_f32: Vec<f32> = filtered.iter().map(|&x| x as f32).collect();
2149
2150        // Compute RT60 for this band
2151        let rt60 = compute_rt60_broadband(&filtered_f32, sample_rate);
2152
2153        band_rt60s.push(rt60);
2154        valid_centers.push(freq);
2155    }
2156
2157    // Log per-band values
2158    log::info!(
2159        "[RT60] Per-band values: {:?}",
2160        valid_centers
2161            .iter()
2162            .zip(band_rt60s.iter())
2163            .map(|(f, v)| format!("{:.0}Hz:{:.1}ms", f, v))
2164            .collect::<Vec<_>>()
2165    );
2166
2167    if valid_centers.is_empty() {
2168        return vec![0.0; frequencies.len()];
2169    }
2170
2171    // Interpolate to output frequencies
2172    interpolate_log(&valid_centers, &band_rt60s, frequencies)
2173}
2174
2175/// Compute Clarity spectrum (C50, C80) using octave band filtering
2176/// Returns (C50_vec, C80_vec)
2177pub fn compute_clarity_spectrum(
2178    impulse: &[f32],
2179    sample_rate: f32,
2180    frequencies: &[f32],
2181) -> (Vec<f32>, Vec<f32>) {
2182    if impulse.is_empty() || frequencies.is_empty() {
2183        return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2184    }
2185
2186    // Octave band center frequencies
2187    let centers = [
2188        63.0f32, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
2189    ];
2190    let mut band_c50s = Vec::with_capacity(centers.len());
2191    let mut band_c80s = Vec::with_capacity(centers.len());
2192    let mut valid_centers = Vec::with_capacity(centers.len());
2193
2194    // Time boundaries for clarity calculation
2195    let samp_50ms = (0.050 * sample_rate) as usize;
2196    let samp_80ms = (0.080 * sample_rate) as usize;
2197
2198    // Compute Clarity for each band using cascaded bandpass for better selectivity
2199    for &freq in &centers {
2200        if freq >= sample_rate / 2.0 {
2201            continue;
2202        }
2203
2204        // Use cascaded biquads for sharper filter response (reduces filter ringing effects)
2205        let mut biquad1 = Biquad::new(
2206            BiquadFilterType::Bandpass,
2207            freq as f64,
2208            sample_rate as f64,
2209            0.707, // Lower Q per stage, cascaded gives Q ~ 1.0
2210            0.0,
2211        );
2212        let mut biquad2 = Biquad::new(
2213            BiquadFilterType::Bandpass,
2214            freq as f64,
2215            sample_rate as f64,
2216            0.707,
2217            0.0,
2218        );
2219
2220        let mut filtered: Vec<f64> = impulse.iter().map(|&x| x as f64).collect();
2221        biquad1.process_block(&mut filtered);
2222        biquad2.process_block(&mut filtered);
2223
2224        // Compute energy in early and late windows directly
2225        let mut energy_0_50 = 0.0f64;
2226        let mut energy_50_inf = 0.0f64;
2227        let mut energy_0_80 = 0.0f64;
2228        let mut energy_80_inf = 0.0f64;
2229
2230        for (i, &samp) in filtered.iter().enumerate() {
2231            let sq = samp * samp;
2232
2233            if i < samp_50ms {
2234                energy_0_50 += sq;
2235            } else {
2236                energy_50_inf += sq;
2237            }
2238
2239            if i < samp_80ms {
2240                energy_0_80 += sq;
2241            } else {
2242                energy_80_inf += sq;
2243            }
2244        }
2245
2246        // Compute C50 and C80 with proper handling
2247        // When late energy is very small, clarity is high (capped at 40 dB for display)
2248        const MAX_CLARITY_DB: f32 = 40.0;
2249        const MIN_ENERGY: f64 = 1e-20;
2250
2251        let c50 = if energy_50_inf > MIN_ENERGY && energy_0_50 > MIN_ENERGY {
2252            let ratio = energy_0_50 / energy_50_inf;
2253            (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2254        } else if energy_0_50 > energy_50_inf {
2255            MAX_CLARITY_DB
2256        } else {
2257            -MAX_CLARITY_DB
2258        };
2259
2260        let c80 = if energy_80_inf > MIN_ENERGY && energy_0_80 > MIN_ENERGY {
2261            let ratio = energy_0_80 / energy_80_inf;
2262            (10.0 * ratio.log10() as f32).clamp(-MAX_CLARITY_DB, MAX_CLARITY_DB)
2263        } else if energy_0_80 > energy_80_inf {
2264            MAX_CLARITY_DB
2265        } else {
2266            -MAX_CLARITY_DB
2267        };
2268
2269        band_c50s.push(c50);
2270        band_c80s.push(c80);
2271        valid_centers.push(freq);
2272    }
2273
2274    // Log per-band values
2275    log::info!(
2276        "[Clarity] Per-band C50: {:?}",
2277        valid_centers
2278            .iter()
2279            .zip(band_c50s.iter())
2280            .map(|(f, v)| format!("{:.0}Hz:{:.1}dB", f, v))
2281            .collect::<Vec<_>>()
2282    );
2283
2284    if valid_centers.is_empty() {
2285        return (vec![0.0; frequencies.len()], vec![0.0; frequencies.len()]);
2286    }
2287
2288    // Interpolate to output frequency grid
2289    let c50_interp = interpolate_log(&valid_centers, &band_c50s, frequencies);
2290    let c80_interp = interpolate_log(&valid_centers, &band_c80s, frequencies);
2291
2292    (c50_interp, c80_interp)
2293}
2294
2295/// Compute Spectrogram from Impulse Response
2296/// Returns (spectrogram_matrix_db, frequency_bins, time_bins)
2297/// `window_size` samples (e.g. 512), `hop_size` samples (e.g. 128).
2298pub fn compute_spectrogram(
2299    impulse: &[f32],
2300    sample_rate: f32,
2301    window_size: usize,
2302    hop_size: usize,
2303) -> (Vec<Vec<f32>>, Vec<f32>, Vec<f32>) {
2304    use rustfft::num_complex::Complex;
2305
2306    if impulse.len() < window_size {
2307        return (Vec::new(), Vec::new(), Vec::new());
2308    }
2309
2310    let num_frames = (impulse.len() - window_size) / hop_size;
2311    let mut spectrogram = Vec::with_capacity(num_frames);
2312    let mut times = Vec::with_capacity(num_frames);
2313
2314    // Precompute Hann window
2315    let window: Vec<f32> = (0..window_size)
2316        .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / (window_size as f32 - 1.0)).cos()))
2317        .collect();
2318
2319    // Setup FFT
2320    let fft = plan_fft_forward(window_size);
2321
2322    for i in 0..num_frames {
2323        let start = i * hop_size;
2324        let time_ms = (start as f32 / sample_rate) * 1000.0;
2325        times.push(time_ms);
2326
2327        let mut buffer: Vec<Complex<f32>> = (0..window_size)
2328            .map(|j| {
2329                let sample = impulse.get(start + j).copied().unwrap_or(0.0);
2330                Complex::new(sample * window[j], 0.0)
2331            })
2332            .collect();
2333
2334        fft.process(&mut buffer);
2335
2336        // Take magnitude of first half (up to Nyquist)
2337        // Store as dB
2338        let magnitude_db: Vec<f32> = buffer[..window_size / 2]
2339            .iter()
2340            .map(|c| {
2341                let mag = c.norm();
2342                if mag > 1e-9 {
2343                    20.0 * mag.log10()
2344                } else {
2345                    -180.0
2346                }
2347            })
2348            .collect();
2349
2350        spectrogram.push(magnitude_db);
2351    }
2352
2353    // Generate frequency bins
2354    let num_bins = window_size / 2;
2355    let freq_step = sample_rate / window_size as f32;
2356    let freqs: Vec<f32> = (0..num_bins).map(|i| i as f32 * freq_step).collect();
2357
2358    (spectrogram, freqs, times)
2359}
2360
2361/// Find a frequency point where the magnitude reaches a specific dB level
2362///
2363/// # Arguments
2364/// * `frequencies` - Frequency points in Hz
2365/// * `magnitude_db` - Magnitude in dB
2366/// * `target_db` - The target level to find (e.g., -3.0)
2367/// * `from_start` - If true, search from the beginning of the curve. If false, search from the end.
2368///
2369/// # Returns
2370/// The interpolated frequency where the target dB is reached, or None if not found.
2371pub fn find_db_point(
2372    frequencies: &[f32],
2373    magnitude_db: &[f32],
2374    target_db: f32,
2375    from_start: bool,
2376) -> Option<f32> {
2377    if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2378        return None;
2379    }
2380
2381    if from_start {
2382        for i in 0..magnitude_db.len() - 1 {
2383            let m0 = magnitude_db[i];
2384            let m1 = magnitude_db[i + 1];
2385
2386            // Check if target_db is between m0 and m1
2387            if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2388                // Linear interpolation: m0 + t * (m1 - m0) = target_db
2389                let denominator = m1 - m0;
2390                if denominator.abs() < 1e-9 {
2391                    return Some(frequencies[i]);
2392                }
2393                let t = (target_db - m0) / denominator;
2394                return Some(frequencies[i] + t * (frequencies[i + 1] - frequencies[i]));
2395            }
2396        }
2397    } else {
2398        for i in (1..magnitude_db.len()).rev() {
2399            let m0 = magnitude_db[i];
2400            let m1 = magnitude_db[i - 1];
2401
2402            // Check if target_db is between m0 and m1
2403            if (m0 <= target_db && target_db <= m1) || (m1 <= target_db && target_db <= m0) {
2404                let denominator = m1 - m0;
2405                if denominator.abs() < 1e-9 {
2406                    return Some(frequencies[i]);
2407                }
2408                let t = (target_db - m0) / denominator;
2409                return Some(frequencies[i] + t * (frequencies[i - 1] - frequencies[i]));
2410            }
2411        }
2412    }
2413
2414    None
2415}
2416
2417/// Compute a log-frequency weighted reference response level in dB.
2418///
2419/// # Arguments
2420/// * `frequencies` - Frequency points in Hz
2421/// * `magnitude_db` - Magnitude in dB
2422/// * `freq_range` - Optional (start_freq, end_freq) to limit the averaging range.
2423///   If None, averages over the full bandwidth.
2424///
2425/// # Returns
2426/// The log-frequency weighted average in the dB domain.
2427///
2428/// This is intended as a stable acoustic reference level for comparison and
2429/// normalization. It is not a pressure- or energy-domain average.
2430pub fn compute_average_response(
2431    frequencies: &[f32],
2432    magnitude_db: &[f32],
2433    freq_range: Option<(f32, f32)>,
2434) -> f32 {
2435    if frequencies.len() < 2 || frequencies.len() != magnitude_db.len() {
2436        return magnitude_db.first().copied().unwrap_or(0.0);
2437    }
2438
2439    let (start_freq, end_freq) =
2440        freq_range.unwrap_or((frequencies[0], frequencies[frequencies.len() - 1]));
2441
2442    let mut sum_weighted_db = 0.0;
2443    let mut sum_weights = 0.0;
2444
2445    for i in 0..frequencies.len() - 1 {
2446        let f0 = frequencies[i];
2447        let f1 = frequencies[i + 1];
2448
2449        // Check if this segment overlaps with the target range
2450        if f1 < start_freq || f0 > end_freq {
2451            continue;
2452        }
2453
2454        // Clamp segment to target range
2455        let fa = f0.max(start_freq);
2456        let fb = f1.min(end_freq);
2457
2458        if fb <= fa {
2459            continue;
2460        }
2461
2462        // For acoustic data, we weight by log frequency (octaves)
2463        // weight = log2(fb/fa)
2464        let weight = (fb / fa).log2();
2465
2466        // Average magnitude in this segment
2467        // We'll use the midpoint value of the segment (or average of endpoints)
2468        // If the segment is partially outside start_freq/end_freq, we should interpolate
2469        // but for many points simple average of endpoints in the segment is fine.
2470        let m0 = magnitude_db[i];
2471        let m1 = magnitude_db[i + 1];
2472        let avg_m = (m0 + m1) / 2.0;
2473
2474        sum_weighted_db += avg_m * weight;
2475        sum_weights += weight;
2476    }
2477
2478    if sum_weights > 0.0 {
2479        sum_weighted_db / sum_weights
2480    } else {
2481        magnitude_db.first().copied().unwrap_or(0.0)
2482    }
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487    use super::*;
2488
2489    #[test]
2490    fn test_next_power_of_two() {
2491        assert_eq!(next_power_of_two(1), 1);
2492        assert_eq!(next_power_of_two(2), 2);
2493        assert_eq!(next_power_of_two(3), 4);
2494        assert_eq!(next_power_of_two(1000), 1024);
2495        assert_eq!(next_power_of_two(1024), 1024);
2496        assert_eq!(next_power_of_two(1025), 2048);
2497    }
2498
2499    #[test]
2500    fn test_hann_window() {
2501        let signal = vec![1.0; 100];
2502        let windowed = apply_hann_window(&signal);
2503
2504        // First and last samples should be near zero
2505        assert!(windowed[0].abs() < 0.01);
2506        assert!(windowed[99].abs() < 0.01);
2507
2508        // Middle sample should be near 1.0
2509        assert!((windowed[50] - 1.0).abs() < 0.01);
2510    }
2511
2512    #[test]
2513    fn test_estimate_lag_zero() {
2514        // Identical signals should have zero lag
2515        let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2516        let lag = estimate_lag(&signal, &signal).unwrap();
2517        assert_eq!(lag, 0);
2518    }
2519
2520    #[test]
2521    fn test_estimate_lag_positive() {
2522        // Reference leads recorded (recorded is delayed)
2523        // Use longer signals for reliable FFT-based cross-correlation
2524        let mut reference = vec![0.0; 100];
2525        let mut recorded = vec![0.0; 100];
2526
2527        // Create a pulse pattern that will correlate well
2528        for (j, val) in reference[10..20].iter_mut().enumerate() {
2529            *val = j as f32 / 10.0;
2530        }
2531        // Same pattern but delayed by 5 samples
2532        for (j, val) in recorded[15..25].iter_mut().enumerate() {
2533            *val = j as f32 / 10.0;
2534        }
2535
2536        let lag = estimate_lag(&reference, &recorded).unwrap();
2537        assert_eq!(lag, 5, "Recorded signal is delayed by 5 samples");
2538    }
2539
2540    #[test]
2541    fn test_identical_signals_have_zero_lag() {
2542        // When signals are truly identical (like in the bug case),
2543        // lag should be exactly zero
2544        let signal = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2545        let lag = estimate_lag(&signal, &signal).unwrap();
2546        assert_eq!(lag, 0, "Identical signals should have zero lag");
2547    }
2548
2549    /// Write a mono f32 WAV file for testing
2550    fn write_test_wav(path: &std::path::Path, samples: &[f32], sample_rate: u32) {
2551        let spec = hound::WavSpec {
2552            channels: 1,
2553            sample_rate,
2554            bits_per_sample: 32,
2555            sample_format: hound::SampleFormat::Float,
2556        };
2557        let mut writer = hound::WavWriter::create(path, spec).unwrap();
2558        for &s in samples {
2559            writer.write_sample(s).unwrap();
2560        }
2561        writer.finalize().unwrap();
2562    }
2563
2564    /// Generate a log sweep signal (same as the recording system uses)
2565    fn generate_test_sweep(
2566        start_freq: f32,
2567        end_freq: f32,
2568        duration_secs: f32,
2569        sample_rate: u32,
2570        amplitude: f32,
2571    ) -> Vec<f32> {
2572        let num_samples = (duration_secs * sample_rate as f32) as usize;
2573        let mut signal = Vec::with_capacity(num_samples);
2574        let ln_ratio = (end_freq / start_freq).ln();
2575        for i in 0..num_samples {
2576            let t = i as f32 / sample_rate as f32;
2577            let phase = 2.0 * PI * start_freq * duration_secs / ln_ratio
2578                * ((t / duration_secs * ln_ratio).exp() - 1.0);
2579            signal.push(amplitude * phase.sin());
2580        }
2581        signal
2582    }
2583
2584    #[test]
2585    fn test_analyze_recording_normal_channel() {
2586        // Simulate a normal speaker: reference sweep played back and recorded
2587        // with some attenuation and small delay
2588        let sample_rate = 48000;
2589        let duration = 1.0;
2590        let reference = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
2591
2592        // Simulate recording: attenuate by ~-6dB (factor 0.5) and delay by 100 samples
2593        let delay = 100;
2594        let attenuation = 0.5;
2595        let mut recorded = vec![0.0_f32; reference.len() + delay];
2596        for (i, &s) in reference.iter().enumerate() {
2597            recorded[i + delay] = s * attenuation;
2598        }
2599
2600        let dir = std::env::temp_dir().join(format!(
2601            "sotf_test_normal_{}",
2602            std::process::id()
2603        ));
2604        std::fs::create_dir_all(&dir).unwrap();
2605        let wav_path = dir.join("test_normal.wav");
2606        write_test_wav(&wav_path, &recorded, sample_rate);
2607
2608        let result = analyze_recording(&wav_path, &reference, sample_rate, None).unwrap();
2609        std::fs::remove_dir_all(&dir).ok();
2610
2611        // Compute average SPL in the passband (200 Hz - 10 kHz)
2612        let mut sum = 0.0_f32;
2613        let mut count = 0;
2614        for (&freq, &db) in result.frequencies.iter().zip(result.spl_db.iter()) {
2615            if (200.0..=10000.0).contains(&freq) {
2616                sum += db;
2617                count += 1;
2618            }
2619        }
2620        let avg_db = sum / count as f32;
2621
2622        // Expected: ~-6 dB (attenuation factor 0.5)
2623        // Allow generous tolerance for windowing/FFT artifacts
2624        assert!(
2625            avg_db > -12.0 && avg_db < 0.0,
2626            "Normal channel avg SPL should be near -6 dB, got {:.1} dB",
2627            avg_db
2628        );
2629
2630        // No bin should exceed +6 dB (physically implausible for passive attenuation)
2631        let max_db = result
2632            .spl_db
2633            .iter()
2634            .zip(result.frequencies.iter())
2635            .filter(|&(_, &f)| (200.0..=10000.0).contains(&f))
2636            .map(|(&db, _)| db)
2637            .fold(f32::NEG_INFINITY, f32::max);
2638        assert!(
2639            max_db < 6.0,
2640            "Normal channel should not have bins above +6 dB, got {:.1} dB",
2641            max_db
2642        );
2643    }
2644
2645    #[test]
2646    fn test_analyze_recording_silent_channel() {
2647        // Simulate a disconnected speaker: reference sweep played but recording
2648        // is just low-level noise (no speaker output)
2649        let sample_rate = 48000;
2650        let duration = 1.0;
2651        let reference = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
2652
2653        // Recording is pure noise at -60 dBFS (amplitude 0.001)
2654        let noise_amplitude = 0.001;
2655        let num_samples = reference.len();
2656        let mut recorded = Vec::with_capacity(num_samples);
2657        // Use deterministic "noise" (alternating small values)
2658        for i in 0..num_samples {
2659            let pseudo_noise = noise_amplitude
2660                * (((i as f32 * 0.1).sin() + (i as f32 * 0.37).cos()) * 0.5);
2661            recorded.push(pseudo_noise);
2662        }
2663
2664        let dir = std::env::temp_dir().join(format!(
2665            "sotf_test_silent_{}",
2666            std::process::id()
2667        ));
2668        std::fs::create_dir_all(&dir).unwrap();
2669        let wav_path = dir.join("test_silent.wav");
2670        write_test_wav(&wav_path, &recorded, sample_rate);
2671
2672        let result = analyze_recording(&wav_path, &reference, sample_rate, None).unwrap();
2673        std::fs::remove_dir_all(&dir).ok();
2674
2675        // For a disconnected channel, the transfer function should be very low
2676        // (noise / sweep ≈ noise floor). It must NOT show spurious high-dB peaks.
2677        let max_db = result
2678            .spl_db
2679            .iter()
2680            .zip(result.frequencies.iter())
2681            .filter(|&(_, &f)| (100.0..=10000.0).contains(&f))
2682            .map(|(&db, _)| db)
2683            .fold(f32::NEG_INFINITY, f32::max);
2684
2685        assert!(
2686            max_db < 0.0,
2687            "Silent/disconnected channel should not have positive dB values, got max {:.1} dB",
2688            max_db
2689        );
2690    }
2691
2692    #[test]
2693    fn test_analyze_recording_lfe_narrow_sweep_same_point_count() {
2694        // Simulate a 5.1 scenario: LFE uses a narrow sweep (20-500 Hz) while
2695        // main channels use the full range (20-20000 Hz). Both must produce
2696        // the same number of output frequency points to avoid ndarray shape
2697        // mismatches when curves are combined in the optimizer.
2698        let sample_rate = 48000;
2699        let duration = 1.0;
2700
2701        // Full-range reference (main channel)
2702        let ref_full = generate_test_sweep(20.0, 20000.0, duration, sample_rate, 0.5);
2703        // Narrow reference (LFE)
2704        let ref_lfe = generate_test_sweep(20.0, 500.0, duration, sample_rate, 0.5);
2705
2706        // Simulate recordings: attenuated copies with delay
2707        let delay = 50;
2708        let atten = 0.3;
2709
2710        let mut rec_full = vec![0.0_f32; ref_full.len() + delay];
2711        for (i, &s) in ref_full.iter().enumerate() {
2712            rec_full[i + delay] = s * atten;
2713        }
2714
2715        let mut rec_lfe = vec![0.0_f32; ref_lfe.len() + delay];
2716        for (i, &s) in ref_lfe.iter().enumerate() {
2717            rec_lfe[i + delay] = s * atten;
2718        }
2719
2720        let dir = std::env::temp_dir().join(format!(
2721            "sotf_test_lfe_points_{}",
2722            std::process::id()
2723        ));
2724        std::fs::create_dir_all(&dir).unwrap();
2725
2726        let wav_full = dir.join("main.wav");
2727        let wav_lfe = dir.join("lfe.wav");
2728        write_test_wav(&wav_full, &rec_full, sample_rate);
2729        write_test_wav(&wav_lfe, &rec_lfe, sample_rate);
2730
2731        let result_full = analyze_recording(&wav_full, &ref_full, sample_rate, None).unwrap();
2732        let result_lfe = analyze_recording(&wav_lfe, &ref_lfe, sample_rate, None).unwrap();
2733        std::fs::remove_dir_all(&dir).ok();
2734
2735        // Both must produce the same number of frequency points
2736        assert_eq!(
2737            result_full.frequencies.len(),
2738            result_lfe.frequencies.len(),
2739            "Main ({}) and LFE ({}) must have the same number of frequency points",
2740            result_full.frequencies.len(),
2741            result_lfe.frequencies.len()
2742        );
2743        assert_eq!(
2744            result_full.spl_db.len(),
2745            result_lfe.spl_db.len(),
2746            "SPL arrays must match in length"
2747        );
2748
2749        // LFE should have valid data below ~500 Hz and noise floor above
2750        let lfe_valid_count = result_lfe
2751            .spl_db
2752            .iter()
2753            .zip(result_lfe.frequencies.iter())
2754            .filter(|&(&db, &f)| f <= 500.0 && db > -100.0)
2755            .count();
2756        assert!(
2757            lfe_valid_count > 100,
2758            "LFE should have valid data below 500 Hz, got {} points",
2759            lfe_valid_count
2760        );
2761
2762        let lfe_above_500_max = result_lfe
2763            .spl_db
2764            .iter()
2765            .zip(result_lfe.frequencies.iter())
2766            .filter(|&(_, &f)| f > 1000.0)
2767            .map(|(&db, _)| db)
2768            .fold(f32::NEG_INFINITY, f32::max);
2769        assert!(
2770            lfe_above_500_max <= -100.0,
2771            "LFE above 1 kHz should be at noise floor, got {:.1} dB",
2772            lfe_above_500_max
2773        );
2774    }
2775}