Skip to main content

secs_engine/
fit.rs

1//! The shared greedy/TRF PEQ fitter, ringing limits, GD cuts and acoustic XO target.
2//! Upstream: `correction_engine.py` `fit_peq`, `_ring_q_cap`, `_ring_boxes`,
3//! `fit_gd_cuts`, `CorrectionEngine._xo_target_peq`.
4
5use crate::grid::{SR_F, design_freqs};
6use faer::Mat;
7use secs_dsp::{
8    Complex64 as C, optimize, response,
9    smooth::{self, Fraction},
10};
11use secs_types::{chain::Peq, target::TargetCurve};
12use std::{cell::RefCell, f64::consts::PI};
13
14/// One `fit_peq` call's arguments. The grid and correction must have equal,
15/// nonzero length; frequencies, bounds and residuals must be finite and valid.
16#[derive(Debug, Clone)]
17pub struct FitRequest<'a> {
18    pub g: &'a [f64],
19    pub corr_db: &'a [f64],
20    pub n: usize,
21    pub lo: f64,
22    pub hi: f64,
23    pub boost: f64,
24    pub ring_ms: Option<f64>,
25    pub f_max: Option<f64>,
26    /// Optional correction-band union, intersected with `lo..hi` for weights.
27    /// None retains the legacy contiguous fit and unconstrained centres.
28    pub bands: Option<&'a [(f64, f64)]>,
29}
30
31impl FitRequest<'_> {
32    fn in_range(&self, f: f64) -> bool {
33        f >= self.lo && f <= self.hi && self.bands.is_none_or(|bands| crate::rc::in_bands(f, bands))
34    }
35}
36
37/// Production uses `fit_peq`; stage goldens inject recorded decisions here.
38/// Callers may reuse the result of an identical request across candidates.
39pub type Fitter<'f> = dyn FnMut(&FitRequest<'_>) -> Vec<Peq> + 'f;
40
41/// How PEQ fits differentiate their bands.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum PeqDerivatives {
44    /// SciPy's two-point differences, as upstream.
45    #[default]
46    TwoPoint,
47    /// Closed-form cascade Jacobian (experimental).
48    Analytic,
49}
50
51pub fn ring_q_cap(f0: f64, gain_db: f64, ring_ms: f64) -> f64 {
52    PI * f0 * ring_ms / 1000.0 / 10_f64.powf(gain_db / 40.0)
53}
54
55pub(crate) fn ring_boxes(bands: &[Peq], lb: &mut [f64], ub: &mut [f64], ring_ms: f64) {
56    for (i, &(f0, _, gain)) in bands.iter().enumerate() {
57        let k = 3 * i;
58        lb[k] = lb[k].max((f0 / 1.2).ln());
59        ub[k] = ub[k].min((f0 * 1.2).ln()).max(lb[k] + 1e-3);
60        if gain > 0.0 {
61            lb[k + 2] = 0.0;
62            ub[k + 2] = (gain + 3.0).min(ub[k + 2]);
63        } else {
64            ub[k + 2] = (gain / 2.0).min(-0.25);
65        }
66        ub[k + 1] = ub[k + 1]
67            .exp()
68            .min(ring_q_cap(f0 / 1.2, ub[k + 2], ring_ms))
69            .max(0.31)
70            .ln();
71    }
72}
73
74pub(crate) struct Seed {
75    pub bands: Vec<Peq>,
76    pub x0: Vec<f64>,
77    pub lb: Vec<f64>,
78    pub ub: Vec<f64>,
79}
80
81pub(crate) fn seed(req: &FitRequest<'_>) -> Seed {
82    assert_eq!(req.g.len(), req.corr_db.len());
83    let mut bands = Vec::new();
84    for _ in 0..req.n {
85        let current = response::peq_response(req.g, &bands);
86        let r: Vec<_> = req
87            .g
88            .iter()
89            .zip(req.corr_db)
90            .zip(current)
91            .map(|((&g, &c), h)| {
92                if req.in_range(g) {
93                    c - 20.0 * (response::magnitude(h) + 1e-12).log10()
94                } else {
95                    0.0
96                }
97            })
98            .collect();
99        let k = argmax(&r.iter().map(|v| v.abs()).collect::<Vec<_>>());
100        if r[k].abs() < 0.5 {
101            break;
102        }
103        let gain = r[k].clamp(-25.0, req.boost);
104        let half = r[k].abs() / 2.0;
105        let same = |i: usize| r[i].abs() >= half && r[i].signum() == r[k].signum();
106        let mut i0 = k;
107        let mut i1 = k;
108        while i0 > 0 && same(i0 - 1) {
109            i0 -= 1;
110        }
111        while i1 + 1 < r.len() && same(i1 + 1) {
112            i1 += 1;
113        }
114        let f0 = req.g[k];
115        let mut q = (f0 / (req.g[i1] - req.g[i0]).max(f0 * 0.02)).clamp(0.5, 15.0);
116        if let Some(ms) = req.ring_ms {
117            q = q.min(ring_q_cap(f0, gain, ms)).max(0.31);
118        }
119        bands.push((f0, q, gain));
120    }
121    let fmax = req.f_max.unwrap_or(2000.0_f64.max(req.hi * 1.2));
122    let mut lb = [15_f64.ln(), 0.3_f64.ln(), -25.0].repeat(bands.len());
123    let mut ub = [fmax.ln(), 20_f64.ln(), req.boost].repeat(bands.len());
124    if let Some(ms) = req.ring_ms {
125        ring_boxes(&bands, &mut lb, &mut ub, ms);
126    }
127    // A multi-band seed must not drift into an uncorrected gap during TRF.
128    // Legacy fits keep their original bounds, including the split-fit overlap.
129    if let Some(regions) = req.bands {
130        for (i, &(f, _, _)) in bands.iter().enumerate() {
131            let &(lo, hi) = regions
132                .iter()
133                .find(|&&(lo, hi)| f >= lo && f <= hi)
134                .expect("seed belongs to a correction band");
135            lb[3 * i] = lb[3 * i].max(lo.ln());
136            ub[3 * i] = ub[3 * i].min(hi.ln());
137        }
138    }
139    let x0 = bands
140        .iter()
141        .flat_map(|&(f, q, g)| [f.ln(), q.ln(), g])
142        .enumerate()
143        .map(|(i, x)| {
144            let margin = if req.bands.is_some() {
145                1e-6_f64.min((ub[i] - lb[i]) / 4.0)
146            } else {
147                1e-6
148            };
149            x.max(lb[i] + margin).min(ub[i] - margin)
150        })
151        .collect();
152    Seed { bands, x0, lb, ub }
153}
154
155pub(crate) fn residual(req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
156    let bands: Vec<_> = x
157        .as_chunks::<3>()
158        .0
159        .iter()
160        .map(|b| (b[0].exp(), b[1].exp(), b[2]))
161        .collect();
162    weighted_residual(req, response::peq_response(req.g, &bands))
163}
164
165fn weighted_residual(req: &FitRequest<'_>, h: Vec<C>) -> Vec<f64> {
166    h.into_iter()
167        .enumerate()
168        .map(|(i, h)| {
169            let w = if req.in_range(req.g[i]) { 1.0 } else { 0.15 };
170            let value = w * (20.0 * (response::magnitude(h) + 1e-12).log10() - req.corr_db[i]);
171            if value.is_finite() { value } else { 1e12 }
172        })
173        .collect()
174}
175
176/// Two-point Jacobian probes change only one band. Reuse the other bands'
177/// responses, but always multiply the complete cascade in its original order.
178pub(crate) struct ResidualCache {
179    z: Vec<C>,
180    bands: Vec<Option<([u64; 3], Vec<C>)>>,
181}
182
183impl ResidualCache {
184    pub(crate) fn new(g: &[f64], bands: usize) -> Self {
185        Self {
186            z: g.iter()
187                .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
188                .collect(),
189            bands: vec![None; bands],
190        }
191    }
192
193    pub(crate) fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
194        let mut h = vec![C::new(1.0, 0.0); self.z.len()];
195        for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
196            let key = b.map(f64::to_bits);
197            if cached.as_ref().is_none_or(|(old, _)| *old != key) {
198                *cached = Some((
199                    key,
200                    response::peaking_response_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0),
201                ));
202            }
203            for (h, p) in h.iter_mut().zip(&cached.as_ref().unwrap().1) {
204                *h *= p;
205            }
206        }
207        weighted_residual(req, h)
208    }
209}
210
211struct AnalyticBand {
212    key: [u64; 3],
213    response: Vec<C>,
214    logarithmic_derivatives: Option<[Vec<C>; 3]>,
215}
216
217/// Analytic response and derivative cache. The two-point path keeps using
218/// `ResidualCache`, so its arithmetic and cache behavior remain unchanged.
219struct AnalyticResidualCache {
220    z: Vec<C>,
221    bands: Vec<Option<AnalyticBand>>,
222    key: Vec<u64>,
223    cascade: Vec<C>,
224}
225
226impl AnalyticResidualCache {
227    fn new(g: &[f64], bands: usize) -> Self {
228        Self {
229            z: g.iter()
230                .map(|&f| C::from_polar(1.0, (-2.0 * PI * f) * (1.0 / 48000.0)))
231                .collect(),
232            bands: (0..bands).map(|_| None).collect(),
233            key: Vec::new(),
234            cascade: Vec::new(),
235        }
236    }
237
238    fn response(&mut self, x: &[f64]) {
239        if self.key.len() == x.len()
240            && self.key.iter().zip(x).all(|(&a, &b)| a == b.to_bits())
241            && !self.cascade.is_empty()
242        {
243            return;
244        }
245        self.cascade.clear();
246        self.cascade.resize(self.z.len(), C::new(1.0, 0.0));
247        for (cached, b) in self.bands.iter_mut().zip(x.as_chunks::<3>().0) {
248            let key = b.map(f64::to_bits);
249            if cached.as_ref().is_none_or(|old| old.key != key) {
250                *cached = Some(AnalyticBand {
251                    key,
252                    response: response::peaking_response_z(
253                        &self.z,
254                        b[0].exp(),
255                        b[1].exp(),
256                        b[2],
257                        48000.0,
258                    ),
259                    logarithmic_derivatives: None,
260                });
261            }
262            for (h, p) in self
263                .cascade
264                .iter_mut()
265                .zip(&cached.as_ref().unwrap().response)
266            {
267                *h *= p;
268            }
269        }
270        self.key.clear();
271        self.key.extend(x.iter().map(|v| v.to_bits()));
272    }
273
274    fn evaluate(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Vec<f64> {
275        self.response(x);
276        weighted_residual(req, self.cascade.clone())
277    }
278
279    fn jacobian(&mut self, req: &FitRequest<'_>, x: &[f64]) -> Mat<f64> {
280        self.response(x);
281        let factors: Vec<_> = self
282            .cascade
283            .iter()
284            .enumerate()
285            .map(|(i, &h)| {
286                let weight = if req.in_range(req.g[i]) { 1.0 } else { 0.15 };
287                let magnitude = response::magnitude(h);
288                weight * (20.0 / 10_f64.ln()) * magnitude / (magnitude + 1e-12)
289            })
290            .collect();
291        let mut jac = Mat::zeros(req.g.len(), x.len());
292        for (k, (cached, b)) in self.bands.iter_mut().zip(x.as_chunks::<3>().0).enumerate() {
293            let cached = cached.as_mut().unwrap();
294            let derivatives = cached.logarithmic_derivatives.get_or_insert_with(|| {
295                response::peaking_log_derivatives_z(&self.z, b[0].exp(), b[1].exp(), b[2], 48000.0)
296            });
297            for c in 0..3 {
298                for i in 0..req.g.len() {
299                    jac[(i, 3 * k + c)] = factors[i] * derivatives[c][i].re;
300                }
301            }
302        }
303        jac
304    }
305}
306
307pub(crate) fn result_bands(req: &FitRequest<'_>, x: &[f64]) -> Vec<Peq> {
308    x.as_chunks::<3>()
309        .0
310        .iter()
311        .map(|b| {
312            let (f, mut q, gain) = (b[0].exp(), b[1].exp(), b[2]);
313            if let Some(ms) = req.ring_ms {
314                q = q.min(ring_q_cap(f, gain, ms));
315            }
316            (f, q, gain)
317        })
318        .collect()
319}
320
321/// Fits the requested region, retaining upstream's out-of-region weight 0.15,
322/// default TRF tolerances, and 400/150 residual-evaluation caps.
323pub fn fit_peq(req: &FitRequest<'_>) -> Vec<Peq> {
324    fit_peq_until(req, &|| false)
325}
326
327/// `fit_peq` that gives up once `cancelled()` turns true. From then on the
328/// residual reads zero, so the trust-region solver stops at its next gradient
329/// test instead of running to its evaluation cap; the bands it returns are
330/// meaningless and the caller discards them. Until then the arithmetic is
331/// exactly `fit_peq`'s.
332pub fn fit_peq_until(req: &FitRequest<'_>, cancelled: &dyn Fn() -> bool) -> Vec<Peq> {
333    fit_peq_with(req, cancelled, PeqDerivatives::TwoPoint)
334}
335
336/// `fit_peq_until` with a chosen derivative; `TwoPoint` is the same arithmetic.
337pub fn fit_peq_with(
338    req: &FitRequest<'_>,
339    cancelled: &dyn Fn() -> bool,
340    derivatives: PeqDerivatives,
341) -> Vec<Peq> {
342    let seed = seed(req);
343    if seed.bands.is_empty() {
344        return Vec::new();
345    }
346    let max_nfev = if req.ring_ms.is_some() { 150 } else { 400 };
347    let sol = match derivatives {
348        PeqDerivatives::TwoPoint => {
349            let mut cache = ResidualCache::new(req.g, seed.bands.len());
350            optimize::least_squares(
351                |x| {
352                    if cancelled() {
353                        vec![0.0; req.g.len()]
354                    } else {
355                        cache.evaluate(req, x)
356                    }
357                },
358                &seed.x0,
359                &seed.lb,
360                &seed.ub,
361                max_nfev,
362            )
363        }
364        PeqDerivatives::Analytic => {
365            let cache = RefCell::new(AnalyticResidualCache::new(req.g, seed.bands.len()));
366            optimize::least_squares_analytic(
367                |x| {
368                    if cancelled() {
369                        vec![0.0; req.g.len()]
370                    } else {
371                        cache.borrow_mut().evaluate(req, x)
372                    }
373                },
374                |x| {
375                    if cancelled() {
376                        Mat::zeros(req.g.len(), x.len())
377                    } else {
378                        cache.borrow_mut().jacobian(req, x)
379                    }
380                },
381                &seed.x0,
382                &seed.lb,
383                &seed.ub,
384                optimize::Options {
385                    max_nfev,
386                    ..optimize::Options::default()
387                },
388                None,
389            )
390        }
391    };
392    let Ok(sol) = sol else {
393        // Measurement-dependent probes can still make a fit numerically invalid.
394        return result_bands(req, &seed.x0);
395    };
396    result_bands(req, &sol.x)
397}
398
399fn argmax(v: &[f64]) -> usize {
400    assert!(!v.is_empty());
401    (1..v.len()).fold(0, |k, i| if v[i] > v[k] { i } else { k })
402}
403fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
404    let start = lo.log10();
405    let step = (hi.log10() - start) / (n - 1) as f64;
406    let mut g: Vec<_> = (0..n)
407        .map(|i| 10_f64.powf(i as f64 * step + start))
408        .collect();
409    g[0] = lo;
410    g[n - 1] = hi;
411    g
412}
413fn interp(x: f64, f: &[f64], y: &[f64]) -> f64 {
414    let i = f.partition_point(|v| *v <= x).saturating_sub(1);
415    if x <= f[0] {
416        y[0]
417    } else if i + 1 == f.len() || x == f[i] {
418        y[i]
419    } else {
420        (y[i + 1] - y[i]) / (f[i + 1] - f[i]) * (x - f[i]) + y[i]
421    }
422}
423fn median(v: &[f64]) -> f64 {
424    let mut v = v.to_vec();
425    v.sort_by(f64::total_cmp);
426    let k = v.len() / 2;
427    if v.len().is_multiple_of(2) {
428        (v[k - 1] + v[k]) / 2.0
429    } else {
430        v[k]
431    }
432}
433// NumPy's pairwise contiguous sum, used by the GD improvement decision.
434fn sum(v: &[f64]) -> f64 {
435    if v.len() < 8 {
436        return v.iter().fold(-0.0, |s, x| s + x);
437    }
438    if v.len() > 128 {
439        let m = (v.len() / 2) / 8 * 8;
440        return sum(&v[..m]) + sum(&v[m..]);
441    }
442    let mut r: [f64; 8] = v[..8].try_into().unwrap();
443    let end = v.len() / 8 * 8;
444    for c in v[8..end].as_chunks::<8>().0 {
445        for (s, x) in r.iter_mut().zip(c) {
446            *s += x;
447        }
448    }
449    let s = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
450    v[end..].iter().fold(s, |s, x| s + x)
451}
452
453pub fn fit_gd_cuts(f: &[f64], h: &[C], lo: f64, hi: f64, n_max: usize, depth_db: f64) -> Vec<Peq> {
454    fit_gd_cuts_until(f, h, lo, hi, n_max, depth_db, &|| false)
455}
456
457/// `fit_gd_cuts` that gives up once `cancelled()` turns true (TD-49), as
458/// `fit_peq_until` does: no further cut starts, and the running fit's residual
459/// reads zero so the trust-region solver stops at its next gradient test. The
460/// cuts it returns are then meaningless and the caller discards them. Until
461/// then the arithmetic is exactly `fit_gd_cuts`'s.
462pub fn fit_gd_cuts_until(
463    f: &[f64],
464    h: &[C],
465    lo: f64,
466    hi: f64,
467    n_max: usize,
468    depth_db: f64,
469    cancelled: &dyn Fn() -> bool,
470) -> Vec<Peq> {
471    let g = geomspace(lo, hi, 1500);
472    gd_cuts_on_grid(f, h, &g, None, n_max, depth_db, cancelled)
473}
474
475pub(crate) fn fit_gd_cuts_bands_until(
476    f: &[f64],
477    h: &[C],
478    bands: &[(f64, f64)],
479    n_max: usize,
480    depth_db: f64,
481    cancelled: &dyn Fn() -> bool,
482) -> Vec<Peq> {
483    let g = crate::rc::band_grid(bands, 20.0, 1500);
484    gd_cuts_on_grid(f, h, &g, Some(bands), n_max, depth_db, cancelled)
485}
486
487#[allow(clippy::too_many_arguments)]
488fn gd_cuts_on_grid(
489    f: &[f64],
490    h: &[C],
491    g: &[f64],
492    bands: Option<&[(f64, f64)]>,
493    n_max: usize,
494    depth_db: f64,
495    cancelled: &dyn Fn() -> bool,
496) -> Vec<Peq> {
497    let mut cur = h.to_vec();
498    let mut cuts = Vec::new();
499    // Band grids can come out too short when no band holds a frequency.
500    if bands.is_some() && g.len() < 2 {
501        return cuts;
502    }
503    for _ in 0..n_max {
504        if cancelled() {
505            break;
506        }
507        let gd = response::gd_curve(&cur, f, g, Fraction::Scalar(24.0)).expect("valid GD grid");
508        let base: Vec<_> = (0..g.len())
509            .map(|i| median(&gd[i.saturating_sub(40)..(i + 40).min(g.len())]))
510            .collect();
511        let mut exc: Vec<_> = gd.iter().zip(&base).map(|(g, b)| g - b).collect();
512        // With correction bands, skip a peak that lies in no band or leaves
513        // the cut no room to move, and take the next one.
514        let (k, centre) = loop {
515            let k = argmax(&exc);
516            if exc[k] < 10.0 {
517                break (k, None);
518            }
519            match cut_centre_bounds(g[k], bands) {
520                Some(bounds) => break (k, Some(bounds)),
521                None => exc[k] = f64::NEG_INFINITY,
522            }
523        };
524        let Some((centre_lb, centre_ub)) = centre else {
525            break;
526        };
527        let f0 = g[k];
528        let width = 2_f64.powf(1.0 / 3.0);
529        let gw: Vec<_> = g
530            .iter()
531            .copied()
532            .filter(|&g| g >= f0 / width && g <= f0 * width)
533            .collect();
534        let magnitude: Vec<_> = cur.iter().map(|&h| response::magnitude(h)).collect();
535        let mag0: Vec<_> = gw
536            .iter()
537            .map(|&g| 20.0 * (interp(g, f, &magnitude).abs() + 1e-12).log10())
538            .collect();
539        let res = |x: &[f64]| {
540            let hp: Vec<_> = cur
541                .iter()
542                .zip(response::peaking_response(
543                    f,
544                    x[0].exp(),
545                    x[1].exp(),
546                    x[2],
547                    SR_F,
548                ))
549                .map(|(h, p)| h * p)
550                .collect();
551            let gd2 =
552                response::gd_curve(&hp, f, &gw, Fraction::Scalar(24.0)).expect("valid GD grid");
553            let mag: Vec<_> = hp.iter().map(|&h| response::magnitude(h)).collect();
554            gd2.iter()
555                .zip(&gw)
556                .map(|(gd, &w)| (gd - interp(w, g, &base)) / 5.0)
557                .chain(
558                    gw.iter()
559                        .zip(&mag0)
560                        .map(|(&w, m)| 20.0 * (interp(w, f, &mag).abs() + 1e-12).log10() - m),
561                )
562                .collect::<Vec<_>>()
563        };
564        let x0 = [f0.ln(), 8_f64.ln(), -1.0];
565        let lb = [centre_lb, 2_f64.ln(), -depth_db];
566        let ub = [centre_ub, 40_f64.ln(), 0.0];
567        let sol = optimize::least_squares(
568            |x| {
569                if cancelled() {
570                    vec![0.0; 2 * gw.len()]
571                } else {
572                    res(x)
573                }
574            },
575            &x0,
576            &lb,
577            &ub,
578            60,
579        )
580        .expect("valid GD fit");
581        if cancelled() {
582            break;
583        }
584        let band = (sol.x[0].exp(), sol.x[1].exp(), sol.x[2]);
585        let norm = |x: &[f64]| sum(&res(x).iter().map(|v| v * v).collect::<Vec<_>>());
586        if norm(&sol.x) >= norm(&[f0.ln(), 8_f64.ln(), 0.0]) * 0.95 || band.2 > -0.1 {
587            break;
588        }
589        cuts.push(band);
590        for (h, p) in cur
591            .iter_mut()
592            .zip(response::peaking_response(f, band.0, band.1, band.2, SR_F))
593        {
594            *h *= p;
595        }
596    }
597    cuts
598}
599
600/// The log-frequency bounds of a GD cut's centre at `f0`: ±10 %, and with
601/// correction bands also inside the band that holds `f0`. `None` when no band
602/// holds it or the bounds leave the centre no room (the solver needs
603/// `lb < ub`).
604fn cut_centre_bounds(f0: f64, bands: Option<&[(f64, f64)]>) -> Option<(f64, f64)> {
605    let (lb, ub) = ((f0 / 1.1).ln(), (f0 * 1.1).ln());
606    let Some(bands) = bands else {
607        return Some((lb, ub));
608    };
609    let &(lo, hi) = bands.iter().find(|&&(lo, hi)| f0 >= lo && f0 <= hi)?;
610    let (lb, ub) = (lb.max(lo.ln()), ub.min(hi.ln()));
611    (lb < ub).then_some((lb, ub))
612}
613
614/// Acoustic XO target before the electrical crossover; position order is
615/// preserved during power accumulation. No ringing cap is applied here.
616#[allow(clippy::too_many_arguments)]
617pub fn xo_target_peq(
618    by_pos: &[(f64, &[C])],
619    target: &TargetCurve,
620    level_db: f64,
621    lo: f64,
622    hi: f64,
623    bands: usize,
624    boost: f64,
625    f_max: Option<f64>,
626    fitter: &mut Fitter<'_>,
627) -> Vec<Peq> {
628    let f = design_freqs();
629    let mut power = vec![0.0; f.len()];
630    let mut weight = 0.0;
631    for &(w, h) in by_pos {
632        assert_eq!(h.len(), f.len());
633        weight += w;
634        for (p, &h) in power.iter_mut().zip(h) {
635            let m = response::magnitude(h);
636            *p += w * (m * m);
637        }
638    }
639    for p in &mut power {
640        *p /= weight;
641    }
642    let frac = secs_dsp::grid::frac_for(f);
643    let sm = smooth::smooth_power(f, &power, Fraction::PerBin(&frac)).expect("design grid");
644    let sm3 = smooth::smooth_power(f, &power, Fraction::Scalar(3.0)).expect("design grid");
645    let tgt = target.sample(&f.iter().map(|v| v.max(1.0)).collect::<Vec<_>>());
646    let corr: Vec<_> = sm
647        .iter()
648        .zip(sm3)
649        .zip(tgt)
650        .map(|((&s, s3), t)| {
651            (t + level_db - (10.0 * (s + 1e-30).log10()).max(10.0 * (s3 + 1e-30).log10() - 3.0))
652                .clamp(-25.0, boost)
653        })
654        .collect();
655    let g = geomspace(15.0, 2000.0, 500);
656    let c: Vec<_> = g
657        .iter()
658        .map(|&g| {
659            let soft = ((g / (lo / 1.4)).log2() / 0.5).clamp(0.0, 1.0)
660                * (((hi * 1.4) / g).log2() / 0.5).clamp(0.0, 1.0);
661            interp(g, f, &corr) * soft
662        })
663        .collect();
664    fitter(&FitRequest {
665        g: &g,
666        corr_db: &c,
667        n: bands,
668        lo,
669        hi,
670        boost,
671        ring_ms: None,
672        f_max,
673        bands: None,
674    })
675}
676
677#[cfg(any(test, feature = "test-support"))]
678#[doc(hidden)]
679#[derive(Debug, Clone)]
680pub struct RecordedFit {
681    pub g: Vec<f64>,
682    pub corr_db: Vec<f64>,
683    pub n: usize,
684    pub lo: f64,
685    pub hi: f64,
686    pub boost: f64,
687    pub ring_ms: Option<f64>,
688    pub f_max: Option<f64>,
689    pub result: Vec<Peq>,
690}
691#[cfg(any(test, feature = "test-support"))]
692impl RecordedFit {
693    pub fn request(&self) -> FitRequest<'_> {
694        FitRequest {
695            g: &self.g,
696            corr_db: &self.corr_db,
697            n: self.n,
698            lo: self.lo,
699            hi: self.hi,
700            boost: self.boost,
701            ring_ms: self.ring_ms,
702            f_max: self.f_max,
703            bands: None,
704        }
705    }
706}
707#[cfg(any(test, feature = "test-support"))]
708#[doc(hidden)]
709pub fn replay_fitter(
710    calls: Vec<RecordedFit>,
711    rtol: f64,
712) -> impl FnMut(&FitRequest<'_>) -> Vec<Peq> {
713    let mut next = 0;
714    move |req| {
715        let index = next;
716        let call = calls
717            .get(index)
718            .unwrap_or_else(|| panic!("fit call {index}: unexpected extra call"));
719        assert_eq!(req.n, call.n, "fit call {index}: n");
720        let mut worst = (0.0_f64, String::new());
721        for (name, a, b) in [
722            ("g", req.g, call.g.as_slice()),
723            ("corr_db", req.corr_db, call.corr_db.as_slice()),
724            (
725                "scalars",
726                &[req.lo, req.hi, req.boost],
727                &[call.lo, call.hi, call.boost],
728            ),
729        ] {
730            assert_eq!(a.len(), b.len(), "fit call {index}: {name} length");
731            for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
732                let err = (a - b).abs() / b.abs().max(1.0);
733                if !err.is_finite() || err > worst.0 {
734                    worst = (err, format!("{name}[{i}] got={a:e} expected={b:e}"));
735                }
736            }
737        }
738        for (name, a, b) in [
739            ("ring_ms", req.ring_ms, call.ring_ms),
740            ("f_max", req.f_max, call.f_max),
741        ] {
742            assert_eq!(
743                a.is_some(),
744                b.is_some(),
745                "fit call {index}: {name} presence"
746            );
747            if let (Some(a), Some(b)) = (a, b) {
748                let err = (a - b).abs() / b.abs().max(1.0);
749                if !err.is_finite() || err > worst.0 {
750                    worst = (err, format!("{name} got={a:e} expected={b:e}"));
751                }
752            }
753        }
754        assert!(
755            worst.0.is_finite() && worst.0 <= rtol,
756            "fit call {index}: worst mismatch {} relative={} tolerance={rtol}",
757            worst.1,
758            worst.0
759        );
760        next += 1;
761        call.result.clone()
762    }
763}
764
765#[cfg(test)]
766mod analytic_tests {
767    use super::*;
768
769    #[test]
770    fn a_nonfinite_response_bin_weighs_as_a_large_finite_residual() {
771        let g = [1_000.0, 24_000.0];
772        let corr = [0.0, 0.0];
773        let req = FitRequest {
774            g: &g,
775            corr_db: &corr,
776            n: 1,
777            lo: 20.0,
778            hi: 20_000.0,
779            boost: 6.0,
780            ring_ms: None,
781            f_max: None,
782            bands: None,
783        };
784        let h = vec![C::new(1.0, 0.0), C::new(f64::NAN, f64::NAN)];
785        assert_eq!(
786            weighted_residual(&req, h),
787            [20.0 * (1.0_f64 + 1e-12).log10(), 1e12]
788        );
789    }
790
791    // A band centred on Nyquist expands its RBJ response to 0/0 there. Which
792    // Nyquist multiple hits it depends on the platform's libm (24 kHz itself
793    // on the macOS self-test, where `f_max = 1.2 × 20 kHz` puts the bound on
794    // Nyquist), so the test searches and skips when none cancels exactly.
795    #[test]
796    fn fit_survives_a_band_whose_perturbed_response_is_not_finite() {
797        let Some(upper) = (1..=1_000_000)
798            .map(|multiple| 24_000.0 * f64::from(multiple))
799            .find(|&f| {
800                let h =
801                    response::peaking_response(&[24_000.0], f.ln().exp(), 15.0, -6.0, 48_000.0)[0];
802                !h.re.is_finite() || !h.im.is_finite()
803            })
804        else {
805            eprintln!("skipped: no Nyquist multiple cancels to 0/0 with this libm");
806            return;
807        };
808        let lower = upper * (1.0 - 1e-9);
809        let g = [24_000.0, lower];
810        let corr = [0.0, -6.0];
811        let bands = [(lower, upper)];
812        let req = FitRequest {
813            g: &g,
814            corr_db: &corr,
815            n: 1,
816            lo: lower,
817            hi: upper,
818            boost: 6.0,
819            ring_ms: None,
820            f_max: Some(upper),
821            bands: Some(&bands),
822        };
823        let seed = seed(&req);
824        assert!(
825            residual(&req, &seed.x0)
826                .iter()
827                .all(|value| value.is_finite())
828        );
829        let mut probe = seed.x0.clone();
830        probe[0] = seed.ub[0];
831        let raw = response::peq_response(req.g, &result_bands(&req, &probe));
832        assert!(raw.iter().any(|h| !h.re.is_finite() || !h.im.is_finite()));
833
834        for result in [
835            fit_peq(&req),
836            fit_peq_with(&req, &|| false, PeqDerivatives::Analytic),
837        ] {
838            assert_eq!(result.len(), 1);
839            assert!(
840                result
841                    .iter()
842                    .all(|&(f, q, gain)| f.is_finite() && q.is_finite() && gain.is_finite())
843            );
844        }
845    }
846
847    #[test]
848    fn analytic_peq_jacobian_differentiates_the_weighted_residual() {
849        let g = geomspace(15.0, 3000.0, 100);
850        let corr = vec![0.0; g.len()];
851        let req = FitRequest {
852            g: &g,
853            corr_db: &corr,
854            n: 3,
855            lo: 20.0,
856            hi: 250.0,
857            boost: 6.0,
858            ring_ms: None,
859            f_max: None,
860            bands: None,
861        };
862        let x = [
863            30.0_f64.ln(),
864            1.0_f64.ln(),
865            -10.0,
866            80.0_f64.ln(),
867            4.0_f64.ln(),
868            3.0,
869            200.0_f64.ln(),
870            2.0_f64.ln(),
871            -5.0,
872        ];
873        let mut cache = AnalyticResidualCache::new(&g, 3);
874        let jac = cache.jacobian(&req, &x);
875        for j in 0..x.len() {
876            let samples: Vec<_> = [-2.0, -1.0, 1.0, 2.0]
877                .iter()
878                .map(|multiple| {
879                    let mut probe = x;
880                    probe[j] += multiple * 1e-3;
881                    residual(&req, &probe)
882                })
883                .collect();
884            let error = (0..g.len())
885                .map(|i| {
886                    let central = (samples[0][i] - 8.0 * samples[1][i] + 8.0 * samples[2][i]
887                        - samples[3][i])
888                        / 0.012;
889                    (jac[(i, j)] - central).abs()
890                })
891                .fold(0.0_f64, f64::max);
892            let scale = (0..g.len())
893                .map(|i| jac[(i, j)].abs())
894                .fold(1.0_f64, f64::max);
895            assert!(error < 3e-6 * scale, "column {j}: {error}/{scale}");
896        }
897        let cached = cache.evaluate(&req, &x);
898        assert!(
899            cached
900                .iter()
901                .zip(residual(&req, &x))
902                .all(|(a, b)| a.to_bits() == b.to_bits())
903        );
904    }
905
906    #[test]
907    fn analytic_peq_jacobian_retains_the_additive_magnitude_floor() {
908        let g = [100.0];
909        let corr = [0.0];
910        let req = FitRequest {
911            g: &g,
912            corr_db: &corr,
913            n: 16,
914            lo: 20.0,
915            hi: 250.0,
916            boost: 6.0,
917            ring_ms: None,
918            f_max: None,
919            bands: None,
920        };
921        let x = [100.0_f64.ln(), 2.0_f64.ln(), -25.0].repeat(16);
922        let mut cache = AnalyticResidualCache::new(&g, 16);
923        let jac = cache.jacobian(&req, &x);
924        let magnitude = response::magnitude(cache.cascade[0]);
925        let expected = magnitude / (magnitude + 1e-12);
926        assert!(expected > 0.0 && expected < 1e-6);
927        assert!((jac[(0, 2)] / expected - 1.0).abs() < 1e-8);
928    }
929}