autoeq 0.4.36

Automatic equalization for speakers, headphones and rooms!
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
use std::error::Error;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;

use crate::Curve;
use ndarray::Array1;

/// Load frequency response data from a CSV or text file
/// Expected formats:
/// - 2 columns: frequency, spl
/// - 4 columns: freq_left, spl_left, freq_right, spl_right (averaged)
pub fn load_frequency_response(
    path: &PathBuf,
) -> Result<(Array1<f64>, Array1<f64>), Box<dyn std::error::Error>> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    let mut frequencies = Vec::new();
    let mut spl_values = Vec::new();
    let mut detected_columns = 0;

    for (line_num, line) in reader.lines().enumerate() {
        let line = line?;
        let line = line.trim();

        // Skip empty lines and comments
        if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
            continue;
        }

        // Skip header if it contains text
        if line_num == 0 && (line.contains("freq") || line.contains("Freq") || line.contains("Hz"))
        {
            continue;
        }

        // Parse line (handle both comma and whitespace separation)
        let parts: Vec<&str> = if line.contains(',') {
            line.split(',').map(|s| s.trim()).collect()
        } else {
            line.split_whitespace().collect()
        };

        // Detect number of columns on first data line
        if detected_columns == 0 && parts.len() >= 2 {
            detected_columns = parts.len();
        }

        if detected_columns == 2 && parts.len() >= 2 {
            // 2-column format: freq, spl
            if let (Ok(freq), Ok(spl)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
                frequencies.push(freq);
                spl_values.push(spl);
            }
        } else if detected_columns == 4 && parts.len() >= 4 {
            // 4-column format: freq_left, spl_left, freq_right, spl_right
            // Assume frequencies are the same for left and right, average the SPL
            if let (Ok(freq_l), Ok(spl_l), Ok(_freq_r), Ok(spl_r)) = (
                parts[0].parse::<f64>(),
                parts[1].parse::<f64>(),
                parts[2].parse::<f64>(),
                parts[3].parse::<f64>(),
            ) {
                frequencies.push(freq_l);
                spl_values.push((spl_l + spl_r) / 2.0); // Average left and right
            }
        }
    }

    if frequencies.is_empty() {
        return Err("No valid frequency response data found in file".into());
    }

    Ok((Array1::from_vec(frequencies), Array1::from_vec(spl_values)))
}

/// Read a frequency response curve from a CSV file.
///
/// After loading the raw columns, calls [`crate::Curve::decompose_into_cache`]
/// to populate `min_phase`, `excess_phase`, and `excess_delay_ms` when phase
/// data is present. These derived fields are **not** persisted to CSV — they
/// are always recomputed at load time so the decomposition algorithm can
/// evolve without requiring a re-export (§2.4 and §2.11 Q3 of
/// `docs/gd_opt_v2_plan.md`). When phase is absent the cache fields stay
/// `None`.
///
/// # Arguments
/// * `path` - Path to the CSV file
///
/// # CSV Format
/// The CSV file should have a header row with "frequency" and "spl" columns,
/// followed by rows of frequency (Hz) and SPL (dB) values.
pub fn read_curve_from_csv(path: &PathBuf) -> Result<Curve, Box<dyn Error>> {
    // Try to load as driver measurement (with optional phase / coherence /
    // noise_floor_db) first. GD-Opt v2 adds `coherence` and
    // `noise_floor_db` columns — see §2.4 of `docs/gd_opt_v2_plan.md`.
    let mut curve = match load_driver_measurement(path) {
        Ok((freq, spl, phase, coherence, noise_floor_db)) => crate::Curve {
            freq,
            spl,
            phase,
            coherence,
            noise_floor_db,
            ..Default::default()
        },
        Err(_) => {
            // Fallback to load_frequency_response (handles 4-column stereo average)
            let result = load_frequency_response(path)?;
            crate::Curve {
                freq: Array1::from(result.0),
                spl: Array1::from(result.1),
                phase: None,
                ..Default::default()
            }
        }
    };
    // GD-1d: populate min-phase / excess-phase cache at the disk-load boundary.
    // No-op when phase is absent or arrays disagree in length.
    curve.decompose_into_cache();
    Ok(curve)
}

