Skip to main content

open_stomp/
ir.rs

1//! Impulse responses for the pedal's user CAB (IR) slots (docs/05): reading and writing WAV files,
2//! resampling, and the conversion to the pedal's upload format.
3//!
4//! [`convert`] is the official app's IR conversion, recovered from its native code (docs/05) and
5//! matching its uploads exactly: a file that isn't a 48 kHz mono 24-bit WAV is first converted to
6//! one (first channel, JUCE's resampler, 24-bit); the first [`IR_TAPS`] samples are time-reversed and
7//! scaled so that the response between 398 Hz and 3 kHz is centred on 0 dB.
8//! The upload itself is [`crate::upload::user_ir_upload`] / `Device::import_user_ir`.
9
10use std::fmt;
11
12/// A decoded WAV file: one sample vector per channel, scaled to ±1.0.
13#[derive(Clone, Debug, PartialEq)]
14pub struct Wav {
15    pub sample_rate: u32,
16    pub channels: Vec<Vec<f32>>,
17    /// How the file stored its samples (the official app converts anything but 24-bit PCM).
18    pub encoding: Encoding,
19}
20
21/// Sample encoding of a WAV file.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Encoding {
24    /// Integer PCM with this many bits.
25    Pcm(u16),
26    /// IEEE float with this many bits.
27    Float(u16),
28}
29
30impl Wav {
31    /// In-memory audio (32-bit float, like a float WAV file).
32    pub fn new(sample_rate: u32, channels: Vec<Vec<f32>>) -> Wav {
33        Wav { sample_rate, channels, encoding: Encoding::Float(32) }
34    }
35}
36
37#[derive(Debug, PartialEq, Eq)]
38pub enum WavError {
39    /// Not a RIFF/WAVE file, or a chunk is cut short.
40    Format(String),
41    /// A valid WAV in an encoding this reader doesn't handle (e.g. compressed).
42    Unsupported(String),
43}
44
45impl fmt::Display for WavError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            WavError::Format(s) => write!(f, "invalid WAV file: {s}"),
49            WavError::Unsupported(s) => write!(f, "unsupported WAV encoding: {s}"),
50        }
51    }
52}
53
54impl std::error::Error for WavError {}
55
56const PCM: u16 = 1;
57const FLOAT: u16 = 3;
58const EXTENSIBLE: u16 = 0xFFFE;
59
60fn u16_at(b: &[u8], i: usize) -> u16 {
61    u16::from_le_bytes([b[i], b[i + 1]])
62}
63
64fn u32_at(b: &[u8], i: usize) -> u32 {
65    u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]])
66}
67
68impl Wav {
69    /// Decode a WAV file's bytes: PCM 8/16/24/32-bit or IEEE float 32/64-bit, any channel count,
70    /// plain or `WAVE_FORMAT_EXTENSIBLE`.
71    pub fn parse(bytes: &[u8]) -> Result<Wav, WavError> {
72        let bad = |s: &str| WavError::Format(s.to_owned());
73        if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
74            return Err(bad("no RIFF/WAVE header"));
75        }
76        let (mut fmt, mut data) = (None, None);
77        let mut i = 12;
78        while i + 8 <= bytes.len() {
79            let (id, len) = (&bytes[i..i + 4], u32_at(bytes, i + 4) as usize);
80            let body = &bytes[i + 8..(i + 8 + len).min(bytes.len())];
81            match id {
82                b"fmt " => fmt = Some(body),
83                // a truncated data chunk (e.g. a writer that never patched the length) is read as is
84                b"data" => data = Some(body),
85                _ => {}
86            }
87            i += 8 + len + (len & 1);
88        }
89        let fmt = fmt.ok_or_else(|| bad("no fmt chunk"))?;
90        let data = data.ok_or_else(|| bad("no data chunk"))?;
91        if fmt.len() < 16 {
92            return Err(bad("short fmt chunk"));
93        }
94        let mut tag = u16_at(fmt, 0);
95        let channels = u16_at(fmt, 2) as usize;
96        let sample_rate = u32_at(fmt, 4);
97        let bits = u16_at(fmt, 14) as usize;
98        if tag == EXTENSIBLE {
99            if fmt.len() < 26 {
100                return Err(bad("short extensible fmt chunk"));
101            }
102            tag = u16_at(fmt, 24); // first two bytes of the sub-format GUID
103        }
104        if channels == 0 || sample_rate == 0 {
105            return Err(bad("no channels or no sample rate"));
106        }
107        let width = bits.div_ceil(8);
108        let decode: fn(&[u8]) -> f32 = match (tag, width) {
109            (PCM, 1) => |s| (s[0] as f32 - 128.0) / 128.0,
110            (PCM, 2) => |s| i16::from_le_bytes([s[0], s[1]]) as f32 / 32768.0,
111            (PCM, 3) => |s| (i32::from_le_bytes([0, s[0], s[1], s[2]]) >> 8) as f32 / 8_388_608.0,
112            (PCM, 4) => |s| (i32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f64 / 2_147_483_648.0) as f32,
113            (FLOAT, 4) => |s| f32::from_le_bytes([s[0], s[1], s[2], s[3]]),
114            (FLOAT, 8) => |s| f64::from_le_bytes(s.try_into().expect("8 bytes")) as f32,
115            _ => return Err(WavError::Unsupported(format!("format {tag:#06x}, {bits} bits"))),
116        };
117        let frame = width * channels;
118        let mut out = vec![Vec::with_capacity(data.len() / frame); channels];
119        for f in data.chunks_exact(frame) {
120            for (c, s) in f.chunks_exact(width).enumerate() {
121                out[c].push(decode(s));
122            }
123        }
124        let encoding = if tag == FLOAT { Encoding::Float(bits as u16) } else { Encoding::Pcm(bits as u16) };
125        Ok(Wav { sample_rate, channels: out, encoding })
126    }
127
128    pub fn load(path: impl AsRef<std::path::Path>) -> Result<Wav, Box<dyn std::error::Error + Send + Sync>> {
129        Ok(Wav::parse(&std::fs::read(path)?)?)
130    }
131}
132
133impl Wav {
134    /// Encode as a WAV file: 16/24/32-bit PCM (`float = false`) or 32-bit float (`bits = 32`, `float = true`).
135    pub fn to_bytes(&self, bits: u16, float: bool) -> Vec<u8> {
136        assert!(matches!((bits, float), (16 | 24 | 32, false) | (32, true)), "unsupported WAV encoding");
137        let ch = self.channels.len() as u16;
138        let frames = self.channels.first().map_or(0, Vec::len);
139        let width = bits as usize / 8;
140        let mut data = Vec::with_capacity(frames * ch as usize * width);
141        for i in 0..frames {
142            for c in &self.channels {
143                let v = c.get(i).copied().unwrap_or(0.0);
144                if float {
145                    data.extend_from_slice(&v.to_le_bytes());
146                } else {
147                    let full = (1i64 << (bits - 1)) as f64;
148                    let q = (v as f64 * full).round().clamp(-full, full - 1.0) as i64;
149                    data.extend_from_slice(&q.to_le_bytes()[..width]);
150                }
151            }
152        }
153        let mut b = Vec::with_capacity(44 + data.len());
154        b.extend_from_slice(b"RIFF");
155        b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
156        b.extend_from_slice(b"WAVEfmt ");
157        b.extend_from_slice(&16u32.to_le_bytes());
158        b.extend_from_slice(&(if float { FLOAT } else { PCM }).to_le_bytes());
159        b.extend_from_slice(&ch.to_le_bytes());
160        b.extend_from_slice(&self.sample_rate.to_le_bytes());
161        b.extend_from_slice(&(self.sample_rate * ch as u32 * width as u32).to_le_bytes());
162        b.extend_from_slice(&(ch * width as u16).to_le_bytes());
163        b.extend_from_slice(&bits.to_le_bytes());
164        b.extend_from_slice(b"data");
165        b.extend_from_slice(&(data.len() as u32).to_le_bytes());
166        b.extend_from_slice(&data);
167        b
168    }
169}
170
171// ---------------------------------------------------------------------------------------------
172// resampling
173
174/// Modified Bessel function of the first kind, order 0 (for the Kaiser window).
175fn bessel_i0(x: f64) -> f64 {
176    let (mut sum, mut term, mut k) = (1.0, 1.0, 1.0);
177    while term > 1e-12 * sum {
178        term *= (x / (2.0 * k)).powi(2);
179        sum += term;
180        k += 1.0;
181    }
182    sum
183}
184
185/// Resample `x` from `from` Hz to `to` Hz: windowed-sinc interpolation (Kaiser, β = 9, 32 zero
186/// crossings each side), low-pass at 95 % of the lower Nyquist frequency. The output covers the same
187/// duration (`ceil(len · to / from)` samples), with no delay.
188pub fn resample(x: &[f32], from: u32, to: u32) -> Vec<f32> {
189    if from == to || x.is_empty() {
190        return x.to_vec();
191    }
192    const ZEROS: f64 = 32.0;
193    const BETA: f64 = 9.0;
194    let ratio = to as f64 / from as f64;
195    // cutoff in cycles per input sample
196    let fc = 0.5 * ratio.min(1.0) * 0.95;
197    let half = ZEROS / (2.0 * fc); // window half-width in input samples
198    let i0b = bessel_i0(BETA);
199    let len = (x.len() as u64 * to as u64).div_ceil(from as u64) as usize;
200    (0..len)
201        .map(|n| {
202            let t = n as f64 / ratio;
203            let (lo, hi) = (((t - half).ceil().max(0.0)) as usize, ((t + half).floor() as usize).min(x.len() - 1));
204            let mut acc = 0.0;
205            for (k, &v) in x.iter().enumerate().take(hi + 1).skip(lo) {
206                let d = k as f64 - t;
207                let u = d / half;
208                let w = bessel_i0(BETA * (1.0 - u * u).max(0.0).sqrt()) / i0b;
209                let a = std::f64::consts::PI * 2.0 * fc * d;
210                let sinc = if a.abs() < 1e-12 { 1.0 } else { a.sin() / a };
211                acc += v as f64 * 2.0 * fc * sinc * w;
212            }
213            acc as f32
214        })
215        .collect()
216}
217
218// ---------------------------------------------------------------------------------------------
219// conversion
220
221/// Samples per uploaded IR (42.7 ms at 48 kHz).
222pub const IR_TAPS: usize = 2048;
223/// Samples of an uploaded IR that the pedal actually plays (8 ms): `data[1664..2048]`, i.e. taps
224/// 0..383. Measured by re-amping probe IRs (docs/05).
225pub const PEDAL_IR_TAPS: usize = 384;
226/// The pedal's sample rate.
227pub const IR_SAMPLE_RATE: u32 = 48000;
228
229/// An IR converted for upload.
230#[derive(Clone, Debug)]
231pub struct ConvertedIr {
232    /// The [`IR_TAPS`] floats to upload: `data[IR_TAPS − 1 − k] = gain · ir[k]`.
233    pub data: Vec<f32>,
234    /// The gain applied.
235    pub gain: f32,
236    /// The IR at 48 kHz as the app sees it (after its conversion), before truncation.
237    pub ir: Vec<f32>,
238}
239
240/// JUCE's `ResamplingAudioSource` low-pass (`createLowPass`): a 2nd-order Butterworth-style
241/// section at the lower Nyquist frequency, as `(b0, b1, b2, a1, a2)`.
242fn juce_low_pass(ratio: f64) -> [f64; 5] {
243    let rate = if ratio > 1.0 { 0.5 / ratio } else { 0.5 * ratio };
244    let n = 1.0 / (std::f64::consts::PI * rate.max(0.001)).tan();
245    let n2 = n * n;
246    let c1 = 1.0 / (1.0 + std::f64::consts::SQRT_2 * n + n2);
247    [c1, c1 * 2.0, c1, c1 * 2.0 * (1.0 - n2), c1 * (1.0 - std::f64::consts::SQRT_2 * n + n2)]
248}
249
250/// JUCE's `applyFilter`: in place, double-precision state, float samples.
251fn juce_filter(x: &mut [f32], c: &[f64; 5]) {
252    let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
253    for v in x {
254        let i = *v as f64;
255        let o = c[0] * i + c[1] * x1 + c[2] * x2 - c[3] * y1 - c[4] * y2;
256        (x2, x1, y2, y1) = (x1, i, y1, o);
257        *v = o as f32;
258    }
259}
260
261/// Resample like the official app: JUCE's `ResamplingAudioSource` (linear interpolation, with its
262/// low-pass applied to the input when downsampling and to the output when upsampling, none
263/// within ±0.01 % of 1:1), writing `⌊len · to / from⌋` samples.
264pub fn resample_like_app(x: &[f32], from: u32, to: u32) -> Vec<f32> {
265    let ratio = from as f64 / to as f64; // input samples per output sample
266    let out_len = (x.len() as f64 / ratio) as usize;
267    let filter = juce_low_pass(ratio);
268    // the input as the interpolator sees it (zeros past the end, filtered too when downsampling)
269    let mut src: Vec<f32> = x.to_vec();
270    src.resize(x.len().max((out_len as f64 * ratio) as usize + 4), 0.0);
271    if ratio > 1.0001 {
272        juce_filter(&mut src, &filter);
273    }
274    let (mut pos, mut sub) = (0usize, 0.0f64);
275    let mut out = Vec::with_capacity(out_len);
276    for _ in 0..out_len {
277        let alpha = sub as f32;
278        let (a, b) = (src.get(pos).copied().unwrap_or(0.0), src.get(pos + 1).copied().unwrap_or(0.0));
279        out.push(a + alpha * (b - a));
280        sub += ratio;
281        while sub >= 1.0 {
282            pos += 1;
283            sub -= 1.0;
284        }
285    }
286    if ratio < 0.9999 {
287        juce_filter(&mut out, &filter);
288    }
289    out
290}
291
292/// Write to and read back from a 24-bit WAV the way JUCE does (`× 8388607`, rounded, `÷ 8388608`).
293fn via_24_bit(x: &mut [f32]) {
294    for v in x {
295        let q = ((*v as f64).clamp(-1.0, 1.0) * 8_388_607.0).round_ties_even().clamp(-8_388_608.0, 8_388_607.0);
296        *v = (q / 8_388_608.0) as f32;
297    }
298}
299
300/// FFT length and bins of the app's gain measurement: 16384 points, bins 136–1023 (398 Hz – 3 kHz).
301const GAIN_FFT: usize = 16384;
302const GAIN_BINS: std::ops::Range<usize> = 136..1024;
303
304/// The app's gain: `1 / √(max |X| · min |X|)` over bins 136–1023 of the 16384-point spectrum of
305/// the first [`IR_TAPS`] samples, i.e. the band's peak and trough end up symmetric about 0 dB.
306pub fn app_gain(head: &[f32]) -> f64 {
307    let n = GAIN_FFT as f64;
308    // a direct DFT over the 888 bins (no FFT dependency; a few ms)
309    let (mut lo, mut hi) = (f64::MAX, 0f64);
310    for k in GAIN_BINS {
311        let w = std::f64::consts::TAU * k as f64 / n;
312        let (c, s) = (w.cos(), w.sin());
313        let (mut re, mut im, mut cr, mut ci) = (0.0, 0.0, 1.0f64, 0.0f64);
314        for &v in head.iter().take(IR_TAPS) {
315            re += v as f64 * cr;
316            im -= v as f64 * ci;
317            (cr, ci) = (cr * c - ci * s, cr * s + ci * c);
318        }
319        let p = re * re + im * im;
320        (lo, hi) = (lo.min(p), hi.max(p));
321    }
322    if lo > 0.0 && hi.is_finite() { 1.0 / (lo.sqrt() * hi.sqrt()).sqrt() } else { 1.0 }
323}
324
325/// Convert an IR exactly as the official app does (docs/05):
326/// 1. unless the file is a 48 kHz mono 24-bit WAV, convert it to one: the first channel, resampled
327///    with JUCE's `ResamplingAudioSource` ([`resample_like_app`]), stored as 24-bit;
328/// 2. take the first [`IR_TAPS`] samples (zero-padded), time-reversed;
329/// 3. scale by [`app_gain`].
330pub fn convert(wav: &Wav) -> ConvertedIr {
331    let ch = wav.channels.first().map(Vec::as_slice).unwrap_or(&[]);
332    let direct = wav.sample_rate == IR_SAMPLE_RATE && wav.channels.len() == 1 && wav.encoding == Encoding::Pcm(24);
333    let ir = if direct {
334        ch.to_vec()
335    } else {
336        let mut v = resample_like_app(ch, wav.sample_rate, IR_SAMPLE_RATE);
337        via_24_bit(&mut v);
338        v
339    };
340    let head = &ir[..ir.len().min(IR_TAPS)];
341    let gain = app_gain(head);
342    let mut data = vec![0.0f32; IR_TAPS];
343    for (k, &v) in head.iter().enumerate() {
344        data[IR_TAPS - 1 - k] = (gain * v as f64) as f32;
345    }
346    ConvertedIr { data, gain: gain as f32, ir }
347}
348
349// ---------------------------------------------------------------------------------------------
350// optimised conversion (feature `nam`, for the FFT)
351
352/// FFT length of the minimum-phase design (1.46 Hz per bin at 48 kHz).
353#[cfg(feature = "nam")]
354const DESIGN_FFT: usize = 1 << 15;
355/// Smoothing of the target response: ±1/12 octave (1/6-octave bands).
356#[cfg(feature = "nam")]
357const SMOOTH: f64 = 1.059_463_094_359_295_3; // 2^(1/12)
358
359/// Fractional-octave power smoothing on an rfft grid (`p`: power per bin).
360#[cfg(feature = "nam")]
361fn smooth_power(p: &[f64]) -> Vec<f64> {
362    let mut pre = Vec::with_capacity(p.len() + 1);
363    pre.push(0.0);
364    for v in p {
365        pre.push(pre.last().unwrap() + v);
366    }
367    (0..p.len())
368        .map(|k| {
369            let lo = ((k as f64 / SMOOTH).floor() as usize).min(k);
370            let hi = ((k as f64 * SMOOTH).ceil() as usize).max(k).min(p.len() - 1);
371            (pre[hi + 1] - pre[lo]) / (hi + 1 - lo) as f64
372        })
373        .collect()
374}
375
376/// `|FFT(x, n)|²` for bins 0..=n/2.
377#[cfg(feature = "nam")]
378fn power_spectrum(x: &[f64], n: usize) -> Vec<f64> {
379    use rustfft::num_complex::Complex64;
380    let mut c: Vec<Complex64> = x.iter().take(n).map(|&v| Complex64::new(v, 0.0)).collect();
381    c.resize(n, Complex64::default());
382    rustfft::FftPlanner::new().plan_fft_forward(n).process(&mut c);
383    c[..n / 2 + 1].iter().map(|v| v.norm_sqr()).collect()
384}
385
386/// A minimum-phase impulse response with magnitude `mag` (bins 0..=n/2 of an `n`-point grid),
387/// by the folded real cepstrum.
388#[cfg(feature = "nam")]
389fn min_phase(mag: &[f64], taps: usize) -> Vec<f64> {
390    use rustfft::num_complex::Complex64;
391    let n = (mag.len() - 1) * 2;
392    let mut p = rustfft::FftPlanner::new();
393    let (fwd, inv) = (p.plan_fft_forward(n), p.plan_fft_inverse(n));
394    let floor = mag.iter().fold(0f64, |m, &v| m.max(v)) * 1e-6;
395    let mut c: Vec<Complex64> = (0..n).map(|k| Complex64::new(mag[k.min(n - k)].max(floor).ln(), 0.0)).collect();
396    inv.process(&mut c);
397    for (i, v) in c.iter_mut().enumerate() {
398        let w = match i {
399            0 => 1.0,
400            i if i < n / 2 => 2.0,
401            i if i == n / 2 => 1.0,
402            _ => 0.0,
403        };
404        *v = Complex64::new(v.re / n as f64 * w, 0.0);
405    }
406    fwd.process(&mut c);
407    for v in c.iter_mut() {
408        *v = v.exp();
409    }
410    inv.process(&mut c);
411    c[..taps].iter().map(|v| v.re / n as f64).collect()
412}
413
414/// Range of the fit (Hz). A cab IR has next to nothing above 16 kHz (−80 dB and below), and
415/// matching that costs taps the rest of the range needs.
416#[cfg(feature = "nam")]
417const FIT_LO_HZ: f64 = 30.0;
418#[cfg(feature = "nam")]
419const FIT_HI_HZ: f64 = 16000.0;
420
421/// Bands of the fit: 1/6 octave wide, every 1/12 octave from [`FIT_LO_HZ`] to [`FIT_HI_HZ`], as
422/// bin ranges of an `n`-point FFT at 48 kHz.
423#[cfg(feature = "nam")]
424fn fit_bands(n: usize) -> Vec<std::ops::Range<usize>> {
425    let hz = |f: f64| f * n as f64 / IR_SAMPLE_RATE as f64;
426    std::iter::successors(Some(FIT_LO_HZ), |f| Some(f * 2f64.powf(1.0 / 12.0)))
427        .take_while(|&f| f <= FIT_HI_HZ)
428        .map(|f| {
429            let (lo, hi) = (hz(f / SMOOTH).floor() as usize, hz(f * SMOOTH).ceil() as usize);
430            lo..hi.max(lo + 1)
431        })
432        .collect()
433}
434
435/// Fit `h0`'s taps directly to the full IR's band levels: Adam on the variance of the dB error
436/// over [`fit_bands`] (the level is left free; [`app_gain`] sets it afterwards), with the gradient
437/// through the FFT written out.
438#[cfg(feature = "nam")]
439fn refine(h0: &[f64], nf: usize, full_smoothed: &[f64]) -> Vec<f64> {
440    use rustfft::num_complex::Complex64;
441    let n = DESIGN_FFT;
442    let bands = fit_bands(n);
443    // target band levels (dB) from the full IR's spectrum, on the same frequencies
444    let step = nf / n;
445    let target: Vec<f64> = bands
446        .iter()
447        .map(|b| {
448            let p = b.clone().map(|k| full_smoothed[k * step]).sum::<f64>() / b.len() as f64;
449            10.0 * p.max(1e-30).log10()
450        })
451        .collect();
452    let mut planner = rustfft::FftPlanner::new();
453    let (fwd, inv) = (planner.plan_fft_forward(n), planner.plan_fft_inverse(n));
454    let scale = h0.iter().fold(0f64, |m, v| m.max(v.abs())).max(1e-12);
455    let mut h: Vec<f64> = h0.iter().map(|v| v / scale).collect();
456    let eval = |h: &[f64], grad: Option<&mut [f64]>| -> f64 {
457        let mut c: Vec<Complex64> = h.iter().map(|&v| Complex64::new(v, 0.0)).collect();
458        c.resize(n, Complex64::default());
459        fwd.process(&mut c);
460        let p: Vec<f64> = bands.iter().map(|b| b.clone().map(|k| c[k].norm_sqr()).sum::<f64>() / b.len() as f64).collect();
461        let d: Vec<f64> = p.iter().zip(&target).map(|(p, t)| 10.0 * p.max(1e-30).log10() - t).collect();
462        let mean = d.iter().sum::<f64>() / d.len() as f64;
463        let e = d.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / d.len() as f64;
464        if let Some(g) = grad {
465            // dE/dH_k for every bin of every band, then back to the taps through the FFT
466            let mut gk = vec![Complex64::default(); n];
467            for ((b, dv), pv) in bands.iter().zip(&d).zip(&p) {
468                let de_dp = 2.0 * (dv - mean) / d.len() as f64 * 10.0 / (std::f64::consts::LN_10 * pv.max(1e-30));
469                for k in b.clone() {
470                    gk[k] += c[k] * (2.0 * de_dp / b.len() as f64);
471                }
472            }
473            inv.process(&mut gk);
474            for (gi, v) in g.iter_mut().zip(&gk) {
475                *gi = v.re;
476            }
477        }
478        e
479    };
480    let (b1, b2, eps, lr) = (0.9, 0.999, 1e-12, 2e-3);
481    let (mut m, mut v) = (vec![0.0; h.len()], vec![0.0; h.len()]);
482    let mut g = vec![0.0; h.len()];
483    let mut best = (eval(&h, None), h.clone());
484    let steps: usize = 400;
485    for it in 0..steps {
486        let e = eval(&h, Some(&mut g));
487        if e < best.0 {
488            best = (e, h.clone());
489        }
490        let t = (it + 1) as i32;
491        let rate = lr * 0.5 * (1.0 + (std::f64::consts::PI * it as f64 / steps as f64).cos());
492        for i in 0..h.len() {
493            m[i] = b1 * m[i] + (1.0 - b1) * g[i];
494            v[i] = b2 * v[i] + (1.0 - b2) * g[i] * g[i];
495            h[i] -= rate * (m[i] / (1.0 - f64::powi(b1, t))) / ((v[i] / (1.0 - f64::powi(b2, t))).sqrt() + eps);
496        }
497    }
498    let e = eval(&h, None);
499    if e < best.0 {
500        best = (e, h);
501    }
502    best.1.iter().map(|v| v * scale).collect()
503}
504
505/// The optimised conversion: a [`PEDAL_IR_TAPS`]-tap filter (the part of an upload the pedal
506/// plays) with the whole IR's 1/6-octave magnitude response, instead of the IR's first 2048 samples
507/// cut off by the pedal after 384. The file is resampled with [`resample`] (windowed sinc); the
508/// filter is minimum-phase (no leading delay, energy packed into the start), refined over a few
509/// iterations, then fitted tap by tap to the full IR's band levels. Its level is the one the app
510/// means to give the IR ([`app_gain`] on the IR's first 2048 samples from its onset), matched
511/// over 100 Hz – 8 kHz. The unused part of the upload is zero.
512#[cfg(feature = "nam")]
513pub fn convert_optimised(wav: &Wav) -> ConvertedIr {
514    let ch = wav.channels.first().map(Vec::as_slice).unwrap_or(&[]);
515    let ir = resample(ch, wav.sample_rate, IR_SAMPLE_RATE);
516    let x: Vec<f64> = ir.iter().map(|&v| v as f64).collect();
517    let nf = x.len().next_power_of_two().max(DESIGN_FFT);
518    let full = smooth_power(&power_spectrum(&x, nf));
519    let step = nf / DESIGN_FFT;
520    // the target on the design grid (magnitude), never below -120 dB of its peak
521    let target: Vec<f64> = (0..=DESIGN_FFT / 2).map(|k| full[k * step].sqrt()).collect();
522    let peak = target.iter().fold(0f64, |m, &v| m.max(v));
523    let target: Vec<f64> = target.iter().map(|&v| v.max(peak * 1e-6)).collect();
524
525    // the last 48 taps fade out (half cosine), so the cut at 384 doesn't ring
526    let fade = 48;
527    let window = |h: &mut [f64]| {
528        for (i, v) in h.iter_mut().enumerate().skip(PEDAL_IR_TAPS - fade) {
529            let t = (i - (PEDAL_IR_TAPS - fade)) as f64 / fade as f64;
530            *v *= 0.5 + 0.5 * (std::f64::consts::PI * t).cos();
531        }
532    };
533    // error of a design: rms of the log-magnitude difference over 30 Hz – 16 kHz (1/6-octave bins),
534    // with the level difference removed
535    let lo_bin = (30.0 * DESIGN_FFT as f64 / 48000.0) as usize;
536    let hi_bin = (16000.0 * DESIGN_FFT as f64 / 48000.0) as usize;
537    let probe: Vec<usize> = std::iter::successors(Some(lo_bin as f64), |b| Some(b * 2f64.powf(1.0 / 24.0)))
538        .map(|b| b.round() as usize)
539        .take_while(|&b| b <= hi_bin)
540        .collect();
541    let response = |h: &[f64]| -> Vec<f64> { smooth_power(&power_spectrum(h, DESIGN_FFT)).iter().map(|p| p.sqrt()).collect() };
542    let error = |r: &[f64]| {
543        let d: Vec<f64> = probe.iter().map(|&k| 20.0 * (r[k].max(1e-30) / target[k]).log10()).collect();
544        let mean = d.iter().sum::<f64>() / d.len() as f64;
545        (d.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / d.len() as f64).sqrt()
546    };
547    let mut design = target.clone();
548    let mut best: Option<(f64, Vec<f64>)> = None;
549    for _ in 0..12 {
550        let mut h = min_phase(&design, PEDAL_IR_TAPS);
551        window(&mut h);
552        let r = response(&h);
553        let e = error(&r);
554        if best.as_ref().is_none_or(|b| e < b.0) {
555            best = Some((e, h));
556        }
557        // push the design towards the target where the 384 taps fall short, within ±12 dB
558        for k in 0..design.len() {
559            let corr = (target[k] / r[k].max(1e-30)).powf(0.8);
560            design[k] = (design[k] * corr).clamp(target[k] / 4.0, target[k] * 4.0);
561        }
562    }
563    let h0 = best.map(|b| b.1).unwrap_or_default();
564    let h = refine(&h0, nf, &full);
565    // level: what the app means to give this IR (its rule on the first 2048 samples, counted from
566    // the IR's onset so leading silence doesn't skew it), matched over 100 Hz – 8 kHz
567    let peak_x = x.iter().fold(0f64, |m, v| m.max(v.abs()));
568    let onset = x.iter().position(|v| v.abs() > 0.01 * peak_x).unwrap_or(0).saturating_sub(8);
569    let head: Vec<f32> = x[onset..].iter().take(IR_TAPS).map(|&v| v as f32).collect();
570    let g_ref = app_gain(&head);
571    let bands = fit_bands(DESIGN_FFT);
572    let step = nf / DESIGN_FFT;
573    let ours = smooth_power(&power_spectrum(&h, DESIGN_FFT));
574    let (mut sum, mut count) = (0.0, 0);
575    for b in &bands {
576        let f = b.start as f64 * IR_SAMPLE_RATE as f64 / DESIGN_FFT as f64;
577        if (100.0..=8000.0).contains(&f) {
578            let o = b.clone().map(|k| ours[k]).sum::<f64>() / b.len() as f64;
579            let t = b.clone().map(|k| full[k * step]).sum::<f64>() / b.len() as f64 * g_ref * g_ref;
580            sum += 10.0 * (t.max(1e-30) / o.max(1e-30)).log10();
581            count += 1;
582        }
583    }
584    let gain = 10f64.powf(sum / count.max(1) as f64 / 20.0);
585    let h: Vec<f32> = h.iter().map(|&v| v as f32).collect();
586    let mut data = vec![0.0f32; IR_TAPS];
587    for (k, &v) in h.iter().enumerate() {
588        data[IR_TAPS - 1 - k] = (gain * v as f64) as f32;
589    }
590    ConvertedIr { data, gain: gain as f32, ir }
591}
592
593/// How an IR is converted.
594#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
595pub enum Method {
596    /// Exactly the official app's conversion ([`convert`]).
597    #[default]
598    App,
599    /// A 384-tap filter fitted to the whole IR ([`convert_optimised`]; feature `nam`).
600    #[cfg(feature = "nam")]
601    Optimised,
602}
603
604/// Convert with the given [`Method`].
605pub fn convert_with(wav: &Wav, method: Method) -> ConvertedIr {
606    match method {
607        Method::App => convert(wav),
608        #[cfg(feature = "nam")]
609        Method::Optimised => convert_optimised(wav),
610    }
611}
612
613/// The slot name the official app gives an imported IR: the file name without its extension, cut
614/// to 16 characters (seen in the one captured import; the record has room for 31).
615pub fn slot_name(path: &std::path::Path) -> String {
616    path.file_stem().map(|s| s.to_string_lossy().chars().take(16).collect()).unwrap_or_default()
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    /// A minimal WAV file around `data`.
624    fn wav(tag: u16, channels: u16, rate: u32, bits: u16, data: &[u8]) -> Vec<u8> {
625        let mut f = vec![];
626        f.extend_from_slice(&tag.to_le_bytes());
627        f.extend_from_slice(&channels.to_le_bytes());
628        f.extend_from_slice(&rate.to_le_bytes());
629        f.extend_from_slice(&(rate * channels as u32 * bits as u32 / 8).to_le_bytes());
630        f.extend_from_slice(&(channels * bits / 8).to_le_bytes());
631        f.extend_from_slice(&bits.to_le_bytes());
632        let mut b = b"RIFF\0\0\0\0WAVE".to_vec();
633        for (id, body) in [(b"fmt ", &f[..]), (b"LIST", &b"junk"[..]), (b"data", data)] {
634            b.extend_from_slice(id);
635            b.extend_from_slice(&(body.len() as u32).to_le_bytes());
636            b.extend_from_slice(body);
637        }
638        b
639    }
640
641    #[test]
642    fn pcm_and_float() {
643        let w = Wav::parse(&wav(PCM, 1, 48000, 24, &[0, 0, 0x40, 0, 0, 0xC0, 0xFF, 0xFF, 0x7F])).unwrap();
644        assert_eq!(w.sample_rate, 48000);
645        assert_eq!(w.channels, vec![vec![0.5, -0.5, 8_388_607.0 / 8_388_608.0]]);
646        let w = Wav::parse(&wav(PCM, 2, 44100, 16, &[0, 0x40, 0, 0xC0])).unwrap();
647        assert_eq!(w.channels, vec![vec![0.5], vec![-0.5]]);
648        let d: Vec<u8> = [0.25f32, -1.0].iter().flat_map(|v| v.to_le_bytes()).collect();
649        assert_eq!(Wav::parse(&wav(FLOAT, 1, 96000, 32, &d)).unwrap().channels, vec![vec![0.25, -1.0]]);
650    }
651
652    #[test]
653    fn write_and_read_back() {
654        let w = Wav::new(44100, vec![vec![0.5, -0.25, 0.0], vec![0.125, 1.0 - 1.0 / 8_388_608.0, -1.0]]);
655        for (bits, float, enc) in [(24, false, Encoding::Pcm(24)), (32, true, Encoding::Float(32))] {
656            assert_eq!(Wav::parse(&w.to_bytes(bits, float)).unwrap(), Wav { encoding: enc, ..w.clone() });
657        }
658        let r = Wav::parse(&w.to_bytes(16, false)).unwrap();
659        assert!(r.channels[0].iter().zip(&w.channels[0]).all(|(a, b)| (a - b).abs() < 1e-4));
660    }
661
662    #[test]
663    fn resampling_keeps_a_sine() {
664        let f = 1000.0;
665        let x: Vec<f32> = (0..4410).map(|n| (std::f64::consts::TAU * f * n as f64 / 44100.0).sin() as f32).collect();
666        let y = resample(&x, 44100, 48000);
667        assert_eq!(y.len(), 4800);
668        // away from the ends (the window needs ~32 cycles of context)
669        for (n, &v) in y.iter().enumerate().take(4400).skip(400) {
670            let want = (std::f64::consts::TAU * f * n as f64 / 48000.0).sin() as f32;
671            assert!((v - want).abs() < 1e-3, "sample {n}: {v} vs {want}");
672        }
673        assert_eq!(resample(&x, 48000, 48000), x);
674        assert_eq!((resample(&x[..441], 44100, 48000).len(), resample(&x[..442], 44100, 48000).len()), (480, 482));
675    }
676
677    #[test]
678    fn conversion_layout() {
679        let mut ch = vec![0.0f32; 3000];
680        (ch[0], ch[2500]) = (0.25, 0.9);
681        let mut w = Wav::new(48000, vec![ch, vec![1.0; 3000]]);
682        let c = convert(&w);
683        assert_eq!(c.data.len(), IR_TAPS);
684        // first channel, reversed, nothing past IR_TAPS; a lone impulse is scaled to 1
685        assert!((c.data[IR_TAPS - 1] - 1.0).abs() < 1e-6 && (c.gain - 4.0).abs() < 1e-5);
686        assert_eq!(c.data.iter().filter(|v| **v != 0.0).count(), 1);
687        // short IRs are zero-padded; a 48 kHz mono 24-bit file is used as is
688        w = Wav { sample_rate: 48000, channels: vec![vec![-0.5]], encoding: Encoding::Pcm(24) };
689        let s = convert(&w);
690        assert_eq!((s.data[IR_TAPS - 1], s.data[0], s.ir.len()), (-1.0, 0.0, 1));
691        assert_eq!(slot_name(std::path::Path::new("/x/Delay And Reverb Ambient.wav")), "Delay And Reverb");
692    }
693
694    #[test]
695    fn app_gain_centres_the_mid_band() {
696        // two impulses whose response swings between about 0.75 and 0.25 across the band
697        // (≈ 1/√(0.75·0.25)); 2.309391 is what the official app applied to this IR
698        let mut x = vec![0.0f32; 2048];
699        (x[0], x[1000]) = (0.5, -0.25);
700        assert!((app_gain(&x) / 2.309391 - 1.0).abs() < 1e-6, "{}", app_gain(&x));
701    }
702
703    #[test]
704    fn app_resampler_basics() {
705        // 1:1 is a copy; the output length is ⌊len · to / from⌋
706        let x: Vec<f32> = (0..100).map(|i| (i as f32 * 0.1).sin()).collect();
707        assert_eq!(resample_like_app(&x, 48000, 48000), x);
708        assert_eq!((resample_like_app(&x, 44100, 48000).len(), resample_like_app(&x, 96000, 48000).len()), (108, 50));
709        // DC passes the low-pass at unity gain
710        let dc = resample_like_app(&[0.5; 4000], 44100, 48000);
711        assert!((dc[3000] - 0.5).abs() < 1e-4);
712    }
713
714    #[cfg(feature = "nam")]
715    #[test]
716    fn optimised_conversion_fits_a_delayed_ir_into_384_taps() {
717        // a decaying resonance that starts 1000 samples in: the official upload puts it out of the
718        // pedal's reach; the optimised one keeps its response within the 384 played taps
719        let x: Vec<f32> = (0..6000)
720            .map(|n| if n < 1000 { 0.0 } else { let t = (n - 1000) as f32; (-t / 120.0).exp() * (t * 0.05).sin() + if n == 1000 { 0.5 } else { 0.0 } })
721            .collect();
722        let w = Wav::new(48000, vec![x]);
723        let c = convert_optimised(&w);
724        assert_eq!(c.data.len(), IR_TAPS);
725        assert!(c.data[..IR_TAPS - PEDAL_IR_TAPS].iter().all(|&v| v == 0.0));
726        let played: f32 = c.data[IR_TAPS - PEDAL_IR_TAPS..].iter().map(|v| v * v).sum();
727        let official = convert(&w);
728        let official_played: f32 = official.data[IR_TAPS - PEDAL_IR_TAPS..].iter().map(|v| v * v).sum();
729        assert!(c.data.iter().all(|v| v.is_finite()) && played > 1e3 * official_played, "{played} vs {official_played}");
730        assert_eq!(convert_with(&w, Method::App).data, official.data);
731    }
732
733    #[test]
734    fn errors() {
735        assert!(matches!(Wav::parse(b"nope"), Err(WavError::Format(_))));
736        assert!(matches!(Wav::parse(&wav(2, 1, 48000, 4, &[0; 8])), Err(WavError::Unsupported(_))));
737    }
738}