Skip to main content

secs_engine/
design.rs

1//! One immutable design, ported from `CorrectionEngine.compute` and
2//! `calibration.compute`. Spectra stay on the internal axis; exports use the
3//! shifted output delays in `SignalChain`.
4
5use std::sync::Arc;
6
7use secs_compute::{CancelToken, Context};
8use secs_dsp::{
9    interp, response,
10    smooth::{self, Fraction},
11};
12use secs_types::{
13    calibration::RcHiBasis,
14    chain::{BandType, MainChannel, SignalChain, SubChannel},
15    target::TargetCurve,
16};
17
18use crate::{
19    C, EngineConfig, EngineError, MeasurementSet, Tuning,
20    ba::{self, BaInput, LayoutInfo, ManualPlan},
21    chain::BaRow,
22    fit::{self, Fitter, PeqDerivatives},
23    grid::{N_FFT, SR, design_freqs},
24    levels,
25    measurement::{self, Spectra},
26    notes::Note,
27    rc::{self, RcInput},
28    scorecard::{self, Metrics},
29    setup::Setup,
30};
31
32#[derive(Debug)]
33pub enum FitStage<'a> {
34    Ba,
35    Top(&'a str),
36    Rc(&'a str),
37}
38pub type FitterFactory<'f> = dyn Fn(FitStage<'_>) -> Box<Fitter<'f>> + Sync + 'f;
39pub fn production_fitters(cancel: &CancelToken) -> Box<FitterFactory<'_>> {
40    production_fitters_with(cancel, PeqDerivatives::TwoPoint)
41}
42
43pub fn production_fitters_with(
44    cancel: &CancelToken,
45    derivatives: PeqDerivatives,
46) -> Box<FitterFactory<'_>> {
47    Box::new(move |_| {
48        Box::new(move |request| {
49            if cancel.is_cancelled() {
50                Vec::new()
51            } else {
52                fit::fit_peq_with(request, &|| cancel.is_cancelled(), derivatives)
53            }
54        })
55    })
56}
57
58#[derive(Debug)]
59pub struct DesignRequest<'a> {
60    pub measurements: &'a MeasurementSet,
61    pub config: &'a EngineConfig,
62    pub tuning: &'a Tuning,
63    pub subs_only: bool,
64    pub adapter_notes: &'a [String],
65}
66#[derive(Debug)]
67pub struct Design {
68    pub replay: Arc<crate::DesignReplay>,
69    pub chain: SignalChain,
70    pub mains: Vec<MainDesign>,
71    pub subs: Option<SubDesign>,
72    pub ref_level: f64,
73    pub rc_hi: f64,
74    /// Merged correction bands; empty when no extra bands were requested.
75    pub rc_bands: Vec<(f64, f64)>,
76    pub rc_auto: bool,
77    pub schroeder_hz: Option<f64>,
78    pub target_db: Arc<[f64]>,
79    pub target_display: Vec<f64>,
80    pub spectra: DesignSpectra,
81    pub notes: Vec<Note>,
82    pub adapter_notes: Vec<String>,
83    pub scores: Vec<RoleScores>,
84    /// The speaker groups the design was made for (`engine.groups`), in order.
85    pub groups: Vec<(String, Vec<String>)>,
86    /// The target curve the design was made for (`engine.target_db_fn`).
87    pub target: TargetCurve,
88    /// The crossover per group as configured (`engine.xo`).
89    pub xo: Vec<(String, f64)>,
90    #[cfg(test)]
91    pub timings: Vec<(String, std::time::Duration)>,
92}
93#[derive(Debug)]
94pub struct DesignSpectra {
95    pub raw: Arc<Spectra>,
96    pub used: Arc<Spectra>,
97    pub main_position: u32,
98    pub positions: Vec<u32>,
99}
100#[derive(Debug)]
101pub struct MainDesign {
102    pub role: String,
103    pub gain_db: f64,
104    pub xo_hz: Option<f64>,
105    pub align_delay_ms: f64,
106    pub top_response: Option<Arc<[C]>>,
107    pub response: Arc<[C]>,
108    pub phase_strength: f64,
109    pub phase_band: Option<f64>,
110    pub phase_frac_used: Option<f64>,
111    pub before: Arc<[C]>,
112    pub combined: Arc<[C]>,
113    pub after: Arc<[C]>,
114}
115#[derive(Debug)]
116pub struct SubDesign {
117    pub rows: Vec<(String, BaRow)>,
118    pub bus_gain: f64,
119    pub bus_delay_ms: f64,
120    pub bus_polarity: i8,
121    pub bus_response: Option<Arc<[C]>>,
122    pub sum_at_reference: Arc<[C]>,
123    pub sub_level_db: Option<f64>,
124    pub layout_info: Vec<(String, LayoutInfo)>,
125    pub plan: Option<ManualPlan>,
126}
127#[derive(Debug)]
128pub struct RoleScores {
129    pub role: String,
130    pub before: Metrics,
131    pub after: Metrics,
132    pub spl_before: Vec<f64>,
133    pub spl_after: Vec<f64>,
134    pub gd_before: Vec<f64>,
135    pub gd_after: Vec<f64>,
136}
137
138#[derive(Debug, Clone, PartialEq)]
139pub struct RoleLateEnergy {
140    pub role: String,
141    pub position: u32,
142    pub before_db: f64,
143    pub after_db: f64,
144}
145
146/// Late energy on the same per-position responses used by the scorecard at the
147/// main position and by the mdat's before/after items elsewhere: the 2 s export
148/// view, which MIMO also starts from. The fitting spectra (`spectra.used`) are
149/// not used; their late tail differs from those items by up to about 1 dB.
150/// Rows are position major, with main roles in chain order.
151pub fn late_energy(design: &Design) -> Result<Vec<RoleLateEnergy>, EngineError> {
152    let view = design.replay.export_view(design)?;
153    let mut rows = Vec::new();
154    for &position in &design.spectra.positions {
155        for channel in &design.chain.mains {
156            let (before_db, after_db) = if position == design.spectra.main_position
157                && design.chain.input_hp_hz.is_none()
158            {
159                let Some(main) = design.mains.iter().find(|main| main.role == channel.role) else {
160                    continue;
161                };
162                (
163                    crate::decay::late_db_20_150(&main.before),
164                    crate::decay::late_db_20_150(&main.after),
165                )
166            } else {
167                let Some(before) = view.spectra.get(position, &channel.role) else {
168                    continue;
169                };
170                let Some(after) =
171                    crate::stage_responses(design, &channel.role, &view.spectra, position)
172                        .map(|stages| stages.output)
173                else {
174                    continue;
175                };
176                (
177                    crate::decay::late_db_20_150(before),
178                    crate::decay::late_db_20_150(&after),
179                )
180            };
181            rows.push(RoleLateEnergy {
182                role: channel.role.clone(),
183                position,
184                before_db,
185                after_db,
186            });
187        }
188    }
189    Ok(rows)
190}
191
192fn check(ctx: &Context) -> Result<(), EngineError> {
193    ctx.cancel().check().map_err(|_| EngineError::Cancelled)
194}
195
196/// RC realizes each channel FIR on the `N_FFT`-point design grid and keeps
197/// its first `taps` samples, so more taps than that cannot be designed
198/// (TD-50); 0 selects the PEQ-only path. The modes use 0 and 2048–16384.
199pub(crate) fn check_taps(config: &EngineConfig) -> Result<(), EngineError> {
200    if config.taps > N_FFT {
201        return Err(EngineError::Input(format!(
202            "FIR taps {} outside 0..={N_FFT} (0 is PEQ only)",
203            config.taps
204        )));
205    }
206    Ok(())
207}
208
209/// The input cut's frequency stays inside the settings limits even when a
210/// caller builds the config without the settings edit (ADR 0031): 0 would
211/// make every export response non-finite.
212pub(crate) fn check_input_hp(config: &EngineConfig) -> Result<(), EngineError> {
213    let (lo, hi) = secs_types::rules::CALIBRATION_LIMITS.input_hp;
214    match config.input_hp_hz {
215        Some(hz) if !(hz.is_finite() && (lo..=hi).contains(&hz)) => Err(EngineError::Input(
216            format!("input high-pass {hz} Hz outside {lo}..={hi} Hz"),
217        )),
218        _ => Ok(()),
219    }
220}
221
222/// Full-grid arrays one `rc::channel` keeps alive at once, in spectra (16
223/// bytes per bin). `prepare_correction` holds `p`, `m`, `u`, `reference`, `mp`
224/// and the minimum-phase buffers (`logm`, `cep`, FFT output) beside the f64
225/// grids `power`, `total`, `frac`, `sm`, `sm3`, `target`, `corr`, `design_db`
226/// and the masks; `finish_correction` holds that preparation and its clones
227/// of `p`, `m`, `design_db` beside `phase`, `tau`, `excess`, `band`, the
228/// realized `spec`/`h`/`response`, the residual grids and, for a second phase
229/// candidate, the first `RcOutput`. About 350 bytes per bin, rounded up.
230const RC_SCRATCH_SPECTRA: u64 = 24;
231/// What `lf_align` and `mode_decay` (ADR 0025) keep beside that: the total
232/// phase vector, the cut response, β and `τ_T` in the preparation, and the
233/// smoothed total phase, its GD and the total-GD error in
234/// `finish_correction`. About 90 bytes per bin, rounded up.
235const RC_ADR0025_SPECTRA: u64 = 6;
236
237/// What one design reserves beyond the prepared spectra (TD-24). `retained`
238/// is alive from BA to the end of the design; `channel` is one RC channel (or
239/// one channel's scores, which need less) while it runs.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub(crate) struct DesignBytes {
242    pub retained: u64,
243    pub channel: u64,
244}
245
246impl DesignBytes {
247    /// Counted in spectra of `SPECTRUM_BYTES`, with P positions, S subs and M
248    /// mains (none for a subs-only design):
249    ///
250    /// - retained: `used` cells replaced by BA's `alignment.h_subs` (each sub
251    ///   cell once per layout) and the top PEQ (each main cell), BA's
252    ///   `sub_sum_by_pos` and layout `by_pos` sums (2·P), `ba::sums`' gathered
253    ///   cells, index and result at one position (S + 2), `top_responses` (M),
254    ///   and per finished channel `MainDesign.response`, `after` and
255    ///   `combined` (3·M). Without subs only the last term remains.
256    /// - replay: owned input/config/Setup metadata and preparation handles;
257    ///   IR samples and prepared spectrum buffers are shared, not copied.
258    /// - channel: `comb` (P), `rc::combine`'s `delay`, `hp`, `lp` and bus
259    ///   `delay` (4), and `rc::channel`'s scratch (`RC_SCRATCH_SPECTRA`).
260    pub(crate) fn new(
261        set: &MeasurementSet,
262        config: &EngineConfig,
263        setup: &Setup,
264        subs_only: bool,
265    ) -> Self {
266        let positions = set.positions.len() as u64;
267        let subs = config.subs.len() as u64;
268        let mains = if subs_only
269            && !(config.rc_range.1.is_none() && config.rc_hi_basis == RcHiBasis::Agreement)
270        {
271            0
272        } else {
273            config.mains.len() as u64
274        };
275        let ba = if config.subs.is_empty() {
276            0
277        } else {
278            measurement::cells(set, Some(&config.subs))
279                .saturating_mul(setup.layouts.len() as u64)
280                .saturating_add(measurement::cells(set, Some(&config.mains)))
281                .saturating_add(2 * positions)
282                .saturating_add(subs + 2)
283                .saturating_add(config.mains.len() as u64)
284        };
285        let spectra = |n: u64| n.saturating_mul(measurement::SPECTRUM_BYTES);
286        Self {
287            retained: spectra(ba.saturating_add(3 * mains))
288                .saturating_add(crate::DesignReplay::bytes(set, config, setup)),
289            channel: if mains == 0 {
290                0
291            } else {
292                let extra = if setup.lf_align || setup.mode_decay {
293                    RC_ADR0025_SPECTRA
294                } else {
295                    0
296                };
297                spectra(positions + 4 + RC_SCRATCH_SPECTRA + extra)
298            },
299        }
300    }
301}
302pub(crate) fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
303    let start = lo.log10();
304    let step = (hi.log10() - start) / (n - 1) as f64;
305    let mut out: Vec<_> = (0..n)
306        .map(|i| 10f64.powf(start + i as f64 * step))
307        .collect();
308    out[0] = lo;
309    out[n - 1] = hi;
310    out
311}
312pub(crate) fn spl_db(h: &[C], grid: &[f64]) -> Vec<f64> {
313    let p: Vec<_> = h
314        .iter()
315        .map(|v| {
316            let a = v.re.hypot(v.im);
317            a * a
318        })
319        .collect();
320    let db: Vec<_> = smooth::smooth_power(design_freqs(), &p, Fraction::Scalar(24.0))
321        .expect("design grid")
322        .iter()
323        .map(|p| 10.0 * (p + 1e-30).log10())
324        .collect();
325    grid.iter()
326        .map(|&g| interp(g, design_freqs(), &db))
327        .collect()
328}
329
330pub fn design(
331    req: &DesignRequest<'_>,
332    ctx: &Context,
333    fitters: &FitterFactory<'_>,
334) -> Result<Design, EngineError> {
335    design_observed(req, ctx, fitters, &|_, _| {})
336}
337
338/// Stage wall times for the release audit example; not part of the public API.
339pub(crate) fn design_observed(
340    req: &DesignRequest<'_>,
341    ctx: &Context,
342    fitters: &FitterFactory<'_>,
343    observed: &(dyn Fn(&str, std::time::Duration) + Sync),
344) -> Result<Design, EngineError> {
345    check(ctx)?;
346    check_taps(req.config)?;
347    check_input_hp(req.config)?;
348    let prep_started = std::time::Instant::now();
349    let config = req.config;
350    let setup = Setup::new(config);
351    // Reserve before `measurement::prepare` allocates (TD-24): its spectra
352    // and the design's retained arrays are held to the end; RC channels and
353    // their scores reserve `bytes.channel` each, in batches, out of the rest.
354    let bytes = DesignBytes::new(req.measurements, config, &setup, req.subs_only);
355    let held =
356        measurement::preparation_bytes(req.measurements, &setup).saturating_add(bytes.retained);
357    let schroeder = levels::Schroeder::analyze(req.measurements, config, ctx)?;
358    let _permit = crate::memory::reserve(ctx, held, bytes.channel)?;
359    let prepared = measurement::prepare_until(req.measurements, config, &setup, &|| {
360        ctx.cancel().is_cancelled()
361    })?;
362    observed("prepare", prep_started.elapsed());
363    design_prepared_scored(
364        req,
365        ctx,
366        fitters,
367        &prepared,
368        observed,
369        true,
370        ChannelBytes {
371            held,
372            channel: bytes.channel,
373        },
374        schroeder,
375    )
376}
377
378/// The caller must prepare these measurements with this config's non-XO fields.
379/// Recommendation varies only XO: preparation reads the reference position,
380/// main/sub membership and fixed sub filters, never XO or BA-derived filters.
381/// Candidates borrow the spectra; all candidate-dependent work stays below.
382/// Cloning Spectra at the output boundary copies Arc handles, not sample buffers.
383pub(crate) fn design_prepared(
384    req: &DesignRequest<'_>,
385    ctx: &Context,
386    fitters: &FitterFactory<'_>,
387    prepared: &measurement::Prepared,
388    observed: &(dyn Fn(&str, std::time::Duration) + Sync),
389) -> Result<Design, EngineError> {
390    let bytes = DesignBytes::new(
391        req.measurements,
392        req.config,
393        &Setup::new(req.config),
394        req.subs_only,
395    );
396    let schroeder = levels::Schroeder::analyze(req.measurements, req.config, ctx)?;
397    let _permit = crate::memory::reserve(ctx, bytes.retained, bytes.channel)?;
398    design_prepared_scored(
399        req,
400        ctx,
401        fitters,
402        prepared,
403        observed,
404        true,
405        ChannelBytes {
406            held: bytes.retained,
407            channel: bytes.channel,
408        },
409        schroeder,
410    )
411}
412
413/// Candidate-only design: the recommendation consumes the chain and spectra,
414/// not the final report plots. A normal design always includes those reports.
415/// The caller reserves the whole design (`DesignBytes` with one channel) for
416/// this serial context, so the channel maps below reserve nothing more.
417pub(crate) fn design_prepared_for_xo(
418    req: &DesignRequest<'_>,
419    ctx: &Context,
420    fitters: &FitterFactory<'_>,
421    prepared: &measurement::Prepared,
422    schroeder: levels::Schroeder,
423) -> Result<Design, EngineError> {
424    design_prepared_scored(
425        req,
426        ctx,
427        fitters,
428        prepared,
429        &|_, _| {},
430        false,
431        ChannelBytes::RESERVED,
432        schroeder,
433    )
434}
435
436/// How the per-channel maps reserve: `held` bytes are already reserved by
437/// this job on the calling thread, and each channel needs `channel` more.
438#[derive(Debug, Clone, Copy)]
439struct ChannelBytes {
440    held: u64,
441    channel: u64,
442}
443
444impl ChannelBytes {
445    /// The caller reserved every channel already.
446    const RESERVED: Self = Self {
447        held: 0,
448        channel: 0,
449    };
450}
451
452#[allow(clippy::too_many_arguments)]
453fn design_prepared_scored(
454    req: &DesignRequest<'_>,
455    ctx: &Context,
456    fitters: &FitterFactory<'_>,
457    prepared: &measurement::Prepared,
458    observed: &(dyn Fn(&str, std::time::Duration) + Sync),
459    include_scores: bool,
460    reserve: ChannelBytes,
461    schroeder: levels::Schroeder,
462) -> Result<Design, EngineError> {
463    if req.config.rc_range.1.is_none() && req.config.rc_hi_basis == RcHiBasis::Agreement {
464        let count = agreement_positions(req.measurements, req.config);
465        if count < 3 {
466            return Err(EngineError::Input(format!(
467                "RC agreement requires at least 3 measured positions; found {count}"
468            )));
469        }
470        let (probe, limits) = design_pass(
471            req,
472            ctx,
473            fitters,
474            prepared,
475            observed,
476            false,
477            reserve,
478            schroeder,
479            RcPass::Probe,
480        )?;
481        let limit = levels::median(limits).clamp(100.0, 2000.0);
482        drop(probe);
483        check(ctx)?;
484        return design_pass(
485            req,
486            ctx,
487            fitters,
488            prepared,
489            observed,
490            include_scores,
491            reserve,
492            schroeder,
493            RcPass::Final(limit),
494        )
495        .map(|(design, _)| design);
496    }
497    design_pass(
498        req,
499        ctx,
500        fitters,
501        prepared,
502        observed,
503        include_scores,
504        reserve,
505        schroeder,
506        RcPass::Normal,
507    )
508    .map(|(design, _)| design)
509}
510
511pub(crate) fn agreement_positions(set: &MeasurementSet, config: &EngineConfig) -> usize {
512    set.positions
513        .iter()
514        .filter(|(_, roles)| {
515            roles
516                .iter()
517                .any(|(role, m)| config.mains.contains(role) && !m.samples.is_empty())
518        })
519        .count()
520}
521
522#[derive(Clone, Copy)]
523enum RcPass {
524    Normal,
525    Probe,
526    Final(f64),
527}
528
529#[allow(clippy::too_many_arguments)]
530fn design_pass(
531    req: &DesignRequest<'_>,
532    ctx: &Context,
533    fitters: &FitterFactory<'_>,
534    prepared: &measurement::Prepared,
535    observed: &(dyn Fn(&str, std::time::Duration) + Sync),
536    include_scores: bool,
537    reserve: ChannelBytes,
538    schroeder: levels::Schroeder,
539    pass: RcPass,
540) -> Result<(Design, Vec<f64>), EngineError> {
541    check(ctx)?;
542    check_taps(req.config)?;
543    check_input_hp(req.config)?;
544    let config = req.config;
545    let mut setup = Setup::new(config);
546    if matches!(pass, RcPass::Probe) {
547        setup.rc_bands.clear();
548        setup.lf_align = false;
549        setup.mode_decay = false;
550        setup.hf_phase = false;
551    }
552    let positions = prepared.h.positions().to_vec();
553    let progress = ctx.stage("engine.design", 1 + config.mains.len() as u64);
554    let mut notes = Vec::new();
555    let run_final_checks = !req.subs_only && !matches!(pass, RcPass::Probe);
556    if run_final_checks {
557        notes.extend(crate::polarity::check(
558            req.measurements,
559            &config.mains,
560            config.main_position,
561        ));
562    }
563    if let Some(from_ms) = setup.lift_capped_from_ms {
564        notes.push(Note::LiftCapped {
565            from_ms,
566            taps: config.taps,
567            lift_ms: setup.lift_ms,
568        });
569    }
570    if !setup.sub_filters.is_empty() {
571        notes.push(Note::SubFilters(setup.sub_filters.clone()));
572    }
573    let rc_auto = setup.rc_hi_manual.is_none();
574    let schroeder_hz = schroeder.strict;
575    let lenient = schroeder.lenient;
576    if rc_auto {
577        if !matches!(config.rc_hi_transition, 1 | 2 | 4) {
578            return Err(EngineError::Input(
579                "RC transition factor must be 1, 2 or 4".into(),
580            ));
581        }
582        setup.rc_hi = match pass {
583            RcPass::Probe => 2000.0,
584            RcPass::Final(hz) => hz,
585            RcPass::Normal => {
586                levels::schroeder_limit(schroeder_hz, lenient.map(|v| v.0), config.rc_hi_transition)
587            }
588        };
589    }
590    notes.push(Note::RcLimit {
591        hz: setup.rc_hi,
592        auto: rc_auto,
593        schroeder_hz,
594        basis: config.rc_hi_basis,
595        factor: config.rc_hi_transition,
596        band_hz: lenient.map(|v| v.0),
597        band_t60_s: lenient.map(|v| v.1),
598        positions: (rc_auto && config.rc_hi_basis == RcHiBasis::Agreement)
599            .then(|| agreement_positions(req.measurements, config)),
600    });
601    let rc_bands = if setup.rc_bands.is_empty() {
602        Vec::new()
603    } else {
604        let bands = rc::band_set(setup.rc_lo, setup.rc_hi, &setup.rc_bands);
605        notes.push(Note::RcBands {
606            bands: bands.clone(),
607        });
608        bands
609    };
610    let xo_of: Vec<_> = config
611        .groups
612        .iter()
613        .filter(|(g, _)| g != "sub")
614        .flat_map(|(g, roles)| {
615            roles.iter().filter_map(move |r| {
616                config
617                    .xo
618                    .iter()
619                    .find(|(name, x)| name == g && *x != 0.0 && !config.subs.is_empty())
620                    .map(|(_, x)| (r.clone(), *x))
621            })
622        })
623        .collect();
624    let weights = levels::weights(&positions, config.main_position);
625    let levels: Vec<_> = config
626        .mains
627        .iter()
628        .filter(|r| prepared.h.get(config.main_position, r).is_some())
629        .map(|r| {
630            (
631                r.clone(),
632                levels::band_level(&prepared.h, r, &weights, &config.target, 300.0, 3000.0),
633            )
634        })
635        .collect();
636    let ref_level = levels
637        .iter()
638        .map(|(_, v)| *v)
639        .reduce(f64::min)
640        .ok_or_else(|| EngineError::Input("no measured main".into()))?;
641    let input = BaInput {
642        measurements: req.measurements,
643        prepared,
644        setup: &setup,
645        config,
646        tuning: req.tuning,
647        xo_of: &xo_of,
648        ref_level,
649        cancel: ctx.cancel(),
650    };
651    let ba_started = std::time::Instant::now();
652    let mut ba_fitter = fitters(FitStage::Ba);
653    let alignment = ba::align(&input, &mut *ba_fitter)?;
654    check(ctx)?;
655    #[cfg(test)]
656    let mut timings = vec![("BA".into(), ba_started.elapsed())];
657    notes.extend(alignment.notes.clone());
658    progress.advance(1);
659    let ba_elapsed = ba_started.elapsed();
660    let mut used = prepared.h.clone();
661    for (p, r, h) in &alignment.h_subs {
662        used.set(*p, r, h.clone());
663    }
664    let top_started = std::time::Instant::now();
665    let mut top_peq = Vec::new();
666    let mut top_responses = Vec::new();
667    if !config.subs.is_empty() && req.tuning.xo_stage {
668        for (role, xo) in &xo_of {
669            check(ctx)?;
670            let lo = if config.xo_peq_lo_hz == 0.0 {
671                xo / 2.0
672            } else {
673                config.xo_peq_lo_hz
674            };
675            let hi = if config.xo_peq_hi_hz == 0.0 {
676                xo * 4.0
677            } else {
678                config.xo_peq_hi_hz
679            };
680            if !config.xo_peq_on || hi <= lo {
681                continue;
682            }
683            let level = levels
684                .iter()
685                .find(|(r, _)| r == role)
686                .ok_or_else(|| EngineError::Input(format!("XO role {role} missing at reference")))?
687                .1;
688            let gain = C::new(10f64.powf((ref_level - level) / 20.0), 0.0);
689            let by_pos: Vec<_> = positions
690                .iter()
691                .zip(&weights)
692                .filter_map(|(&p, &w)| {
693                    used.get(p, role)
694                        .map(|h| (w, h.iter().map(|v| v * gain).collect::<Vec<_>>()))
695                })
696                .collect();
697            let weighted: Vec<_> = by_pos.iter().map(|(w, h)| (*w, h.as_slice())).collect();
698            let peq = fit::xo_target_peq(
699                &weighted,
700                &config.target,
701                ref_level,
702                lo,
703                hi,
704                6,
705                config.max_boost_db.min(6.0),
706                Some(hi),
707                &mut *fitters(FitStage::Top(role)),
708            );
709            check(ctx)?;
710            let response: Arc<[C]> = response::peq_response(design_freqs(), &peq).into();
711            for &p in &positions {
712                if let Some(h) = used.get(p, role) {
713                    used.set(
714                        p,
715                        role,
716                        h.iter().zip(response.iter()).map(|(h, p)| h * p).collect(),
717                    );
718                }
719            }
720            top_responses.push((role.clone(), response));
721            top_peq.push((role.clone(), peq));
722        }
723    }
724    #[cfg(test)]
725    timings.push(("top PEQ".into(), top_started.elapsed()));
726    observed("top", top_started.elapsed());
727    let bus_started = std::time::Instant::now();
728    let (bus_delay_ms, bus_polarity) = if config.subs.is_empty() {
729        (0.0, 1)
730    } else {
731        let earliest = alignment
732            .rows
733            .iter()
734            .map(|r| r.delay_ms)
735            .fold(f64::INFINITY, f64::min);
736        let lo = (-(setup.max_latency_ms - setup.lift_ms) - earliest) * 4.0;
737        let mains: Vec<_> = config
738            .mains
739            .iter()
740            .map(|r| {
741                (
742                    r.clone(),
743                    positions
744                        .iter()
745                        .filter(|p| alignment.sub_sum_by_pos.iter().any(|(q, _)| q == *p))
746                        .filter_map(|&p| used.get(p, r).map(|h| (p, h.clone())))
747                        .collect(),
748                )
749            })
750            .collect();
751        ba::bus_delay(
752            &alignment,
753            &mains,
754            &prepared.align_delay_ms,
755            &xo_of,
756            lo.ceil() / 4.0,
757            &|| ctx.cancel().is_cancelled(),
758        )?
759    };
760    check(ctx)?;
761    let rows = ba::fold(
762        &alignment.rows,
763        alignment.bus_gain,
764        bus_delay_ms,
765        bus_polarity,
766    );
767    observed("ba", ba_elapsed + bus_started.elapsed());
768    let shift = if alignment.plan.as_ref().is_some_and(|p| !p.fixed.is_empty()) {
769        config.sub_main_delay_ms
770    } else {
771        (-rows
772            .iter()
773            .map(|r| r.delay_ms)
774            .reduce(f64::min)
775            .unwrap_or(0.0))
776        .max(0.0)
777    };
778    let roles = if req.subs_only && !matches!(pass, RcPass::Probe) {
779        vec![]
780    } else {
781        config.mains.iter().collect()
782    };
783    let rc_all_started = std::time::Instant::now();
784    // Batches of channels reserve on this thread before entering the pool,
785    // never inside a pool task (TD-23); outputs keep the channel order.
786    let outputs = crate::memory::try_map(
787        ctx,
788        roles,
789        reserve.held,
790        |_| reserve.channel,
791        |role| {
792            check(ctx)?;
793            let Some(before) = prepared.h0.get(config.main_position, role) else {
794                progress.advance(1);
795                return Ok(None);
796            };
797            let rc_started = std::time::Instant::now();
798            let gain_db = ref_level - levels.iter().find(|(r, _)| r == role).unwrap().1;
799            let xo_hz = xo_of.iter().find(|(r, _)| r == role).map(|(_, x)| *x);
800            let align_delay_ms = prepared
801                .align_delay_ms
802                .iter()
803                .find(|(r, _)| r == role)
804                .unwrap()
805                .1;
806            let comb: Vec<(u32, Arc<[C]>)> = positions
807                .iter()
808                .filter_map(|&p| {
809                    used.get(p, role).map(|h| {
810                        let sub = alignment
811                            .sub_sum_by_pos
812                            .iter()
813                            .find(|(q, _)| *q == p)
814                            .map(|(_, h)| h.as_ref());
815                        (
816                            p,
817                            rc::combine(
818                                h,
819                                align_delay_ms,
820                                gain_db,
821                                xo_hz,
822                                sub,
823                                bus_polarity,
824                                bus_delay_ms,
825                            )
826                            .into(),
827                        )
828                    })
829                })
830                .collect();
831            let mut fitter = fitters(FitStage::Rc(role));
832            let rc_input = RcInput {
833                role,
834                comb: &comb,
835                main_position: config.main_position,
836                level_db: ref_level,
837                setup: &setup,
838                config,
839                tuning: req.tuning,
840                positions: &positions,
841                bus_rolloff_hz: if config.low_protect && xo_hz.is_some_and(|hz| hz != 0.0) {
842                    alignment.bus_rolloff_hz
843                } else {
844                    None
845                },
846            };
847            let result = rc::channel(&rc_input, &mut *fitter, &|| ctx.cancel().is_cancelled())?;
848            check(ctx)?;
849            let agreement = matches!(pass, RcPass::Probe)
850                .then(|| rc::agreement_limit(&rc_input, &result.response));
851            let combined = comb
852                .iter()
853                .find(|(p, _)| *p == config.main_position)
854                .unwrap()
855                .1
856                .clone();
857            let response: Arc<[C]> = result.response.into();
858            let after = combined
859                .iter()
860                .zip(response.iter())
861                .map(|(h, f)| h * f)
862                .collect();
863            let mut peq = result.out.peq;
864            peq.extend(result.gd_cuts);
865            let channel = MainChannel {
866                role: role.clone(),
867                input_peq: peq,
868                fir: result.out.fir,
869                gain_db,
870                polarity: config.inverted_mains.contains(role).then_some(-1),
871                delay_ms: align_delay_ms + shift,
872                xo_hz,
873                align_delay_ms,
874                top_peq: top_peq
875                    .iter()
876                    .find(|(r, _)| r == role)
877                    .map_or_else(Vec::new, |(_, p)| p.clone()),
878            };
879            let main = MainDesign {
880                role: role.clone(),
881                gain_db,
882                xo_hz,
883                align_delay_ms,
884                top_response: top_responses
885                    .iter()
886                    .find(|(r, _)| r == role)
887                    .map(|(_, p)| p.clone()),
888                response,
889                phase_strength: result.out.phase_strength,
890                phase_band: result.out.phase_band,
891                phase_frac_used: result.phase_frac_used,
892                before: before.clone(),
893                combined,
894                after,
895            };
896            observed(&format!("rc:{role}"), rc_started.elapsed());
897            progress.advance(1);
898            #[cfg(test)]
899            let elapsed = rc_started.elapsed();
900            #[cfg(not(test))]
901            let elapsed = ();
902            Ok(Some((channel, main, result.out.notes, elapsed, agreement)))
903        },
904    )?;
905    observed("rc_all", rc_all_started.elapsed());
906    let mut channels = Vec::new();
907    let mut mains = Vec::new();
908    let mut agreement_limits = Vec::new();
909    for (channel, main, channel_notes, _elapsed, agreement) in outputs.into_iter().flatten() {
910        if let Some(limit) = agreement {
911            agreement_limits.push(limit);
912        }
913        #[cfg(test)]
914        timings.push((format!("RC {}", main.role), _elapsed));
915        channels.push(channel);
916        mains.push(main);
917        notes.extend(channel_notes);
918    }
919    let latency_ms = setup.lift_ms + shift;
920    notes.push(Note::Latency {
921        latency_ms,
922        lift_ms: setup.lift_ms,
923        main_extra_ms: shift,
924    });
925    let mut subs = Vec::new();
926    for (role, row) in config.subs.iter().zip(&rows) {
927        let mut sub = SubChannel {
928            role: role.clone(),
929            gain_db: row.gain_db,
930            delay_ms: row.delay_ms + shift,
931            polarity: row.polarity,
932            allpass: row.allpass.clone(),
933            hp_hz: 0.0,
934            hp_type: BandType::Lr24,
935            lp_hz: 0.0,
936            lp_type: BandType::Lr24,
937            peq: vec![],
938            mode: ba::sub_mode_label(role, &setup, &alignment.layout_info),
939        };
940        if alignment
941            .plan
942            .as_ref()
943            .is_some_and(|p| p.fixed.contains(role))
944        {
945            let m = &setup.sub_manual.iter().find(|(r, _)| r == role).unwrap().1;
946            sub.gain_db = m.gain_db;
947            sub.delay_ms = m.delay_ms;
948            sub.polarity = if m.polarity < 0 { -1 } else { 1 };
949            sub.allpass = m.allpass.iter().copied().take(2).collect();
950            sub.mode = "fixed".into();
951        }
952        if let Some((_, f)) = alignment.sub_filters.iter().find(|(r, _)| r == role) {
953            sub.hp_hz = f.hp_hz;
954            sub.hp_type = f.hp_type;
955            sub.lp_hz = f.lp_hz;
956            sub.lp_type = f.lp_type;
957            sub.peq = f.peq.clone();
958        }
959        if config.low_protect {
960            let by_pos: Vec<_> = positions
961                .iter()
962                .filter_map(|&p| used.get(p, role).map(|h| (p, h.clone())))
963                .collect();
964            if let Some(hz) = crate::low_protect::rolloff(
965                &by_pos,
966                config.main_position,
967                Some((sub.hp_hz, sub.lp_hz)),
968            ) {
969                if sub.hp_hz == 0.0 {
970                    sub.hp_hz = hz / 2.0_f64.sqrt();
971                    sub.hp_type = BandType::Lr24;
972                }
973                notes.push(Note::LowProtect {
974                    role: role.clone(),
975                    rolloff_hz: hz,
976                    hp_hz: Some(sub.hp_hz),
977                    limited: true,
978                    reduced: 0,
979                    cuts: 0,
980                });
981            }
982        }
983        subs.push(sub);
984    }
985    let sub = if config.subs.is_empty() {
986        None
987    } else {
988        Some(SubDesign {
989            rows: config.subs.iter().cloned().zip(rows).collect(),
990            bus_gain: alignment.bus_gain,
991            bus_delay_ms,
992            bus_polarity,
993            bus_response: (!alignment.bus_peq.is_empty())
994                .then(|| response::peq_response(design_freqs(), &alignment.bus_peq).into()),
995            sum_at_reference: alignment
996                .sub_sum_by_pos
997                .iter()
998                .find(|(p, _)| *p == config.main_position)
999                .unwrap()
1000                .1
1001                .clone(),
1002            sub_level_db: alignment.sub_level_db,
1003            layout_info: alignment.layout_info,
1004            plan: alignment.plan,
1005        })
1006    };
1007    let f = design_freqs();
1008    let target = config
1009        .target
1010        .sample(&f.iter().map(|f| f.max(1.0)).collect::<Vec<_>>());
1011    let target_db: Arc<[f64]> = target.iter().map(|v| v + ref_level).collect();
1012    let spl_grid = if include_scores {
1013        geomspace(5.0, 20000.0, 1390)
1014    } else {
1015        Vec::new()
1016    };
1017    let gd_grid = if include_scores {
1018        geomspace(5.0, 5000.0, 1070)
1019    } else {
1020        Vec::new()
1021    };
1022    let scores_started = std::time::Instant::now();
1023    let scores = if include_scores {
1024        // A channel's scores need less than its RC working set.
1025        crate::memory::try_map(
1026            ctx,
1027            mains.iter().collect(),
1028            reserve.held,
1029            |_| reserve.channel,
1030            |main| {
1031                check(ctx)?;
1032                Ok(RoleScores {
1033                    role: main.role.clone(),
1034                    before: scorecard::metrics(&main.before, f, &target, (100.0, 400.0)),
1035                    after: scorecard::metrics(&main.after, f, &target, (100.0, 400.0)),
1036                    spl_before: spl_db(&main.before, &spl_grid),
1037                    spl_after: spl_db(&main.after, &spl_grid),
1038                    gd_before: response::gd_curve(
1039                        &main.before,
1040                        f,
1041                        &gd_grid,
1042                        Fraction::Scalar(24.0),
1043                    )?,
1044                    gd_after: response::gd_curve(&main.after, f, &gd_grid, Fraction::Scalar(24.0))?,
1045                })
1046            },
1047        )?
1048    } else {
1049        Vec::new()
1050    };
1051    observed("scores", scores_started.elapsed());
1052    #[cfg(test)]
1053    timings.push(("scores".into(), scores_started.elapsed()));
1054    notes.push(Note::PhaseStrength(
1055        mains
1056            .iter()
1057            .map(|m| (m.role.clone(), m.phase_strength))
1058            .collect(),
1059    ));
1060    notes.push(Note::PhaseBand(
1061        mains
1062            .iter()
1063            .map(|m| (m.role.clone(), m.phase_band))
1064            .collect(),
1065    ));
1066    if let Some(hz) = setup.input_hp_hz {
1067        notes.push(Note::InputHp { hz });
1068    }
1069    check(ctx)?;
1070    let mut design = Design {
1071        chain: SignalChain {
1072            input_hp_hz: setup.input_hp_hz,
1073            sample_rate: SR,
1074            mains: channels,
1075            subs,
1076            sub_bus_peq: alignment.bus_peq,
1077            main_extra_ms: shift,
1078            lift_ms: setup.lift_ms,
1079            latency_ms,
1080        },
1081        mains,
1082        subs: sub,
1083        ref_level,
1084        rc_hi: setup.rc_hi,
1085        rc_bands,
1086        rc_auto,
1087        schroeder_hz,
1088        target_display: spl_grid.iter().map(|&g| interp(g, f, &target_db)).collect(),
1089        target_db,
1090        spectra: DesignSpectra {
1091            raw: Arc::new(prepared.h0.clone()),
1092            used: Arc::new(used),
1093            main_position: config.main_position,
1094            positions,
1095        },
1096        notes,
1097        adapter_notes: req.adapter_notes.to_vec(),
1098        groups: config.groups.clone(),
1099        target: config.target.clone(),
1100        xo: config.xo.clone(),
1101        scores,
1102        replay: Arc::new(crate::DesignReplay::new(
1103            req.measurements,
1104            config,
1105            setup,
1106            prepared,
1107        )),
1108        #[cfg(test)]
1109        timings,
1110    };
1111    if run_final_checks {
1112        let pair_started = std::time::Instant::now();
1113        // Only the inspection range's bins are kept, a few hundred per main,
1114        // so the check stays far below the reserved design budget (TD-24).
1115        let (lo, hi) = crate::pair_phase::BAND_HZ;
1116        let kept: Vec<usize> = f
1117            .iter()
1118            .enumerate()
1119            .filter(|&(_, &hz)| hz >= lo && hz <= hi)
1120            .map(|(i, _)| i)
1121            .collect();
1122        let band_f: Vec<f64> = kept.iter().map(|&i| f[i]).collect();
1123        let mut responses = Vec::with_capacity(design.mains.len());
1124        for main in &design.mains {
1125            check(ctx)?;
1126            let Some(stages) =
1127                crate::stage_responses(&design, &main.role, &prepared.h, config.main_position)
1128            else {
1129                continue;
1130            };
1131            let response: Vec<C> = kept
1132                .iter()
1133                .map(|&i| stages.top_xo[i] * main.response[i])
1134                .collect();
1135            responses.push((main.role.clone(), response));
1136        }
1137        design
1138            .notes
1139            .extend(crate::pair_phase::check(&band_f, &responses));
1140        check(ctx)?;
1141        observed("pair_phase", pair_started.elapsed());
1142    }
1143    Ok((design, agreement_limits))
1144}