/// Load driver measurement data from a CSV file.
///
/// # Arguments
/// * `path` - Path to the CSV file
///
/// # Returns
/// * `(frequencies, spl_values, phase?, coherence?, noise_floor_db?)`.
///   The three optional columns are populated when the matching
///   header is present and every row parses cleanly; otherwise `None`.
///
/// # CSV Format
/// Column discovery is header-name driven, so column order doesn't matter.
/// Recognised column names (case-insensitive):
/// - **freq**: `frequency_hz`, `frequency`, `freq`, or `hz`.
/// - **spl**: `spl`, `spl_db`, `magnitude`, or `db`.
/// - **phase**: `phase_deg` or any column name containing `phase`.
/// - **coherence**: `coherence` (γ² from the multi-sweep average,
///   added by GD-Opt v2 — see `docs/gd_opt_v2_plan.md` §2.4).
/// - **noise_floor_db**: `noise_floor_db` (per-bin noise-floor
///   estimate in dB, added by GD-Opt v2 §2.4).
///
/// Headerless CSVs default to positional columns: col 0 = freq,
/// col 1 = spl, col 2 = phase (if present). Coherence and noise-floor
/// columns require explicit headers.
#[allow(clippy::type_complexity)]
pub fn load_driver_measurement(
    path: &PathBuf,
) -> Result<
    (
        Array1<f64>,
        Array1<f64>,
        Option<Array1<f64>>,
        Option<Array1<f64>>,
        Option<Array1<f64>>,
    ),
    Box<dyn std::error::Error>,
> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    let mut frequencies = Vec::new();
    let mut spl_values = Vec::new();
    let mut phase_values = Vec::new();
    let mut coherence_values = Vec::new();
    let mut noise_floor_values = Vec::new();

    // Column indices (default to first 2-3 columns)
    let mut freq_col: Option<usize> = None;
    let mut spl_col: Option<usize> = None;
    let mut phase_col: Option<usize> = None;
    let mut coherence_col: Option<usize> = None;
    let mut noise_floor_col: Option<usize> = None;
    let mut header_parsed = false;

    for (line_num, line) in reader.lines().enumerate() {
        let line = line?;
        let line = line.trim();

        // Skip empty lines and comments
        if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
            continue;
        }

        // Parse line (handle both comma and whitespace separation)
        let parts: Vec<&str> = if line.contains(',') {
            line.split(',').map(|s| s.trim()).collect()
        } else {
            line.split_whitespace().collect()
        };

        // Try to parse header on first line
        if line_num == 0 && !header_parsed {
            let is_header = parts.iter().any(|p| {
                let lower = p.to_lowercase();
                lower.contains("freq")
                    || lower.contains("hz")
                    || lower.contains("spl")
                    || lower.contains("phase")
                    || lower.contains("db")
                    || lower.contains("coherence")
                    || lower.contains("noise_floor")
            });

            if is_header {
                // Parse header to find column indices
                for (idx, col_name) in parts.iter().enumerate() {
                    let lower = col_name.to_lowercase();
                    // Order matters — check the most specific names first.
                    // `noise_floor_db` contains `db`, so it must be checked
                    // before `spl_col`'s `db` fallback, otherwise `spl`
                    // would hijack it. Same for `coherence` vs any
                    // generic check.
                    if noise_floor_col.is_none() && lower.contains("noise_floor") {
                        noise_floor_col = Some(idx);
                    } else if coherence_col.is_none() && lower == "coherence" {
                        coherence_col = Some(idx);
                    } else if freq_col.is_none()
                        && (lower.contains("freq") || lower == "hz" || lower == "frequency_hz")
                    {
                        freq_col = Some(idx);
                    } else if phase_col.is_none()
                        && (lower.contains("phase") || lower == "phase_deg")
                    {
                        phase_col = Some(idx);
                    } else if spl_col.is_none()
                        && (lower.contains("spl")
                            || lower.contains("magnitude")
                            || lower == "db"
                            || lower == "spl_db")
                    {
                        spl_col = Some(idx);
                    }
                }
                header_parsed = true;
                continue; // Skip header line
            }

            // No header found, use default column positions
            if parts.len() >= 2 {
                freq_col = Some(0);
                spl_col = Some(1);
                if parts.len() >= 3 {
                    phase_col = Some(2);
                }
            }
            header_parsed = true;
        }

        // Use default columns if not set
        let freq_idx = freq_col.unwrap_or(0);
        let spl_idx = spl_col.unwrap_or(1);

        // Parse data
        if parts.len() > freq_idx
            && parts.len() > spl_idx
            && let (Ok(freq), Ok(spl)) = (
                parts[freq_idx].parse::<f64>(),
                parts[spl_idx].parse::<f64>(),
            )
        {
            frequencies.push(freq);
            spl_values.push(spl);

            // Parse phase if available
            if let Some(phase_idx) = phase_col
                && parts.len() > phase_idx
                && let Ok(phase) = parts[phase_idx].parse::<f64>()
            {
                phase_values.push(phase);
            }
            // Parse coherence if available
            if let Some(coh_idx) = coherence_col
                && parts.len() > coh_idx
                && let Ok(coh) = parts[coh_idx].parse::<f64>()
            {
                coherence_values.push(coh);
            }
            // Parse noise_floor_db if available
            if let Some(nf_idx) = noise_floor_col
                && parts.len() > nf_idx
                && let Ok(nf) = parts[nf_idx].parse::<f64>()
            {
                noise_floor_values.push(nf);
            }
        }
    }

    if frequencies.is_empty() {
        return Err("No valid driver measurement data found in file".into());
    }

    let phase = if !phase_values.is_empty() && phase_values.len() == frequencies.len() {
        Some(Array1::from_vec(phase_values))
    } else {
        None
    };
    let coherence = if !coherence_values.is_empty()
        && coherence_values.len() == frequencies.len()
    {
        Some(Array1::from_vec(coherence_values))
    } else {
        None
    };
    let noise_floor_db = if !noise_floor_values.is_empty()
        && noise_floor_values.len() == frequencies.len()
    {
        Some(Array1::from_vec(noise_floor_values))
    } else {
        None
    };

    Ok((
        Array1::from_vec(frequencies),
        Array1::from_vec(spl_values),
        phase,
        coherence,
        noise_floor_db,
    ))
}

