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