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