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