Skip to main content

open_stomp/nam/
convert.rs

1//! NAM model → pedal user AMP data: the official app's converter, reimplemented (docs/05).
2//!
3//! The pedal doesn't run the network. It runs *G1 → its own static nonlinearity → G2*, two
4//! 1024-tap FIR filters fitted to the model:
5//!
6//! 1. **Best linear approximation**: a pink multisine at about −90 dBFS through the model;
7//!    `H = |Y| / |X|` per FFT bin, plus a 1/24-octave smoothed copy.
8//! 2. **Tone test**: a full-scale sine at each of 94 frequencies (58.6 Hz – 14.6 kHz); the level of
9//!    the fundamental and of the 3rd harmonic at the output.
10//! 3. `G1 = smoothed H / max(fundamental, 3rd harmonic)` at the tone frequencies, interpolated in
11//!    log-frequency; `G2 = H / G1`.
12//! 4. Both become minimum-phase impulse responses; the upload is the first 1024 taps of each,
13//!    reversed, and a factor of 1.0.
14//!
15//! The pedal only runs the first [`PEDAL_TAPS`] (512) taps of each uploaded filter (found by
16//! re-amping, docs/05): the app's extra 512 taps per filter are ignored. [`Measure::App`] keeps the
17//! app's layout byte for byte; the other modes upload exactly what the pedal runs ([`upload_data`]).
18//!
19//! Every stage was checked against the vendor code (open-stomp-manager `tools/namconv`). The one
20//! difference is the excitation: the vendor's 8192-sample multisine is a stored table, so
21//! [`excitation`] generates our own with the same spectrum (see [`Converted`] for how close the
22//! results are).
23
24use rustfft::FftPlanner;
25use rustfft::num_complex::Complex64;
26
27use super::Model;
28
29/// FFT length of every measurement (48 kHz → 5.859375 Hz per bin).
30pub const N: usize = 8192;
31const HALF: usize = N / 2;
32/// Samples per tone, of which the first [`TONE_SETTLE`] are not measured.
33const TONE_LEN: usize = 10240;
34const TONE_SETTLE: usize = 2048;
35
36/// Tone-test frequencies as FFT bins (the app's `ToneTest_FftBins`): roughly 1/12 octave apart.
37pub const TONE_BINS: [usize; 94] = [
38    10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 25, 27, 28, 30, 32, 34, 36, 38, 41, 43, 46, 48, 51, 54,
39    58, 61, 65, 69, 73, 77, 82, 86, 92, 97, 103, 109, 116, 123, 130, 138, 146, 155, 164, 174, 184, 195, 207, 219, 232,
40    246, 260, 276, 292, 310, 328, 348, 369, 391, 414, 439, 465, 492, 522, 553, 586, 621, 658, 697, 738, 782, 829, 878,
41    931, 986, 1045, 1107, 1173, 1243, 1317, 1395, 1478, 1566, 1660, 1758, 1863, 1974, 2092, 2216, 2348, 2488,
42];
43/// Impulse-response taps of each uploaded filter that the pedal actually runs. An upload holds
44/// 1024 floats per filter, the response reversed (`data[1023 − k]` is tap `k` of G1,
45/// `data[2047 − k]` of G2), but the pedal uses only taps 0..511 (`data[512..1024]`,
46/// `data[1536..2048]`) and ignores the rest.
47pub const PEDAL_TAPS: usize = 512;
48/// Tones whose 3rd harmonic is measured (3 × bin stays below Nyquist).
49const THIRD_TONES: usize = 83;
50
51/// The result of a conversion, with every intermediate stage.
52#[derive(Clone, Debug)]
53pub struct Converted {
54    /// The 2048 floats to upload (`g1 ‖ g2`).
55    pub data: Vec<f32>,
56    /// The slot-info factor (always 1.0, as in the official app).
57    pub factor: f32,
58    /// Fundamental level per tone.
59    pub levels: Vec<f64>,
60    /// 3rd-harmonic level per tone (first 83 tones).
61    pub levels3: Vec<f64>,
62    /// The measured linear response `20·log10 H`, bins 0..4095.
63    pub bla_db: Vec<f64>,
64    /// G1 target gain per tone, in dB.
65    pub g1_gains_db: Vec<f64>,
66    /// The 8192-tap minimum-phase responses before truncation (for [`Measure::Optimised`], the
67    /// fitted [`PEDAL_TAPS`]-tap responses as uploaded).
68    pub g1_ir: Vec<f64>,
69    pub g2_ir: Vec<f64>,
70}
71
72/// Upload data for two impulse responses as the pedal runs them: the first [`PEDAL_TAPS`] taps of
73/// each, reversed, in the second half of each 1024-float block; the halves the pedal ignores are 0.
74pub fn upload_data(g1_ir: &[f64], g2_ir: &[f64]) -> Vec<f32> {
75    let block = |ir: &[f64]| {
76        let mut b = vec![0.0f32; 1024];
77        for (k, &v) in ir.iter().take(PEDAL_TAPS).enumerate() {
78            b[1023 - k] = v as f32;
79        }
80        b
81    };
82    [block(g1_ir), block(g2_ir)].concat()
83}
84
85/// The two impulse responses the pedal actually runs for 2048 floats of upload data
86/// ([`PEDAL_TAPS`] taps each; the inverse of [`upload_data`]).
87pub fn pedal_irs(data: &[f32]) -> (Vec<f64>, Vec<f64>) {
88    assert_eq!(data.len(), 2048, "upload data is 2048 floats");
89    let ir = |b: &[f32]| (0..PEDAL_TAPS).map(|k| b[1023 - k] as f64).collect();
90    (ir(&data[..1024]), ir(&data[1024..]))
91}
92
93/// The DC blocker the app applies to every model output; its state carries over between calls.
94#[derive(Default)]
95struct DcBlocker {
96    x1: f32,
97    x2: f32,
98    y1: f32,
99    y2: f32,
100}
101
102impl DcBlocker {
103    fn run(&mut self, x: &mut [f32]) {
104        const B0: f32 = 0.995_382_7;
105        const B1: f32 = -1.990_765;
106        const B2: f32 = 0.995_382_7;
107        const A1: f32 = -1.990_744;
108        const A2: f32 = 0.990_786_7;
109        for v in x {
110            let x0 = *v;
111            let y0 = (self.x1 * B1 + x0 * B0 + self.x2 * B2) - (self.y2 * A2 + self.y1 * A1);
112            *v = y0;
113            (self.x2, self.x1, self.y2, self.y1) = (self.x1, x0, self.y1, y0);
114        }
115    }
116}
117
118struct Fft {
119    fwd: std::sync::Arc<dyn rustfft::Fft<f64>>,
120    inv: std::sync::Arc<dyn rustfft::Fft<f64>>,
121}
122
123impl Fft {
124    fn new() -> Self {
125        let mut p = FftPlanner::new();
126        Fft { fwd: p.plan_fft_forward(N), inv: p.plan_fft_inverse(N) }
127    }
128
129    /// `|FFT(x)|²` for bins 0..4095 (no window, no scaling), like the app's `getSpectrum`.
130    fn power(&self, x: &[f32]) -> Vec<f64> {
131        let mut buf: Vec<Complex64> = x.iter().map(|&v| Complex64::new(v as f64, 0.0)).collect();
132        buf.resize(N, Complex64::default());
133        self.fwd.process(&mut buf);
134        buf[..HALF].iter().map(|c| c.norm_sqr()).collect()
135    }
136
137    /// dB curve over bins 0..4095 → 8192-tap minimum-phase impulse response (`Magnitude2MinPhase`).
138    fn min_phase(&self, db: &[f64]) -> Vec<f64> {
139        // the app mirrors as m[8191 - k] = m[k]
140        let m: Vec<f64> = (0..N).map(|i| 10f64.powf(db[if i < HALF { i } else { N - 1 - i }] / 20.0)).collect();
141        let mut c: Vec<Complex64> =
142            m.iter().map(|&v| Complex64::new(if v == 0.0 { -30.0 } else { v.ln() }, 0.0)).collect();
143        // analytic signal of ln|m|: keep DC and Nyquist, double positive, zero negative frequencies
144        self.fwd.process(&mut c);
145        for (i, v) in c.iter_mut().enumerate() {
146            *v *= match i {
147                0 => 1.0,
148                i if i == HALF => 1.0,
149                i if i < HALF => 2.0,
150                _ => 0.0,
151            } / N as f64;
152        }
153        self.inv.process(&mut c);
154        let mut spec: Vec<Complex64> = m.iter().zip(&c).map(|(&mag, a)| Complex64::from_polar(mag, -a.im)).collect();
155        self.inv.process(&mut spec);
156        spec.iter().map(|v| v.re / N as f64).collect()
157    }
158}
159
160/// 1/24-octave power smoothing with log-frequency triangular weights (bins 2..4095).
161fn smooth(h: &[f64]) -> Vec<f64> {
162    let r = 1.014_545_3_f32; // 2^(1/48)
163    let mut out = vec![0.0; HALF];
164    for (k, o) in out.iter_mut().enumerate().skip(2) {
165        let lo = ((k as f32 / r).floor() as usize).max(2);
166        let hi = ((k as f32 * r).ceil() as usize).clamp(2, HALF - 1).max(lo);
167        let m = k.max(lo);
168        let (lm, llo, lhi) = ((m as f64).log10(), ((lo - 1) as f64).log10(), ((hi + 1) as f64).log10());
169        let (mut sw, mut sp) = (0.0, 0.0);
170        for (j, hj) in h.iter().enumerate().take(hi + 1).skip(lo) {
171            let lj = (j as f64).log10();
172            let w = if j > m {
173                if lhi != lm { (lhi - lj) / (lhi - lm) } else { 1.0 }
174            } else {
175                (lj - llo) / (lm - llo)
176            };
177            sw += w;
178            sp += w * hj * hj;
179        }
180        *o = sp / sw;
181    }
182    out[0] = out[2];
183    out[1] = out[2];
184    out.iter().map(|p| p.sqrt()).collect()
185}
186
187/// Values at `bins`, linearly interpolated over all bins 0..4095 (held outside).
188fn interp_linear(values: &[f64], bins: &[usize], below: f64) -> Vec<f64> {
189    let mut out = vec![below; HALF];
190    for i in 0..bins.len() - 1 {
191        for (k, o) in out.iter_mut().enumerate().take(bins[i + 1]).skip(bins[i]) {
192            let t = (k - bins[i]) as f64 / (bins[i + 1] - bins[i]) as f64;
193            *o = t * values[i + 1] + (1.0 - t) * values[i];
194        }
195    }
196    let last = *bins.last().expect("bins");
197    out[last..].fill(*values.last().expect("values"));
198    out
199}
200
201/// Values at `bins`, interpolated in log-frequency over all bins 0..4095 (held outside).
202fn interp_log(values: &[f64], bins: &[usize]) -> Vec<f64> {
203    let mut out = vec![values[0]; HALF];
204    for i in 0..bins.len() - 1 {
205        let (l0, l1) = ((bins[i] as f64).log10(), (bins[i + 1] as f64).log10());
206        for (k, o) in out.iter_mut().enumerate().take(bins[i + 1]).skip(bins[i]) {
207            let t = ((k as f64).log10() - l0) / (l1 - l0);
208            *o = t * values[i + 1] + (1.0 - t) * values[i];
209        }
210    }
211    let last = *bins.last().expect("bins");
212    out[last..].fill(*values.last().expect("values"));
213    out
214}
215
216/// First 8 bytes and FNV-1a 64-bit hash of the official app's 8192-sample BLA excitation (its
217/// `BLA_x` table, float32 little-endian). Only this fingerprint is in the source: the table itself
218/// is vendor data, read from the user's own copy of the app by [`official_excitation`].
219const OFFICIAL_PREFIX: [u8; 8] = [0xcf, 0xc0, 0x91, 0x37, 0x11, 0x79, 0x17, 0x37];
220const OFFICIAL_FNV1A64: u64 = 0x4aa0_4ced_5cff_86d2;
221
222fn fnv1a64(b: &[u8]) -> u64 {
223    b.iter().fold(0xcbf2_9ce4_8422_2325, |h, &c| (h ^ c as u64).wrapping_mul(0x0100_0000_01b3))
224}
225
226/// Find the official app's BLA excitation in one file's bytes.
227fn find_official(bytes: &[u8]) -> Option<Vec<f32>> {
228    let len = N * 4;
229    let mut i = 0;
230    while i + len <= bytes.len() {
231        let at = i + bytes[i..bytes.len() - len + OFFICIAL_PREFIX.len()].windows(8).position(|w| w == OFFICIAL_PREFIX)?;
232        let table = &bytes[at..at + len];
233        if fnv1a64(table) == OFFICIAL_FNV1A64 {
234            return Some(table.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect());
235        }
236        i = at + 1;
237    }
238    None
239}
240
241/// Read the official app's BLA excitation from an installed copy of Stomp Manager: a file (macOS
242/// `imagelib.framework/imagelib`) or a directory searched recursively (the `.app` bundle on macOS,
243/// the install folder on Windows). Nothing is executed; the table is located by a fingerprint.
244///
245/// With it, [`convert_with`] reproduces the official app's conversion (≤ 0.03 dB). Without it, the
246/// result differs by the measurement noise of the app's method (see [`convert`]): no other
247/// excitation, including one with the same spectrum, gets closer.
248pub fn official_excitation(path: impl AsRef<std::path::Path>) -> Result<Vec<f32>, super::NamError> {
249    fn search(p: &std::path::Path, depth: usize) -> Option<Vec<f32>> {
250        let meta = std::fs::symlink_metadata(p).ok()?;
251        if meta.is_file() {
252            // the table lives in a native library of a few MB; skip big media files
253            return (meta.len() < 200 << 20).then(|| std::fs::read(p).ok()).flatten().and_then(|b| find_official(&b));
254        }
255        if meta.is_dir() && depth < 12 {
256            let mut entries: Vec<_> = std::fs::read_dir(p).ok()?.filter_map(|e| e.ok().map(|e| e.path())).collect();
257            // look at likely places first
258            entries.sort_by_key(|e| {
259                let n = e.to_string_lossy().to_lowercase();
260                !(n.contains("imagelib") || n.contains("frameworks") || n.ends_with(".dll"))
261            });
262            return entries.iter().find_map(|e| search(e, depth + 1));
263        }
264        None
265    }
266    let p = path.as_ref();
267    search(p, 0).ok_or_else(|| {
268        super::NamError::Format(format!("no Stomp Manager BLA excitation found in {}", p.display()))
269    })
270}
271
272/// Standard install locations of the official app, for [`official_excitation`].
273pub fn official_app_locations() -> Vec<std::path::PathBuf> {
274    let mut v = vec![std::path::PathBuf::from("/Applications/Stomp Manager.app")];
275    if let Some(home) = std::env::var_os("HOME") {
276        v.push(std::path::Path::new(&home).join("Applications/Stomp Manager.app"));
277    }
278    for var in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
279        if let Some(dir) = std::env::var_os(var) {
280            v.push(std::path::Path::new(&dir).join("Stomp Manager"));
281        }
282    }
283    v
284}
285
286/// [`official_excitation`] from the first standard install location that has it.
287pub fn find_official_excitation() -> Option<Vec<f32>> {
288    official_app_locations().iter().filter(|p| p.exists()).find_map(|p| official_excitation(p).ok())
289}
290
291/// Our BLA excitation: the same spectrum as the app's (amplitude `√(1/max(f, 40 Hz))` per bin,
292/// overall scale 1e-5, about −90 dBFS peak), with phases from a fixed-seed generator.
293pub fn excitation() -> Vec<f32> {
294    let mut seed: u64 = 0x4f50_454e_5354_4f4d; // "OPENSTOM"
295    let mut next = || {
296        // xorshift64*
297        seed ^= seed >> 12;
298        seed ^= seed << 25;
299        seed ^= seed >> 27;
300        (seed.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11) as f64 / (1u64 << 53) as f64
301    };
302    let mut spec = vec![Complex64::default(); N];
303    for (k, s) in spec.iter_mut().enumerate().take(HALF).skip(1) {
304        let f = k as f64 * 48000.0 / N as f64;
305        let a = (1.0 / f.max(40.0)).sqrt();
306        // a·sin(φ + ωn) has DFT coefficient (N/2)·a·e^{j(φ−π/2)} at bin k
307        *s = Complex64::from_polar(N as f64 / 2.0 * a, next() * std::f64::consts::TAU - std::f64::consts::FRAC_PI_2);
308    }
309    // a DC term of the same magnitude as the flat low bins, like the app's (H[0] must be finite)
310    spec[0] = Complex64::new(N as f64 / 2.0 * (1.0f64 / 40.0).sqrt(), 0.0);
311    for k in 1..HALF {
312        spec[N - k] = spec[k].conj();
313    }
314    FftPlanner::new().plan_fft_inverse(N).process(&mut spec);
315    spec.iter().map(|c| (c.re / N as f64 * 1e-5) as f32).collect()
316}
317
318/// How the small-signal response `H` is measured.
319#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
320pub enum Measure {
321    /// Exactly as the official app: one 8192-sample run from the model's reset state, DC blocker
322    /// starting from zero. Below ~100 Hz the blocker's start-up transient dominates (docs/05), so
323    /// the low bass of the result follows that transient rather than the model.
324    App,
325    /// The excitation played twice and measured on the second, steady-state period: no transient,
326    /// the model's actual small-signal response at every frequency. Departs from the app on purpose,
327    /// and uploads only the taps the pedal runs ([`upload_data`]).
328    Clean,
329    /// [`Clean`](Measure::Clean), then G1/G2 fitted with a simulation of the pedal until it
330    /// sounds like the model ([`super::optimise`]). About a minute per model (optimised build).
331    /// Closer to the model than the app's conversion on hardware (docs/05); the default for
332    /// `Device::import_nam_file`.
333    #[default]
334    Optimised,
335}
336
337/// Convert a model with our excitation (see [`excitation`]), the app's way. Differs from the
338/// official app's result by the app's own measurement noise (1/3-octave response typically within
339/// 0.1–1.5 dB). For an exact match, use [`convert_with`] and [`official_excitation`].
340pub fn convert(model: &mut Model) -> Converted {
341    convert_with(model, &excitation())
342}
343
344/// Convert a model with a given 8192-sample BLA excitation, the app's way. Resets the model first.
345///
346/// With the official app's own excitation (its `BLA_x` table, which can be dumped from an installed
347/// copy with the open-stomp-manager `tools/namconv/harness`), the result matches the app's upload
348/// to within float rounding (≤ 0.03 dB in 1/3-octave response).
349pub fn convert_with(model: &mut Model, bla_x: &[f32]) -> Converted {
350    convert_measured(model, bla_x, Measure::App)
351}
352
353/// [`Measure::Optimised`] with progress: `on_step(done, total)` after each optimiser step (the
354/// clean measurement and the model render before the first take about a fifth of the time).
355/// Returning `false` stops early with the best result so far.
356pub fn convert_optimised(model: &mut Model, bla_x: &[f32], on_step: &mut dyn FnMut(usize, usize) -> bool) -> Converted {
357    let mut c = convert_measured(model, bla_x, Measure::Clean);
358    let o = super::optimise::optimise_with_progress(model, &c.data, &super::optimise::Options::default(), on_step);
359    (c.data, c.g1_ir, c.g2_ir) = (o.data, o.g1_ir, o.g2_ir);
360    c
361}
362
363/// The common output level imports are normalised to ([`normalise_level`]): the pedal's output, in
364/// dB re full scale, for pink noise at −18 dBFS into the pedal (the re-amp rig's units). Calibrated
365/// on the pedal so normalised imports play at the level of its built-in bass amps (Ampug SV Bass,
366/// Mes 400 Bass) with the same knobs (re-amped, `tools/pedalsim/nam_level_rig.py`).
367pub const NORMALISED_LEVEL_DB: f64 = -32.5;
368
369/// The pedal's output level for `model` with the optimiser's input/output calibration, in dB, for
370/// pink noise at −18 dBFS: what an upload that matches the model plays at (resets the model).
371pub fn pedal_level_db(model: &mut Model) -> f64 {
372    let sig = super::optimise::test_signal();
373    let seg = sig.segments.iter().find(|s| s.name == "pink -18").expect("the test signal has its pink segment");
374    let y = super::optimise::render_target(model, &sig.x[seg.start..seg.start + seg.len]);
375    // skip the first 100 ms (the model and DC blocker settling)
376    let y = &y[4800..];
377    10.0 * (y.iter().map(|v| v * v).sum::<f64>() / y.len() as f64).log10()
378}
379
380/// Normalise a conversion's output level like NAM plugins do, so every import plays at the same
381/// level ([`NORMALISED_LEVEL_DB`]) whatever level its model was captured at. Only G2 (after the
382/// pedal's saturation) is scaled, so this is a pure volume change: the tone and the way the amp
383/// breaks up stay the same. Returns the gain applied, in dB.
384pub fn normalise_level(c: &mut Converted, model: &mut Model) -> f64 {
385    let gain_db = NORMALISED_LEVEL_DB - pedal_level_db(model);
386    let g = 10f64.powf(gain_db / 20.0);
387    for v in &mut c.data[1024..] {
388        *v = (*v as f64 * g) as f32;
389    }
390    for v in &mut c.g2_ir {
391        *v *= g;
392    }
393    gain_db
394}
395
396/// Convert with a given excitation and measurement mode. Resets the model first.
397pub fn convert_measured(model: &mut Model, bla_x: &[f32], measure: Measure) -> Converted {
398    if measure == Measure::Optimised {
399        return convert_optimised(model, bla_x, &mut |_, _| true);
400    }
401    assert_eq!(bla_x.len(), N, "the excitation is {N} samples");
402    let fft = Fft::new();
403    let clean_h = (measure == Measure::Clean).then(|| {
404        model.reset();
405        let x2 = [bla_x, bla_x].concat();
406        let mut y2 = vec![0.0f32; 2 * N];
407        model.process(&x2, &mut y2);
408        DcBlocker::default().run(&mut y2);
409        let (py, px) = (fft.power(&y2[N..]), fft.power(bla_x));
410        py.iter().zip(&px).map(|(a, b)| a.sqrt() / b.sqrt()).collect::<Vec<f64>>()
411    });
412    model.reset();
413    let mut dc = DcBlocker::default();
414
415    // 1. best linear approximation (the run also sets the model and DC blocker state the tone
416    //    test starts from, as in the app)
417    let mut y = vec![0.0f32; N];
418    model.process(bla_x, &mut y);
419    dc.run(&mut y);
420    let h: Vec<f64> = match clean_h {
421        Some(h) => h,
422        None => {
423            let (py, px) = (fft.power(&y), fft.power(bla_x));
424            py.iter().zip(&px).map(|(a, b)| a.sqrt() / b.sqrt()).collect()
425        }
426    };
427    let h_db: Vec<f64> = h.iter().map(|v| 20.0 * v.log10()).collect();
428    let hs = smooth(&h);
429
430    // 2. tone test
431    let (mut levels, mut levels3) = (Vec::with_capacity(94), Vec::with_capacity(THIRD_TONES));
432    let mut x = vec![0.0f32; TONE_LEN];
433    let mut out = vec![0.0f32; TONE_LEN];
434    for (i, &b) in TONE_BINS.iter().enumerate() {
435        let w = b as f32 * 6.283_185_5 * (1.0 / N as f32);
436        for (n, v) in x.iter_mut().enumerate() {
437            *v = (n as f32 * w).sin();
438        }
439        model.process(&x, &mut out);
440        dc.run(&mut out);
441        let p = fft.power(&out[TONE_SETTLE..]);
442        levels.push(p[b].sqrt() * std::f64::consts::FRAC_PI_4 / 4096.0);
443        if i < THIRD_TONES {
444            levels3.push(p[3 * b].sqrt() * 3.0 * std::f64::consts::FRAC_PI_4 / 4096.0);
445        }
446    }
447
448    // 3. gains
449    let bins3: Vec<usize> = TONE_BINS[..THIRD_TONES].iter().map(|b| 3 * b).collect();
450    let f = interp_linear(&levels, &TONE_BINS, levels[0]);
451    let t = interp_linear(&levels3, &bins3, 0.0);
452    let g1_gains_db: Vec<f64> = TONE_BINS.iter().map(|&b| 20.0 * (hs[b] / f[b].max(t[b])).log10()).collect();
453
454    // 4. filters
455    let g1_db = interp_log(&g1_gains_db, &TONE_BINS);
456    let g2_db: Vec<f64> = h_db.iter().zip(&g1_db).map(|(a, b)| a - b).collect();
457    let (g1_ir, g2_ir) = (fft.min_phase(&g1_db), fft.min_phase(&g2_db));
458    let data = match measure {
459        // the app's layout, byte for byte (the pedal ignores the first 512 floats of each block)
460        Measure::App => g1_ir[..1024].iter().rev().chain(g2_ir[..1024].iter().rev()).map(|&v| v as f32).collect(),
461        Measure::Clean | Measure::Optimised => upload_data(&g1_ir, &g2_ir),
462    };
463    Converted { data, factor: 1.0, levels, levels3, bla_db: h_db, g1_gains_db, g1_ir, g2_ir }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn min_phase_reproduces_magnitude() {
472        let fft = Fft::new();
473        // a smooth tilt: +6 dB at DC falling to -6 dB at Nyquist
474        let db: Vec<f64> = (0..HALF).map(|k| 6.0 - 12.0 * k as f64 / HALF as f64).collect();
475        let ir = fft.min_phase(&db);
476        let mut c: Vec<Complex64> = ir.iter().map(|&v| Complex64::new(v, 0.0)).collect();
477        fft.fwd.process(&mut c);
478        for k in [1, 10, 100, 1000, 4000] {
479            assert!((20.0 * c[k].norm().log10() - db[k]).abs() < 0.01, "bin {k}");
480        }
481        // minimum phase: energy concentrated at the start
482        let e: f64 = ir.iter().map(|v| v * v).sum();
483        assert!(ir[..64].iter().map(|v| v * v).sum::<f64>() > 0.99 * e);
484    }
485
486    #[test]
487    fn flat_curve_is_an_impulse() {
488        let ir = Fft::new().min_phase(&vec![0.0; HALF]);
489        assert!((ir[0] - 1.0).abs() < 1e-9 && ir[1..].iter().all(|v| v.abs() < 1e-9));
490    }
491
492    #[test]
493    fn smoothing_keeps_a_constant() {
494        let s = smooth(&vec![3.0; HALF]);
495        assert!(s.iter().all(|v| (v - 3.0).abs() < 1e-9));
496    }
497
498    #[test]
499    fn interpolation() {
500        let bins = [10, 20, 40];
501        let l = interp_linear(&[1.0, 2.0, 4.0], &bins, 0.5);
502        assert_eq!((l[0], l[10], l[15], l[20], l[30], l[4000]), (0.5, 1.0, 1.5, 2.0, 3.0, 4.0));
503        let g = interp_log(&[0.0, 6.0, 12.0], &bins);
504        assert!((g[14] - 6.0 * (14f64 / 10.0).log10() / 2f64.log10()).abs() < 1e-12);
505        assert_eq!((g[0], g[4095]), (0.0, 12.0));
506    }
507
508    #[test]
509    fn upload_layout() {
510        let g1: Vec<f64> = (0..2000).map(|k| k as f64 + 1.0).collect();
511        let g2: Vec<f64> = (0..2000).map(|k| -(k as f64) - 1.0).collect();
512        let d = upload_data(&g1, &g2);
513        assert_eq!(d.len(), 2048);
514        assert!(d[..512].iter().chain(&d[1024..1536]).all(|&v| v == 0.0));
515        assert_eq!((d[1023], d[512], d[2047], d[1536]), (1.0, 512.0, -1.0, -512.0));
516        let (h1, h2) = pedal_irs(&d);
517        assert_eq!((h1.len(), h2.len()), (PEDAL_TAPS, PEDAL_TAPS));
518        assert!(h1.iter().zip(&g1).chain(h2.iter().zip(&g2)).all(|(a, b)| a == b));
519    }
520
521    #[test]
522    fn official_fingerprint_search() {
523        // a synthetic "table" with the right prefix but wrong contents is rejected
524        let mut fake = vec![0u8; 100];
525        fake.extend_from_slice(&OFFICIAL_PREFIX);
526        fake.resize(100 + N * 4 + 50, 7);
527        assert!(find_official(&fake).is_none());
528        assert!(find_official(&[1, 2, 3]).is_none());
529        assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325);
530    }
531
532    #[test]
533    fn excitation_spectrum() {
534        let x = excitation();
535        let fft = Fft::new();
536        let p = fft.power(&x);
537        for k in [10usize, 100, 1000, 4000] {
538            let want = N as f64 / 2.0 * (1.0 / (k as f64 * 48000.0 / N as f64)).sqrt() * 1e-5;
539            assert!((p[k].sqrt() / want - 1.0).abs() < 1e-3, "bin {k}");
540        }
541        let peak = x.iter().fold(0f32, |m, v| m.max(v.abs()));
542        assert!(peak > 1e-5 && peak < 1e-4, "about -90 dBFS: {peak}");
543    }
544}