Skip to main content

open_stomp/nam/
optimise.rs

1//! Optimised conversion: fit the two filters so the pedal itself sounds like the model (docs/05).
2//!
3//! The official converter designs G1 and G2 from two measurements and never looks at what the
4//! pedal does with them. This module simulates the pedal instead and adjusts G1/G2 until the
5//! simulated output matches the model's on a test signal:
6//!
7//! * **The pedal** (fitted from re-amp recordings, accurate to the rig's own repeatability):
8//!   `y = G2 ⊛ f(G1 ⊛ x)`, with both filters cut to [`PEDAL_TAPS`] and `f` the fixed saturation
9//!   curve [`saturation`].
10//! * **The target**: the model's output for the same signal (input −12 dB, DC-blocked, scaled to
11//!   the pedal's output level).
12//! * **The filters**: smooth log-magnitude curves at 41 control points (20 Hz × 2^(i/4)), each made
13//!   minimum-phase, as the official converter's filters are.
14//! * **The fit**: Adam on a multi-resolution STFT log-magnitude distance over sweeps, tones and pink
15//!   noise at several levels, starting from the [`Measure::Clean`](super::convert::Measure::Clean)
16//!   conversion. Gradients are written out by hand (no ML framework).
17//!
18//! A port of `tools/pedalsim` (PyTorch); `tests/data/optim` holds its reference
19//! values.
20
21use std::sync::Arc;
22
23use rustfft::num_complex::Complex64;
24use rustfft::{Fft, FftPlanner};
25
26use super::Model;
27use super::convert::PEDAL_TAPS;
28
29/// Sample rate of the test signal and the simulation.
30pub const SR: usize = 48000;
31
32// ---------------------------------------------------------------------------------------------
33// the pedal
34
35/// Knots of the saturation curve (input level, in test-signal units after G1).
36const F_KNOTS: [f64; 17] =
37    [-32.0, -16.0, -8.0, -4.0, -2.0, -1.0, -0.5, -0.25, 0.0, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0];
38/// The curve at [`F_KNOTS`], fitted jointly to re-amp recordings of three different uploads. The
39/// recordings barely reach beyond ±32, so the curve is held flat (saturated) there.
40const F_VALUES: [f64; 17] = [
41    -0.159341381678132,
42    -0.1515523482954498,
43    -0.12383923234960825,
44    -0.07909273657985298,
45    -0.04498259976139617,
46    -0.02245628731142803,
47    -0.011406034112795504,
48    -0.005583136803729503,
49    0.00018537827202383975,
50    0.005986027774350994,
51    0.01180106412522064,
52    0.02312483036632483,
53    0.045952939777459255,
54    0.08342816833097066,
55    0.13236822043215563,
56    0.15836762403716023,
57    0.16086942240926744,
58];
59
60/// Segment of the curve used for `u`: the knot interval `(k[i], k[i+1]]` holding it, or `None`
61/// outside the knots (flat).
62fn knot_interval(u: f64) -> Option<usize> {
63    if u <= F_KNOTS[0] || u > F_KNOTS[F_KNOTS.len() - 1] {
64        return None;
65    }
66    Some(F_KNOTS.partition_point(|&k| k < u) - 1)
67}
68
69/// The pedal's fixed saturation curve `f`, applied between G1 and G2 (piecewise linear).
70pub fn saturation(u: f64) -> f64 {
71    match knot_interval(u) {
72        None if u <= F_KNOTS[0] => F_VALUES[0],
73        None => F_VALUES[F_VALUES.len() - 1],
74        Some(i) => {
75            let t = (u - F_KNOTS[i]) / (F_KNOTS[i + 1] - F_KNOTS[i]);
76            F_VALUES[i] * (1.0 - t) + F_VALUES[i + 1] * t
77        }
78    }
79}
80
81/// `f'(u)` (the slope of the segment holding `u`; 0 outside the knots).
82fn saturation_slope(u: f64) -> f64 {
83    knot_interval(u).map_or(0.0, |i| (F_VALUES[i + 1] - F_VALUES[i]) / (F_KNOTS[i + 1] - F_KNOTS[i]))
84}
85
86// ---------------------------------------------------------------------------------------------
87// FFT helpers
88
89fn plan(n: usize) -> (Arc<dyn Fft<f64>>, Arc<dyn Fft<f64>>) {
90    let mut p = FftPlanner::new();
91    (p.plan_fft_forward(n), p.plan_fft_inverse(n))
92}
93
94fn complex(x: &[f64], n: usize) -> Vec<Complex64> {
95    let mut v: Vec<Complex64> = x.iter().take(n).map(|&r| Complex64::new(r, 0.0)).collect();
96    v.resize(n, Complex64::default());
97    v
98}
99
100/// Overlap-save FFT convolution of long signals with filters of up to [`PEDAL_TAPS`] taps, and
101/// its two adjoints (for the gradients).
102struct Conv {
103    fwd: Arc<dyn Fft<f64>>,
104    inv: Arc<dyn Fft<f64>>,
105}
106
107/// FFT block length of [`Conv`].
108const BLOCK: usize = 8192;
109/// Filter history each block carries.
110const HIST: usize = PEDAL_TAPS - 1;
111/// New output samples per block.
112const HOP: usize = BLOCK - HIST;
113
114impl Conv {
115    fn new() -> Self {
116        let (fwd, inv) = plan(BLOCK);
117        Conv { fwd, inv }
118    }
119
120    fn blocks(len: usize) -> usize {
121        len.div_ceil(HOP)
122    }
123
124    /// The spectrum of a filter (zero-padded to [`BLOCK`]).
125    fn filter(&self, h: &[f64]) -> Vec<Complex64> {
126        assert!(h.len() <= PEDAL_TAPS);
127        let mut v = complex(h, BLOCK);
128        self.fwd.process(&mut v);
129        v
130    }
131
132    /// The block spectra of a signal: block `b` covers `x[b·HOP − HIST .. b·HOP − HIST + BLOCK]`
133    /// (zero outside the signal).
134    fn signal(&self, x: &[f64]) -> Vec<Vec<Complex64>> {
135        par_chunks(Self::blocks(x.len()), |blocks| {
136            blocks
137                .map(|b| {
138                    let s = (b * HOP) as isize - HIST as isize;
139                    let mut v: Vec<Complex64> = (0..BLOCK as isize)
140                        .map(|j| {
141                            let i = s + j;
142                            Complex64::new(if i >= 0 && (i as usize) < x.len() { x[i as usize] } else { 0.0 }, 0.0)
143                        })
144                        .collect();
145                    self.fwd.process(&mut v);
146                    v
147                })
148                .collect::<Vec<_>>()
149        })
150        .concat()
151    }
152
153    /// `y = h ⊛ x` (first `len` samples), from the block spectra of `x`.
154    fn apply(&self, blocks: &[Vec<Complex64>], h: &[Complex64], len: usize) -> Vec<f64> {
155        let mut y = par_chunks(blocks.len(), |range| {
156            let mut out = Vec::with_capacity(range.len() * HOP);
157            let mut buf = vec![Complex64::default(); BLOCK];
158            for xb in &blocks[range] {
159                for ((o, a), c) in buf.iter_mut().zip(xb).zip(h) {
160                    *o = a * c;
161                }
162                self.inv.process(&mut buf);
163                out.extend(buf[HIST..].iter().map(|v| v.re / BLOCK as f64));
164            }
165            out
166        })
167        .concat();
168        y.truncate(len);
169        y
170    }
171
172    /// The adjoint of [`Conv::apply`] for the signal: `gx[m] = Σ_k h[k]·g[m + k]`.
173    fn correlate(&self, g: &[f64], h: &[Complex64]) -> Vec<f64> {
174        let mut gx = par_chunks(Self::blocks(g.len()), |range| {
175            let mut out = Vec::with_capacity(range.len() * HOP);
176            let mut buf = vec![Complex64::default(); BLOCK];
177            for b in range {
178                let s = b * HOP;
179                for (j, o) in buf.iter_mut().enumerate() {
180                    *o = Complex64::new(g.get(s + j).copied().unwrap_or(0.0), 0.0);
181                }
182                self.fwd.process(&mut buf);
183                for (o, c) in buf.iter_mut().zip(h) {
184                    *o *= c.conj();
185                }
186                self.inv.process(&mut buf);
187                out.extend(buf[..HOP].iter().map(|v| v.re / BLOCK as f64));
188            }
189            out
190        })
191        .concat();
192        gx.truncate(g.len());
193        gx
194    }
195
196    /// The adjoint of [`Conv::apply`] for the filter: `gh[k] = Σ_n g[n]·x[n − k]`, `k <`
197    /// [`PEDAL_TAPS`], from the block spectra of `x`.
198    fn filter_grad(&self, blocks: &[Vec<Complex64>], g: &[f64]) -> Vec<f64> {
199        let parts = par_chunks(blocks.len(), |range| {
200            let mut acc = vec![Complex64::default(); BLOCK];
201            let mut buf = vec![Complex64::default(); BLOCK];
202            for b in range {
203                let s = b * HOP;
204                buf.fill(Complex64::default());
205                for (j, &v) in g[s..(s + HOP).min(g.len())].iter().enumerate() {
206                    buf[HIST + j].re = v;
207                }
208                self.fwd.process(&mut buf);
209                for ((a, gb), xc) in acc.iter_mut().zip(&buf).zip(&blocks[b]) {
210                    *a += gb * xc.conj();
211                }
212            }
213            acc
214        });
215        let mut acc = vec![Complex64::default(); BLOCK];
216        for p in parts {
217            acc.iter_mut().zip(p).for_each(|(a, v)| *a += v);
218        }
219        self.inv.process(&mut acc);
220        acc[..PEDAL_TAPS].iter().map(|c| c.re / BLOCK as f64).collect()
221    }
222}
223
224/// `f` on contiguous ranges covering `0..n`, one per thread, results in order.
225fn par_chunks<T: Send>(n: usize, f: impl Fn(std::ops::Range<usize>) -> T + Sync) -> Vec<T> {
226    let threads = std::thread::available_parallelism().map_or(1, |t| t.get()).clamp(1, 16).min(n.max(1));
227    let per = n.div_ceil(threads);
228    if threads == 1 {
229        return vec![f(0..n)];
230    }
231    std::thread::scope(|s| {
232        let f = &f;
233        let handles: Vec<_> =
234            (0..threads).map(|t| s.spawn(move || f((t * per).min(n)..((t + 1) * per).min(n)))).collect();
235        handles.into_iter().map(|h| h.join().expect("optimiser thread")).collect()
236    })
237}
238
239/// `f(i)` for `i` in `0..n` on all threads (work-stealing), results in order.
240fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync) -> Vec<T> {
241    use std::sync::atomic::{AtomicUsize, Ordering};
242    let next = AtomicUsize::new(0);
243    let threads = std::thread::available_parallelism().map_or(1, |t| t.get()).clamp(1, 16).min(n.max(1));
244    let mut done: Vec<(usize, T)> = std::thread::scope(|s| {
245        let (f, next) = (&f, &next);
246        let handles: Vec<_> = (0..threads)
247            .map(|_| {
248                s.spawn(move || {
249                    let mut v = Vec::new();
250                    loop {
251                        let i = next.fetch_add(1, Ordering::Relaxed);
252                        if i >= n {
253                            return v;
254                        }
255                        v.push((i, f(i)));
256                    }
257                })
258            })
259            .collect();
260        handles.into_iter().flat_map(|h| h.join().expect("optimiser thread")).collect()
261    });
262    done.sort_by_key(|(i, _)| *i);
263    done.into_iter().map(|(_, t)| t).collect()
264}
265
266// ---------------------------------------------------------------------------------------------
267// the test signal
268
269/// A named part of the [`TestSignal`].
270#[derive(Clone, Debug, PartialEq)]
271pub struct Segment {
272    pub name: String,
273    pub start: usize,
274    pub len: usize,
275    /// Part of the fit (the rest is only simulated).
276    pub train: bool,
277}
278
279/// The signal the optimiser matches the pedal to the model on (all at [`SR`]).
280#[derive(Clone, Debug)]
281pub struct TestSignal {
282    pub x: Vec<f64>,
283    pub segments: Vec<Segment>,
284}
285
286/// xorshift64* with Box–Muller normals: a fixed, portable noise source.
287struct Rng(u64);
288
289impl Rng {
290    fn uniform(&mut self) -> f64 {
291        self.0 ^= self.0 >> 12;
292        self.0 ^= self.0 << 25;
293        self.0 ^= self.0 >> 27;
294        ((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11) as f64 + 0.5) / (1u64 << 53) as f64
295    }
296
297    fn normal(&mut self) -> f64 {
298        let (a, b) = (self.uniform(), self.uniform());
299        (-2.0 * a.ln()).sqrt() * (std::f64::consts::TAU * b).cos()
300    }
301}
302
303/// `n` samples of pink noise at unit rms: white noise with its spectrum divided by `√weight(k)`
304/// (`weight(0)` is the DC bin).
305fn pink(n: usize, seed: u64, weight: impl Fn(usize) -> Option<f64>) -> Vec<f64> {
306    let mut rng = Rng(seed);
307    let mut v: Vec<Complex64> = (0..n).map(|_| Complex64::new(rng.normal(), 0.0)).collect();
308    let (fwd, inv) = plan(n);
309    fwd.process(&mut v);
310    for k in 0..=n / 2 {
311        let g = weight(k).map_or(0.0, |w| 1.0 / w.sqrt());
312        v[k] *= g;
313        if k != 0 && k != n - k {
314            v[n - k] *= g;
315        }
316    }
317    inv.process(&mut v);
318    let x: Vec<f64> = v.iter().map(|c| c.re).collect();
319    let rms = (x.iter().map(|a| a * a).sum::<f64>() / n as f64).sqrt();
320    x.iter().map(|a| a / rms).collect()
321}
322
323/// The test signal (about 53 s): the re-amp signal the pedal model was fitted on (log sweeps at
324/// −36/−24/−12/−6 dBFS, bass tones at three levels, pink noise at −18 dBFS) followed by pink noise
325/// at −36, −24 and −12 dBFS. The fit uses everything but the −18 dBFS pink noise.
326pub fn test_signal() -> TestSignal {
327    let mut x: Vec<f64> = Vec::new();
328    let mut segments = Vec::new();
329    let mut add = |name: String, mut s: Vec<f64>, gap: usize, fade: bool, train: bool, x: &mut Vec<f64>| {
330        if fade {
331            let n = 480;
332            for i in 0..n {
333                let g = i as f64 / (n - 1) as f64;
334                s[i] *= g;
335                let j = s.len() - n + i;
336                s[j] *= 1.0 - g;
337            }
338            // played as float32
339            s.iter_mut().for_each(|v| *v = *v as f32 as f64);
340        }
341        segments.push(Segment { name, start: x.len(), len: s.len(), train });
342        x.extend_from_slice(&s);
343        x.resize(x.len() + gap, 0.0);
344    };
345    let db = |d: f64| 10f64.powf(d / 20.0);
346
347    // log sweeps, 20 Hz – 20 kHz in 4 s
348    let (t_len, f1, f2): (f64, f64, f64) = (4.0, 20.0, 20000.0);
349    let l = t_len / (f2 / f1).ln();
350    let sweep: Vec<f64> = (0..(t_len * SR as f64) as usize)
351        .map(|i| (std::f64::consts::TAU * f1 * l * ((i as f64 / SR as f64 / l).exp() - 1.0)).sin())
352        .collect();
353    for d in [-36.0, -24.0, -12.0, -6.0] {
354        add(format!("sweep {d}"), sweep.iter().map(|v| v * db(d)).collect(), SR * 6 / 10, true, true, &mut x);
355    }
356    // 1 s tones
357    for f in [41.2, 55.0, 73.4, 98.0, 196.0] {
358        for d in [-24.0, -12.0, -6.0] {
359            let tone = (0..SR).map(|i| (std::f64::consts::TAU * f * (i as f64 / SR as f64)).sin() * db(d)).collect();
360            add(format!("tone {f:.1} {d}"), tone, SR * 3 / 10, true, true, &mut x);
361        }
362    }
363    // pink noise (spectrum / √bin), not used in the fit
364    let p = pink(3 * SR, 1, |k| Some(k.max(1) as f64));
365    add("pink -18".into(), p.iter().map(|v| v * db(-18.0)).collect(), SR * 6 / 10, true, false, &mut x);
366    // pink noise for the fit (spectrum / √f, nothing below 20 Hz), at three levels
367    for (i, d) in [-30.0, -18.0, -6.0].into_iter().enumerate() {
368        x.resize(x.len() + SR / 2, 0.0);
369        let n = 3 * SR;
370        let p = pink(n, 1000 + i as u64, |k| {
371            let f = k as f64 * SR as f64 / n as f64;
372            (f >= 20.0).then_some(f)
373        });
374        // × 0.5 (≈ −6 dB)
375        add(format!("synth pink {}", d - 6.0), p.iter().map(|v| v * db(d) * 0.5).collect(), 0, false, true, &mut x);
376    }
377    x.resize(x.len() + SR / 2, 0.0);
378    TestSignal { x, segments }
379}
380
381// ---------------------------------------------------------------------------------------------
382// the target
383
384/// Model input level relative to the test signal (dB) and the pedal's output level relative to
385/// the model's, measured on the re-amp rig (an Ampeg SVT bass model; `nam_calibration`).
386const TARGET_GAIN_IN_DB: f64 = -12.0;
387const TARGET_GAIN_OUT: f64 = 0.09316022181625924;
388
389/// What the pedal should output for `x`: the model's output (input −12 dB, from its reset state),
390/// through the app's DC blocker, at the pedal's output level.
391pub fn render_target(model: &mut Model, x: &[f64]) -> Vec<f64> {
392    let gin = 10f64.powf(TARGET_GAIN_IN_DB / 20.0);
393    let input: Vec<f32> = x.iter().map(|v| (v * gin) as f32).collect();
394    let mut out = vec![0.0f32; x.len()];
395    model.reset();
396    model.process(&input, &mut out);
397    // the DC blocker in f64
398    const B: [f64; 3] = [0.9953827, -1.990765, 0.9953827];
399    const A: [f64; 2] = [-1.990744, 0.9907867];
400    let (mut x1, mut x2, mut y1, mut y2) = (0.0, 0.0, 0.0, 0.0);
401    out.iter()
402        .map(|&v| {
403            let x0 = v as f64;
404            let y0 = B[0] * x0 + B[1] * x1 + B[2] * x2 - A[0] * y1 - A[1] * y2;
405            (x2, x1, y2, y1) = (x1, x0, y1, y0);
406            y0 * TARGET_GAIN_OUT
407        })
408        .collect()
409}
410
411// ---------------------------------------------------------------------------------------------
412// the simulator
413
414/// The pedal simulator for one input signal (its block spectra are computed once).
415pub struct Simulator {
416    conv: Conv,
417    len: usize,
418    x: Vec<Vec<Complex64>>,
419}
420
421/// One simulation, with what the gradients need.
422struct Run {
423    /// `G1 ⊛ x`, the input of the saturation.
424    u: Vec<f64>,
425    /// Block spectra of `f(u)`.
426    v: Vec<Vec<Complex64>>,
427    /// The output.
428    y: Vec<f64>,
429}
430
431impl Simulator {
432    pub fn new(x: &[f64]) -> Self {
433        let conv = Conv::new();
434        let blocks = conv.signal(x);
435        Simulator { conv, len: x.len(), x: blocks }
436    }
437
438    fn run(&self, h1: &[f64], h2: &[f64]) -> Run {
439        let u = self.conv.apply(&self.x, &self.conv.filter(h1), self.len);
440        let fu: Vec<f64> = u.iter().map(|&a| saturation(a)).collect();
441        let v = self.conv.signal(&fu);
442        let y = self.conv.apply(&v, &self.conv.filter(h2), self.len);
443        Run { u, v, y }
444    }
445
446    /// Gradients w.r.t. `h1` and `h2` of `Σ gy·y + PENALTY(u)` for a run.
447    fn backward(&self, run: &Run, h2: &[f64], gy: &[f64]) -> (Vec<f64>, Vec<f64>) {
448        let gh2 = self.conv.filter_grad(&run.v, gy);
449        let gv = self.conv.correlate(gy, &self.conv.filter(h2));
450        let gu: Vec<f64> = gv.iter().zip(&run.u).map(|(g, &u)| g * saturation_slope(u) + penalty_grad(u, self.len)).collect();
451        let gh1 = self.conv.filter_grad(&self.x, &gu);
452        (gh1, gh2)
453    }
454
455    /// The pedal's output for impulse responses `h1` (G1) and `h2` (G2), up to [`PEDAL_TAPS`] each.
456    pub fn output(&self, h1: &[f64], h2: &[f64]) -> Vec<f64> {
457        self.run(h1, h2).y
458    }
459}
460
461/// Keeps the input of the saturation inside the range the curve was fitted on:
462/// `1e-3 · mean(max(|u| − 100, 0)²)`.
463const PENALTY_LIMIT: f64 = 100.0;
464const PENALTY_WEIGHT: f64 = 1e-3;
465
466fn penalty(u: &[f64]) -> f64 {
467    PENALTY_WEIGHT * u.iter().map(|v| (v.abs() - PENALTY_LIMIT).max(0.0).powi(2)).sum::<f64>() / u.len() as f64
468}
469
470fn penalty_grad(u: f64, len: usize) -> f64 {
471    PENALTY_WEIGHT * 2.0 * (u.abs() - PENALTY_LIMIT).max(0.0) * u.signum() / len as f64
472}
473
474/// The pedal's output for `x` with user AMP `data` (2048 floats as uploaded).
475pub fn simulate(data: &[f32], x: &[f64]) -> Vec<f64> {
476    let (h1, h2) = super::convert::pedal_irs(data);
477    Simulator::new(x).output(&h1, &h2)
478}
479
480// ---------------------------------------------------------------------------------------------
481// the filters: smooth curves → minimum-phase responses
482
483/// Control points of the filter curves: 41, a quarter octave apart from 20 Hz (to 20.48 kHz).
484pub const CURVE_POINTS: usize = 41;
485/// FFT length of the minimum-phase design (as the converter's).
486const MP_N: usize = 8192;
487const MP_BINS: usize = MP_N / 2 + 1;
488
489/// Control-point frequencies, as log10(Hz).
490fn control_points() -> [f64; CURVE_POINTS] {
491    std::array::from_fn(|i| 20f64.log10() + i as f64 * 2f64.log10() / 4.0)
492}
493
494/// log10 of each design bin's frequency (bin 0 counts as 1 mHz).
495fn bin_log_freqs() -> Vec<f64> {
496    (0..MP_BINS).map(|k| (k as f64 * SR as f64 / MP_N as f64).max(1e-3).log10()).collect()
497}
498
499/// Curves (natural-log magnitude at the control points) → minimum-phase impulse responses, and back.
500struct Curves {
501    /// Per design bin: the lower control point and the weight of the upper one (linear in
502    /// log-frequency, held outside the control points).
503    interp: Vec<(usize, f64)>,
504    fwd: Arc<dyn Fft<f64>>,
505    inv: Arc<dyn Fft<f64>>,
506}
507
508/// One design, with what the gradient needs.
509struct Design {
510    /// `exp(C)`, the minimum-phase spectrum (bins 0..=N/2).
511    e: Vec<Complex64>,
512    h: Vec<f64>,
513}
514
515impl Curves {
516    fn new() -> Self {
517        let cp = control_points();
518        let interp = bin_log_freqs()
519            .iter()
520            .map(|&lf| {
521                let i = (cp.partition_point(|&c| c < lf).max(1) - 1).min(CURVE_POINTS - 2);
522                (i, ((lf - cp[i]) / (cp[i + 1] - cp[i])).clamp(0.0, 1.0))
523            })
524            .collect();
525        let (fwd, inv) = plan(MP_N);
526        Curves { interp, fwd, inv }
527    }
528
529    /// Fold doubling the causal part of a real cepstrum.
530    fn fold(n: usize) -> f64 {
531        match n {
532            0 => 1.0,
533            n if n < MP_N / 2 => 2.0,
534            n if n == MP_N / 2 => 1.0,
535            _ => 0.0,
536        }
537    }
538
539    /// Curve → [`PEDAL_TAPS`]-tap minimum-phase impulse response: the log magnitude interpolated
540    /// over the design bins, folded cepstrum, `exp`, first taps.
541    fn design(&self, curve: &[f64]) -> Design {
542        let l: Vec<f64> = self.interp.iter().map(|&(i, t)| curve[i] * (1.0 - t) + curve[i + 1] * t).collect();
543        // real cepstrum of the (even) log magnitude, folded onto positive quefrencies
544        let mut c: Vec<Complex64> = (0..MP_N).map(|k| Complex64::new(l[k.min(MP_N - k)], 0.0)).collect();
545        self.inv.process(&mut c);
546        for (n, v) in c.iter_mut().enumerate() {
547            *v = Complex64::new(v.re / MP_N as f64 * Self::fold(n), 0.0);
548        }
549        self.fwd.process(&mut c);
550        let e: Vec<Complex64> = c[..MP_BINS].iter().map(|v| v.exp()).collect();
551        // real inverse transform of the half spectrum (the imaginary parts at DC and Nyquist are dropped)
552        let mut z = vec![Complex64::default(); MP_N];
553        z[0] = Complex64::new(e[0].re, 0.0);
554        z[MP_N / 2] = Complex64::new(e[MP_N / 2].re, 0.0);
555        for k in 1..MP_N / 2 {
556            z[k] = e[k];
557            z[MP_N - k] = e[k].conj();
558        }
559        self.inv.process(&mut z);
560        let h = z[..PEDAL_TAPS].iter().map(|v| v.re / MP_N as f64).collect();
561        Design { e, h }
562    }
563
564    /// Gradient w.r.t. the curve, given the gradient `gh` w.r.t. the response of `design`.
565    fn backward(&self, design: &Design, gh: &[f64]) -> Vec<f64> {
566        let n = MP_N as f64;
567        // through h = irfft(E)
568        let mut g = complex(gh, MP_N);
569        self.fwd.process(&mut g);
570        let mut z = vec![Complex64::default(); MP_N];
571        for k in 0..MP_BINS {
572            let ge = if k == 0 || k == MP_N / 2 { Complex64::new(g[k].re / n, 0.0) } else { g[k] * (2.0 / n) };
573            // through E = exp(C)
574            z[k] = ge * design.e[k].conj();
575        }
576        // through C = rfft(folded cepstrum), the fold, and the cepstrum = irfft(l)
577        self.inv.process(&mut z);
578        let mut gc: Vec<Complex64> = z.iter().enumerate().map(|(i, v)| Complex64::new(v.re * Self::fold(i), 0.0)).collect();
579        self.fwd.process(&mut gc);
580        let mut out = vec![0.0; CURVE_POINTS];
581        for (k, &(i, t)) in self.interp.iter().enumerate() {
582            let gl = gc[k].re * if k == 0 || k == MP_N / 2 { 1.0 } else { 2.0 } / n;
583            out[i] += gl * (1.0 - t);
584            out[i + 1] += gl * t;
585        }
586        out
587    }
588
589    /// The curve of an existing response: the mean natural-log magnitude within ±1/16 decade of
590    /// each control point.
591    fn of(&self, h: &[f64]) -> Vec<f64> {
592        let mut s = complex(h, MP_N);
593        self.fwd.process(&mut s);
594        let lf = bin_log_freqs();
595        control_points()
596            .iter()
597            .map(|&c| {
598                let (sum, n) = (0..MP_BINS)
599                    .filter(|&k| lf[k] >= c - 0.0625 && lf[k] < c + 0.0625)
600                    .fold((0.0, 0), |(a, n), k| (a + (s[k].norm() + 1e-9).ln(), n + 1));
601                sum / n as f64
602            })
603            .collect()
604    }
605}
606
607// ---------------------------------------------------------------------------------------------
608// the loss
609
610/// STFT resolutions: (window length, hop).
611const RESOLUTIONS: [(usize, usize); 3] = [(4096, 1024), (1024, 256), (256, 64)];
612/// Samples left out at each end of a segment (fades, filter onsets).
613const TRIM: usize = 2400;
614
615struct Resolution {
616    n: usize,
617    hop: usize,
618    window: Vec<f64>,
619    fwd: Arc<dyn Fft<f64>>,
620    inv: Arc<dyn Fft<f64>>,
621}
622
623impl Resolution {
624    fn new(n: usize, hop: usize) -> Self {
625        let window = (0..n).map(|j| 0.5 - 0.5 * (std::f64::consts::TAU * j as f64 / n as f64).cos()).collect();
626        let (fwd, inv) = plan(n);
627        Resolution { n, hop, window, fwd, inv }
628    }
629
630    fn frames(&self, len: usize) -> usize {
631        1 + len / self.hop
632    }
633
634    /// Index into `x` of sample `i` of the centred, reflect-padded signal.
635    fn source(&self, i: usize, len: usize) -> usize {
636        let pad = self.n / 2;
637        if i < pad {
638            pad - i
639        } else if i < pad + len {
640            i - pad
641        } else {
642            2 * (len - 1) + pad - i
643        }
644    }
645
646    /// The STFT (bins 0..=n/2), frame-major (`frames × (n/2 + 1)`).
647    fn stft(&self, x: &[f64]) -> Vec<Complex64> {
648        let (len, bins) = (x.len(), self.n / 2 + 1);
649        let mut out = Vec::with_capacity(self.frames(len) * bins);
650        let mut buf = vec![Complex64::default(); self.n];
651        for f in 0..self.frames(len) {
652            for (j, b) in buf.iter_mut().enumerate() {
653                *b = Complex64::new(self.window[j] * x[self.source(f * self.hop + j, len)], 0.0);
654            }
655            self.fwd.process(&mut buf);
656            out.extend_from_slice(&buf[..bins]);
657        }
658        out
659    }
660
661    /// The adjoint of [`Resolution::stft`]: adds to `gx` the gradient for STFT gradients `gs`.
662    fn stft_adjoint(&self, gs: &[Complex64], gx: &mut [f64]) {
663        let (len, bins) = (gx.len(), self.n / 2 + 1);
664        let mut buf = vec![Complex64::default(); self.n];
665        for (f, g) in gs.chunks_exact(bins).enumerate() {
666            buf.fill(Complex64::default());
667            buf[..bins].copy_from_slice(g);
668            self.inv.process(&mut buf);
669            for (j, b) in buf.iter().enumerate() {
670                gx[self.source(f * self.hop + j, len)] += self.window[j] * b.re;
671            }
672        }
673    }
674
675    /// `|STFT(x)|`, frame-major (`frames × (n/2 + 1)`), as `torch.stft(center=True,
676    /// pad_mode="reflect")` with a periodic Hann window.
677    fn magnitude(&self, x: &[f64]) -> Vec<f64> {
678        self.stft(x).iter().map(|c| c.norm()).collect()
679    }
680}
681
682/// How the loss weights the STFT bins.
683#[derive(Clone, Copy, Debug, PartialEq, Eq)]
684pub enum Weighting {
685    /// Every bin the same: the bins are evenly spaced in Hz, so the octave below 100 Hz gets about
686    /// 0.5% of the weight and the fit gives the low end away (the hardware-validated 0.3 loss).
687    Flat,
688    /// Bin weights ∝ 1/f above 40 Hz (each octave counts about the same), flat from 20 to 40 Hz,
689    /// nothing below 20 Hz; normalised to mean 1 per resolution.
690    Octave,
691}
692
693impl Weighting {
694    /// The weight of each bin (0..=n/2) of an `n`-point STFT.
695    fn bins(self, n: usize) -> Vec<f64> {
696        let f = |k: usize| k as f64 * SR as f64 / n as f64;
697        let w: Vec<f64> = match self {
698            Weighting::Flat => vec![1.0; n / 2 + 1],
699            Weighting::Octave => (0..=n / 2).map(|k| if f(k) < 20.0 { 0.0 } else { 1.0 / f(k).max(40.0) }).collect(),
700        };
701        let mean = w.iter().sum::<f64>() / w.len() as f64;
702        w.iter().map(|v| v / mean).collect()
703    }
704}
705
706/// Multi-resolution STFT log-magnitude L1 distance to a target, over segments.
707struct SpecLoss {
708    res: Vec<Resolution>,
709    /// Per resolution, the weight of each bin.
710    weights: Vec<Vec<f64>>,
711    /// `(start, end, floor, log |STFT(target)| per resolution)`; floor = 1e-3 · rms(target) · √n is
712    /// added to every magnitude.
713    parts: Vec<(usize, usize, f64, Vec<Vec<f64>>)>,
714}
715
716impl SpecLoss {
717    fn new(target: &[f64], slices: &[(usize, usize)], weighting: Weighting) -> Self {
718        let res: Vec<Resolution> = RESOLUTIONS.iter().map(|&(n, h)| Resolution::new(n, h)).collect();
719        let weights = res.iter().map(|r| weighting.bins(r.n)).collect();
720        let parts = slices
721            .iter()
722            .map(|&(a, b)| {
723                let t = &target[a..b];
724                let eps = 1e-3 * (t.iter().map(|v| v * v).sum::<f64>() / t.len() as f64).sqrt();
725                let logs = res
726                    .iter()
727                    .map(|r| {
728                        let e = eps * (r.n as f64).sqrt();
729                        r.magnitude(t).iter().map(|m| (m + e).ln()).collect()
730                    })
731                    .collect();
732                (a, b, eps, logs)
733            })
734            .collect();
735        SpecLoss { res, weights, parts }
736    }
737
738    /// The loss of `y` and its gradient (added to `gy`).
739    fn value_grad(&self, y: &[f64], gy: &mut [f64]) -> f64 {
740        let scale = 1.0 / (self.parts.len() * self.res.len()) as f64;
741        // one task per segment and resolution, biggest first
742        let tasks: Vec<(usize, usize)> = (0..self.parts.len()).flat_map(|p| (0..self.res.len()).map(move |r| (p, r))).collect();
743        let results = par_map(tasks.len(), |i| {
744            let (p, r) = tasks[i];
745            let ((a, b, eps, logs), res) = (&self.parts[p], &self.res[r]);
746            let e = eps * (res.n as f64).sqrt();
747            let mut s = res.stft(&y[*a..*b]);
748            let k = scale / s.len() as f64;
749            let w = &self.weights[r];
750            let mut sum = 0.0;
751            for (i, (c, t)) in s.iter_mut().zip(&logs[r]).enumerate() {
752                let wi = w[i % w.len()];
753                let m = c.norm();
754                let d = (m + e).ln() - t;
755                sum += wi * d.abs();
756                // d(w|d|)/dS = w · sign(d) / (|S| + e) · S / |S|
757                *c = if m > 0.0 { *c * (k * wi * d.signum() / ((m + e) * m)) } else { Complex64::default() };
758            }
759            let mut g = vec![0.0; b - a];
760            res.stft_adjoint(&s, &mut g);
761            (sum / s.len() as f64, g)
762        });
763        let mut tot = 0.0;
764        for ((p, _), (v, g)) in tasks.iter().zip(results) {
765            tot += v;
766            let a = self.parts[*p].0;
767            gy[a..a + g.len()].iter_mut().zip(g).for_each(|(o, v)| *o += v);
768        }
769        tot * scale
770    }
771
772    #[cfg(test)]
773    /// The loss of `y` (mean over segments and resolutions of the mean absolute log difference).
774    fn value(&self, y: &[f64]) -> f64 {
775        let mut tot = 0.0;
776        for (a, b, eps, logs) in &self.parts {
777            for ((r, t), w) in self.res.iter().zip(logs).zip(&self.weights) {
778                let e = eps * (r.n as f64).sqrt();
779                let m = r.magnitude(&y[*a..*b]);
780                tot += m.iter().zip(t).enumerate().map(|(i, (m, t))| w[i % w.len()] * ((m + e).ln() - t).abs()).sum::<f64>()
781                    / m.len() as f64;
782            }
783        }
784        tot / (self.parts.len() * self.res.len()) as f64
785    }
786}
787
788/// The part of each training segment the loss looks at.
789fn train_slices(segments: &[Segment]) -> Vec<(usize, usize)> {
790    segments.iter().filter(|s| s.train).map(|s| (s.start + TRIM, s.start + s.len - TRIM)).collect()
791}
792
793// ---------------------------------------------------------------------------------------------
794// the fit
795
796/// Optimiser settings (the defaults are the ones validated on hardware).
797#[derive(Clone, Debug)]
798pub struct Options {
799    /// Adam steps.
800    pub steps: usize,
801    /// Peak learning rate (in natural-log magnitude per step), reached after `warmup` steps and
802    /// then decayed to 0 along a half cosine.
803    pub learning_rate: f64,
804    pub warmup: usize,
805    /// Weight of the curve smoothness term (mean squared second difference of each curve).
806    pub smoothness: f64,
807    /// How the loss weights frequencies.
808    pub weighting: Weighting,
809}
810
811impl Default for Options {
812    fn default() -> Self {
813        Options { steps: 300, learning_rate: 0.05, warmup: 10, smoothness: 1e-3, weighting: Weighting::Octave }
814    }
815}
816
817/// The result of [`optimise`].
818#[derive(Clone, Debug)]
819pub struct Optimised {
820    /// The 2048 floats to upload ([`upload_data`](super::convert::upload_data) of the responses).
821    pub data: Vec<f32>,
822    /// G1 and G2 as the pedal runs them ([`PEDAL_TAPS`] taps).
823    pub g1_ir: Vec<f64>,
824    pub g2_ir: Vec<f64>,
825    /// The two curves: natural-log magnitude at the [`CURVE_POINTS`] control points.
826    pub g1_curve: Vec<f64>,
827    pub g2_curve: Vec<f64>,
828    /// The objective at the start and per step; the result is the lowest one seen.
829    pub initial_loss: f64,
830    pub loss: Vec<f64>,
831}
832
833/// Something for G1 and G2.
834type Pair = [Vec<f64>; 2];
835
836/// A fitting problem: a signal, the target output for it and the parts to compare.
837struct Problem {
838    sim: Simulator,
839    loss: SpecLoss,
840    curves: Curves,
841}
842
843/// One evaluation of the objective.
844struct Eval {
845    value: f64,
846    grads: [Vec<f64>; 2],
847    irs: [Vec<f64>; 2],
848}
849
850impl Problem {
851    fn new(x: &[f64], target: &[f64], slices: &[(usize, usize)], weighting: Weighting) -> Self {
852        Problem { sim: Simulator::new(x), loss: SpecLoss::new(target, slices, weighting), curves: Curves::new() }
853    }
854
855    /// The objective for two curves, and its gradient w.r.t. them.
856    fn evaluate(&self, c: &[Vec<f64>; 2], smoothness: f64) -> Eval {
857        let d = [self.curves.design(&c[0]), self.curves.design(&c[1])];
858        let run = self.sim.run(&d[0].h, &d[1].h);
859        let mut gy = vec![0.0; run.y.len()];
860        let mut value = self.loss.value_grad(&run.y, &mut gy) + penalty(&run.u);
861        let (gh1, gh2) = self.sim.backward(&run, &d[1].h, &gy);
862        let mut grads = [self.curves.backward(&d[0], &gh1), self.curves.backward(&d[1], &gh2)];
863        for (cv, g) in c.iter().zip(&mut grads) {
864            let n = (cv.len() - 2) as f64;
865            for i in 0..cv.len() - 2 {
866                let dd = cv[i] - 2.0 * cv[i + 1] + cv[i + 2];
867                value += smoothness * dd * dd / n;
868                let k = smoothness * 2.0 * dd / n;
869                g[i] += k;
870                g[i + 1] -= 2.0 * k;
871                g[i + 2] += k;
872            }
873        }
874        let [Design { h: h1, .. }, Design { h: h2, .. }] = d;
875        Eval { value, grads, irs: [h1, h2] }
876    }
877
878    /// Adam from curves `c`; returns the best curves and responses seen, and the objective per step.
879    /// `on_step(done, total)` after each step; returning `false` stops early (best so far kept).
880    fn fit(&self, mut c: Pair, o: &Options, on_step: &mut dyn FnMut(usize, usize) -> bool) -> (Pair, Pair, Vec<f64>) {
881        let (b1, b2, eps) = (0.9, 0.999, 1e-8);
882        let mut m = [vec![0.0; CURVE_POINTS], vec![0.0; CURVE_POINTS]];
883        let mut v = m.clone();
884        let mut history = Vec::with_capacity(o.steps);
885        let mut best: Option<(f64, Pair, Pair)> = None;
886        for it in 0..o.steps {
887            let lr = o.learning_rate
888                * ((it + 1) as f64 / o.warmup.max(1) as f64).min(1.0)
889                * 0.5
890                * (1.0 + (std::f64::consts::PI * it as f64 / o.steps as f64).cos());
891            let e = self.evaluate(&c, o.smoothness);
892            history.push(e.value);
893            if best.as_ref().is_none_or(|b| e.value < b.0) {
894                best = Some((e.value, c.clone(), e.irs));
895            }
896            let t = (it + 1) as i32;
897            let (bc1, bc2) = (1.0 - f64::powi(b1, t), 1.0 - f64::powi(b2, t));
898            for j in 0..2 {
899                for i in 0..CURVE_POINTS {
900                    let g = e.grads[j][i];
901                    m[j][i] = b1 * m[j][i] + (1.0 - b1) * g;
902                    v[j][i] = b2 * v[j][i] + (1.0 - b2) * g * g;
903                    c[j][i] -= lr / bc1 * m[j][i] / ((v[j][i] / bc2).sqrt() + eps);
904                }
905            }
906            if !on_step(it + 1, o.steps) {
907                break;
908            }
909        }
910        let (_, bc, birs) = best.unwrap_or_else(|| {
911            let e = self.evaluate(&c, o.smoothness);
912            (e.value, c.clone(), e.irs)
913        });
914        (bc, birs, history)
915    }
916}
917
918/// Fit G1/G2 so the simulated pedal matches `model`, starting from the responses in `init` (2048
919/// floats of upload data, normally a [`Measure::Clean`](super::convert::Measure::Clean)
920/// conversion). Runs the model over the test signal once, then `options.steps` simulations; about
921/// a minute per 300 steps in an optimised build.
922pub fn optimise(model: &mut Model, init: &[f32], options: &Options) -> Optimised {
923    optimise_with_progress(model, init, options, &mut |_, _| true)
924}
925
926/// [`optimise`], calling `on_step(done, total)` after each step (the model render before the first
927/// takes about a sixth of the time). Returning `false` stops early with the best result so far.
928pub fn optimise_with_progress(
929    model: &mut Model,
930    init: &[f32],
931    options: &Options,
932    on_step: &mut dyn FnMut(usize, usize) -> bool,
933) -> Optimised {
934    let signal = test_signal();
935    let target = render_target(model, &signal.x);
936    let problem = Problem::new(&signal.x, &target, &train_slices(&signal.segments), options.weighting);
937    let (h1, h2) = super::convert::pedal_irs(init);
938    let c0 = [problem.curves.of(&h1), problem.curves.of(&h2)];
939    let initial_loss = problem.evaluate(&c0, options.smoothness).value;
940    let ([g1_curve, g2_curve], [g1_ir, g2_ir], loss) = problem.fit(c0, options, on_step);
941    Optimised { data: super::convert::upload_data(&g1_ir, &g2_ir), g1_ir, g2_ir, g1_curve, g2_curve, initial_loss, loss }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    fn golden(name: &str) -> Vec<f64> {
949        let p = format!("{}/tests/data/optim/{name}.bin", env!("CARGO_MANIFEST_DIR"));
950        std::fs::read(&p)
951            .unwrap_or_else(|e| panic!("{p}: {e}"))
952            .as_chunks::<8>()
953            .0
954            .iter()
955            .map(|c| f64::from_le_bytes(*c))
956            .collect()
957    }
958
959    fn manifest() -> serde_json::Value {
960        let p = format!("{}/tests/data/optim/golden.json", env!("CARGO_MANIFEST_DIR"));
961        serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
962    }
963
964    /// Max |a − b| relative to max |b|.
965    fn rel_err(a: &[f64], b: &[f64]) -> f64 {
966        assert_eq!(a.len(), b.len());
967        let scale = b.iter().fold(0f64, |m, v| m.max(v.abs()));
968        a.iter().zip(b).fold(0f64, |m, (x, y)| m.max((x - y).abs())) / scale
969    }
970
971    #[test]
972    fn saturation_matches_reference() {
973        let (u, v) = (golden("f_u"), golden("f_v"));
974        let ours: Vec<f64> = u.iter().map(|&a| saturation(a)).collect();
975        assert!(rel_err(&ours, &v) < 1e-12);
976    }
977
978    #[test]
979    fn simulator_matches_reference() {
980        let x = golden("sim_x");
981        let run = Simulator::new(&x).run(&golden("sim_h1"), &golden("sim_h2"));
982        assert!(rel_err(&run.u, &golden("sim_u")) < 1e-10, "u {}", rel_err(&run.u, &golden("sim_u")));
983        assert!(rel_err(&run.y, &golden("sim_y")) < 1e-10, "y {}", rel_err(&run.y, &golden("sim_y")));
984    }
985
986    #[test]
987    fn loss_matches_reference() {
988        let m = manifest();
989        let slices: Vec<(usize, usize)> = m["sim_segs"]
990            .as_array()
991            .unwrap()
992            .iter()
993            .map(|s| {
994                let (a, l) = (s[0].as_u64().unwrap() as usize, s[1].as_u64().unwrap() as usize);
995                (a + TRIM, a + l - TRIM)
996            })
997            .collect();
998        let loss = SpecLoss::new(&golden("sim_tgt"), &slices, Weighting::Flat);
999        let v = loss.value(&golden("sim_y"));
1000        let want = m["loss"].as_f64().unwrap();
1001        assert!((v - want).abs() < 1e-10 * want, "{v} vs {want}");
1002    }
1003
1004    fn golden_slices() -> Vec<(usize, usize)> {
1005        manifest()["sim_segs"]
1006            .as_array()
1007            .unwrap()
1008            .iter()
1009            .map(|s| {
1010                let (a, l) = (s[0].as_u64().unwrap() as usize, s[1].as_u64().unwrap() as usize);
1011                (a + TRIM, a + l - TRIM)
1012            })
1013            .collect()
1014    }
1015
1016    #[test]
1017    fn curve_of_matches_reference() {
1018        let c = Curves::new();
1019        for (h, want) in [("co_h1", "co_c1"), ("co_h2", "co_c2")] {
1020            let ours = c.of(&golden(h));
1021            assert!(rel_err(&ours, &golden(want)) < 1e-10, "{h}: {}", rel_err(&ours, &golden(want)));
1022        }
1023    }
1024
1025    #[test]
1026    fn min_phase_design_and_gradient_match_reference() {
1027        let c = Curves::new();
1028        let d = c.design(&golden("mp_c"));
1029        assert!(rel_err(&d.h, &golden("mp_h")) < 1e-10, "h {}", rel_err(&d.h, &golden("mp_h")));
1030        let g = c.backward(&d, &golden("mp_gh"));
1031        assert!(rel_err(&g, &golden("mp_gc")) < 1e-9, "grad {}", rel_err(&g, &golden("mp_gc")));
1032    }
1033
1034    #[test]
1035    fn min_phase_gradient_is_numerically_right() {
1036        let c = Curves::new();
1037        let curve: Vec<f64> = (0..CURVE_POINTS).map(|i| (i as f64 * 0.7).sin() - 2.0).collect();
1038        let gh: Vec<f64> = (0..PEDAL_TAPS).map(|k| ((k * 37 % 101) as f64 / 50.0 - 1.0) / (1.0 + k as f64 / 20.0)).collect();
1039        let f = |cv: &[f64]| c.design(cv).h.iter().zip(&gh).map(|(a, b)| a * b).sum::<f64>();
1040        let g = c.backward(&c.design(&curve), &gh);
1041        for i in [0, 1, 7, 20, 33, 40] {
1042            let (mut p, mut m) = (curve.clone(), curve.clone());
1043            p[i] += 1e-6;
1044            m[i] -= 1e-6;
1045            let num = (f(&p) - f(&m)) / 2e-6;
1046            assert!((num - g[i]).abs() < 1e-6 * g.iter().fold(0f64, |a, v| a.max(v.abs())), "point {i}: {num} vs {}", g[i]);
1047        }
1048    }
1049
1050    #[test]
1051    fn simulator_gradients_match_reference() {
1052        let sim = Simulator::new(&golden("sim_x"));
1053        let (h1, h2) = (golden("sim_h1"), golden("sim_h2"));
1054        let run = sim.run(&h1, &h2);
1055        let pen = penalty(&run.u);
1056        let want = manifest()["sim_pen"].as_f64().unwrap();
1057        assert!((pen - want).abs() < 1e-10 * want, "penalty {pen} vs {want}");
1058        let (g1, g2) = sim.backward(&run, &h2, &golden("sim_gy"));
1059        assert!(rel_err(&g1, &golden("sim_gh1")) < 1e-9, "h1 {}", rel_err(&g1, &golden("sim_gh1")));
1060        assert!(rel_err(&g2, &golden("sim_gh2")) < 1e-9, "h2 {}", rel_err(&g2, &golden("sim_gh2")));
1061    }
1062
1063    #[test]
1064    fn simulator_gradients_are_numerically_right() {
1065        // a signal that reaches the penalty range and several segments of the curve
1066        let x: Vec<f64> = (0..30000).map(|n| 300.0 * (n as f64 * 0.013).sin() * (n as f64 / 30000.0)).collect();
1067        let h1: Vec<f64> = (0..PEDAL_TAPS).map(|k| 0.5 * (-(k as f64) / 30.0).exp() * (k as f64 * 0.3).cos()).collect();
1068        let h2: Vec<f64> = (0..PEDAL_TAPS).map(|k| (-(k as f64) / 50.0).exp() * (k as f64 * 0.1).sin()).collect();
1069        let gy: Vec<f64> = (0..x.len()).map(|n| (n * 7919 % 1000) as f64 / 500.0 - 1.0).collect();
1070        let sim = Simulator::new(&x);
1071        let obj = |a: &[f64], b: &[f64]| {
1072            let r = sim.run(a, b);
1073            r.y.iter().zip(&gy).map(|(y, g)| y * g).sum::<f64>() + penalty(&r.u)
1074        };
1075        let run = sim.run(&h1, &h2);
1076        assert!(run.u.iter().any(|u| u.abs() > PENALTY_LIMIT));
1077        let (g1, g2) = sim.backward(&run, &h2, &gy);
1078        let d = 1e-7;
1079        for k in [0, 3, 100, 511] {
1080            let (mut p, mut m) = (h1.clone(), h1.clone());
1081            p[k] += d;
1082            m[k] -= d;
1083            let num = (obj(&p, &h2) - obj(&m, &h2)) / (2.0 * d);
1084            assert!((num - g1[k]).abs() < 1e-4 * num.abs().max(1.0), "h1[{k}]: {num} vs {}", g1[k]);
1085            let (mut p, mut m) = (h2.clone(), h2.clone());
1086            p[k] += d;
1087            m[k] -= d;
1088            let num = (obj(&h1, &p) - obj(&h1, &m)) / (2.0 * d);
1089            assert!((num - g2[k]).abs() < 1e-4 * num.abs().max(1.0), "h2[{k}]: {num} vs {}", g2[k]);
1090        }
1091    }
1092
1093    #[test]
1094    fn loss_gradient_matches_reference() {
1095        let loss = SpecLoss::new(&golden("sim_tgt"), &golden_slices(), Weighting::Flat);
1096        let y = golden("sim_y");
1097        let mut gy = vec![0.0; y.len()];
1098        let v = loss.value_grad(&y, &mut gy);
1099        assert!((v - loss.value(&y)).abs() < 1e-12);
1100        assert!(rel_err(&gy, &golden("loss_gy")) < 1e-9, "{}", rel_err(&gy, &golden("loss_gy")));
1101    }
1102
1103    #[test]
1104    fn loss_gradient_is_numerically_right() {
1105        let slices = golden_slices();
1106        let loss = SpecLoss::new(&golden("sim_tgt"), &slices, Weighting::Flat);
1107        let y = golden("sim_y");
1108        let mut gy = vec![0.0; y.len()];
1109        loss.value_grad(&y, &mut gy);
1110        // inside, at the edges (reflect padding) and outside the slices
1111        for i in [slices[0].0, slices[0].0 + 1, slices[0].0 + 1000, slices[0].1 - 1, slices[1].0 + 3, 100] {
1112            let d = 1e-6 * y[i].abs().max(1e-3);
1113            let (mut p, mut m) = (y.clone(), y.clone());
1114            p[i] += d;
1115            m[i] -= d;
1116            let num = (loss.value(&p) - loss.value(&m)) / (2.0 * d);
1117            assert!((num - gy[i]).abs() < 1e-4 * num.abs().max(1e-6), "sample {i}: {num} vs {}", gy[i]);
1118        }
1119    }
1120
1121    #[test]
1122    fn octave_weights() {
1123        assert!(Weighting::Flat.bins(256).iter().all(|&w| w == 1.0));
1124        let n = 4096;
1125        let w = Weighting::Octave.bins(n);
1126        let bin = |f: f64| (f * n as f64 / SR as f64).round() as usize;
1127        assert!((w.iter().sum::<f64>() / w.len() as f64 - 1.0).abs() < 1e-12);
1128        assert_eq!((w[0], w[bin(10.0)]), (0.0, 0.0));
1129        assert!((w[bin(25.0)] - w[bin(35.0)]).abs() < 1e-12, "flat below 40 Hz");
1130        let (lo, hi) = (bin(100.0), bin(1000.0));
1131        let ratio = w[lo] / w[hi];
1132        assert!((ratio - hi as f64 / lo as f64).abs() < 1e-12, "1/f: {ratio}");
1133    }
1134
1135    #[test]
1136    fn weighted_loss_gradient_is_numerically_right() {
1137        let slices = golden_slices();
1138        let loss = SpecLoss::new(&golden("sim_tgt"), &slices, Weighting::Octave);
1139        let y = golden("sim_y");
1140        let mut gy = vec![0.0; y.len()];
1141        let v = loss.value_grad(&y, &mut gy);
1142        assert!((v - loss.value(&y)).abs() < 1e-12 * v);
1143        for i in [slices[0].0, slices[0].0 + 1000, slices[0].1 - 1, slices[1].0 + 3] {
1144            let d = 1e-6 * y[i].abs().max(1e-3);
1145            let (mut p, mut m) = (y.clone(), y.clone());
1146            p[i] += d;
1147            m[i] -= d;
1148            let num = (loss.value(&p) - loss.value(&m)) / (2.0 * d);
1149            assert!((num - gy[i]).abs() < 1e-4 * num.abs().max(1e-6), "sample {i}: {num} vs {}", gy[i]);
1150        }
1151    }
1152
1153    #[test]
1154    fn optimiser_steps_match_reference() {
1155        let problem = Problem::new(&golden("sim_x"), &golden("sim_tgt"), &golden_slices(), Weighting::Flat);
1156        let c0 = golden("opt_c0");
1157        let want = golden("opt_loss");
1158        let o = Options { steps: want.len(), weighting: Weighting::Flat, ..Options::default() };
1159        let c = [c0[..CURVE_POINTS].to_vec(), c0[CURVE_POINTS..].to_vec()];
1160        // each loss after the first follows an Adam step, so this checks the updates too
1161        let (_, _, history) = problem.fit(c, &o, &mut |_, _| true);
1162        assert!(rel_err(&history, &want) < 1e-9, "{history:?} vs {want:?}");
1163    }
1164
1165    #[test]
1166    fn optimiser_reports_progress_and_stops_early() {
1167        let problem = Problem::new(&golden("sim_x"), &golden("sim_tgt"), &golden_slices(), Weighting::Flat);
1168        let c0 = golden("opt_c0");
1169        let c = [c0[..CURVE_POINTS].to_vec(), c0[CURVE_POINTS..].to_vec()];
1170        let mut seen = vec![];
1171        let (_, irs, history) = problem.fit(c, &Options { steps: 8, ..Options::default() }, &mut |d, t| {
1172            seen.push((d, t));
1173            d < 3
1174        });
1175        assert_eq!((seen, history.len(), irs[0].len()), (vec![(1, 8), (2, 8), (3, 8)], 3, PEDAL_TAPS));
1176    }
1177
1178    #[test]
1179    fn target_matches_reference() {
1180        let mut model = Model::load(format!("{}/tests/data/nam/wavenet.nam", env!("CARGO_MANIFEST_DIR"))).unwrap();
1181        let y = render_target(&mut model, &golden("tgt_x"));
1182        assert!(rel_err(&y, &golden("tgt_y")) < 1e-5, "{}", rel_err(&y, &golden("tgt_y")));
1183    }
1184
1185    #[test]
1186    fn test_signal_matches_reference() {
1187        let m = manifest();
1188        let s = test_signal();
1189        let segs = m["sig_segs"].as_array().unwrap();
1190        for (ours, theirs) in s.segments.iter().zip(segs) {
1191            assert_eq!(ours.name, theirs["name"].as_str().unwrap());
1192            assert_eq!((ours.start, ours.len), (theirs["start"].as_u64().unwrap() as usize, theirs["len"].as_u64().unwrap() as usize));
1193        }
1194        // the re-amp part, then three 3.5 s pink segments and 0.5 s of silence
1195        let re_amp = m["sig_len"].as_u64().unwrap() as usize;
1196        assert_eq!(s.x.len(), re_amp + 3 * (SR / 2 + 3 * SR) + SR / 2);
1197        let (idx, v) = (golden("sig_idx"), golden("sig_v"));
1198        for (&i, &want) in idx.iter().zip(&v) {
1199            assert!((s.x[i as usize] - want).abs() < 1e-6, "sample {i}: {} vs {want}", s.x[i as usize]);
1200        }
1201        assert_eq!(s.segments.iter().filter(|s| s.train).count(), 22);
1202        // pink noise at the stated level
1203        let p = s.segments.iter().find(|s| s.name == "synth pink -12").unwrap();
1204        let rms = (s.x[p.start..p.start + p.len].iter().map(|v| v * v).sum::<f64>() / p.len as f64).sqrt();
1205        assert!((rms - 0.5 * 10f64.powf(-6.0 / 20.0)).abs() < 1e-12);
1206    }
1207}