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