#[cfg(test)]
mod gd_v2_tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn write_tmp(csv: &str) -> NamedTempFile {
        let mut f = NamedTempFile::new().unwrap();
        f.write_all(csv.as_bytes()).unwrap();
        f.flush().unwrap();
        f
    }

    #[test]
    fn legacy_three_column_csv_still_loads() {
        let csv = "frequency,spl,phase\n20,0.0,10\n200,1.0,20\n2000,2.0,30\n";
        let f = write_tmp(csv);
        let curve = read_curve_from_csv(&f.path().to_path_buf()).unwrap();
        assert_eq!(curve.freq.len(), 3);
        assert_eq!(curve.spl.len(), 3);
        assert!(curve.phase.is_some());
        assert_eq!(curve.phase.as_ref().unwrap().len(), 3);
        assert!(curve.coherence.is_none());
        assert!(curve.noise_floor_db.is_none());
    }

    #[test]
    fn gd_v2_extended_csv_populates_coherence_and_noise_floor() {
        let csv = "\
frequency,spl,phase,coherence,noise_floor_db
20,0.0,10,0.95,-45
200,1.0,20,0.98,-50
2000,2.0,30,0.99,-55
";
        let f = write_tmp(csv);
        let curve = read_curve_from_csv(&f.path().to_path_buf()).unwrap();
        assert_eq!(curve.freq.len(), 3);
        assert_eq!(curve.spl.len(), 3);
        assert_eq!(curve.phase.as_ref().unwrap().len(), 3);
        let coh = curve.coherence.expect("coherence populated");
        assert_eq!(coh.len(), 3);
        assert!((coh[0] - 0.95).abs() < 1e-9);
        let nf = curve.noise_floor_db.expect("noise_floor_db populated");
        assert_eq!(nf.len(), 3);
        assert!((nf[2] + 55.0).abs() < 1e-9);
        // GD-1d: derived fields are now populated at load time when phase is present.
        assert!(curve.min_phase.is_some(), "min_phase populated by GD-1d");
        assert!(curve.excess_phase.is_some(), "excess_phase populated by GD-1d");
        assert!(curve.excess_delay_ms.is_some(), "excess_delay_ms populated by GD-1d");
    }

    #[test]
    fn column_order_is_header_driven() {
        // Coherence before freq; noise_floor_db before phase. The parser
        // must key off names, not positions.
        let csv = "\
coherence,frequency,noise_floor_db,phase,spl
0.9,20,-45,10,0.0
0.95,200,-50,20,1.0
";
        let f = write_tmp(csv);
        let curve = read_curve_from_csv(&f.path().to_path_buf()).unwrap();
        assert_eq!(curve.freq.len(), 2);
        assert!((curve.freq[0] - 20.0).abs() < 1e-9);
        assert!((curve.freq[1] - 200.0).abs() < 1e-9);
        assert!((curve.spl[0]).abs() < 1e-9);
        assert!((curve.spl[1] - 1.0).abs() < 1e-9);
        let coh = curve.coherence.expect("coherence populated");
        assert!((coh[0] - 0.9).abs() < 1e-9);
        let nf = curve.noise_floor_db.expect("noise_floor_db populated");
        assert!((nf[0] + 45.0).abs() < 1e-9);
    }

    #[test]
    fn mismatched_extended_row_count_drops_column() {
        // noise_floor_db column has one unparseable row ("nan-ish"); the
        // parser must keep the other columns but drop noise_floor_db.
        let csv = "\
frequency,spl,noise_floor_db
20,0.0,-45
200,1.0,not-a-number
2000,2.0,-55
";
        let f = write_tmp(csv);
        let curve = read_curve_from_csv(&f.path().to_path_buf()).unwrap();
        assert_eq!(curve.freq.len(), 3);
        assert_eq!(curve.spl.len(), 3);
        assert!(curve.noise_floor_db.is_none());
    }
}