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    chain::{BandType, MainChannel, SignalChain, SubChannel},
14    target::TargetCurve,
15};
16
17use crate::{
18    C, EngineConfig, EngineError, MeasurementSet, Tuning,
19    ba::{self, BaInput, LayoutInfo, ManualPlan},
20    chain::BaRow,
21    fit::{self, Fitter, PeqDerivatives},
22    grid::{N_FFT, SR, design_freqs},
23    levels,
24    measurement::{self, Spectra},
25    notes::Note,
26    rc::{self, RcInput},
27    scorecard::{self, Metrics},
28    setup::Setup,
29};
30
31#[derive(Debug)]
32pub enum FitStage<'a> {
33    Ba,
34    Top(&'a str),
35    Rc(&'a str),
36}
37pub type FitterFactory<'f> = dyn Fn(FitStage<'_>) -> Box<Fitter<'f>> + Sync + 'f;
38pub fn production_fitters(cancel: &CancelToken) -> Box<FitterFactory<'_>> {
39    production_fitters_with(cancel, PeqDerivatives::TwoPoint)
40}
41
42pub fn production_fitters_with(
43    cancel: &CancelToken,
44    derivatives: PeqDerivatives,
45) -> Box<FitterFactory<'_>> {
46    Box::new(move |_| {
47        Box::new(move |request| {
48            if cancel.is_cancelled() {
49                Vec::new()
50            } else {
51                fit::fit_peq_with(request, &|| cancel.is_cancelled(), derivatives)
52            }
53        })
54    })
55}
56
57/// `correction_engine.py:34`, automatic RC ceiling.
58const RC_AUTO_MAX_HZ: f64 = 500.0;
59
60#[derive(Debug)]
61pub struct DesignRequest<'a> {
62    pub measurements: &'a MeasurementSet,
63    pub config: &'a EngineConfig,
64    pub tuning: &'a Tuning,
65    pub subs_only: bool,
66    pub adapter_notes: &'a [String],
67}
68#[derive(Debug)]
69pub struct Design {
70    pub replay: Arc<crate::DesignReplay>,
71    pub chain: SignalChain,
72    pub mains: Vec<MainDesign>,
73    pub subs: Option<SubDesign>,
74    pub ref_level: f64,
75    pub rc_hi: 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            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    check(ctx)?;
428    check_taps(req.config)?;
429    let config = req.config;
430    let mut setup = Setup::new(config);
431    let positions = prepared.h.positions().to_vec();
432    let progress = ctx.stage("engine.design", 1 + config.mains.len() as u64);
433    let mut notes = Vec::new();
434    if let Some(from_ms) = setup.lift_capped_from_ms {
435        notes.push(Note::LiftCapped {
436            from_ms,
437            taps: config.taps,
438            lift_ms: setup.lift_ms,
439        });
440    }
441    if !setup.sub_filters.is_empty() {
442        notes.push(Note::SubFilters(setup.sub_filters.clone()));
443    }
444    let rc_auto = setup.rc_hi_manual.is_none();
445    let schroeder_hz = rc_auto
446        .then(|| levels::schroeder_hz(req.measurements, config))
447        .flatten();
448    if rc_auto {
449        setup.rc_hi = schroeder_hz.map_or(RC_AUTO_MAX_HZ, |f| f.clamp(100.0, RC_AUTO_MAX_HZ));
450    }
451    notes.push(Note::RcLimit {
452        hz: setup.rc_hi,
453        auto: rc_auto,
454        schroeder_hz,
455    });
456    let xo_of: Vec<_> = config
457        .groups
458        .iter()
459        .filter(|(g, _)| g != "sub")
460        .flat_map(|(g, roles)| {
461            roles.iter().filter_map(move |r| {
462                config
463                    .xo
464                    .iter()
465                    .find(|(name, x)| name == g && *x != 0.0 && !config.subs.is_empty())
466                    .map(|(_, x)| (r.clone(), *x))
467            })
468        })
469        .collect();
470    let weights = levels::weights(&positions, config.main_position);
471    let levels: Vec<_> = config
472        .mains
473        .iter()
474        .filter(|r| prepared.h.get(config.main_position, r).is_some())
475        .map(|r| {
476            (
477                r.clone(),
478                levels::band_level(&prepared.h, r, &weights, &config.target, 300.0, 3000.0),
479            )
480        })
481        .collect();
482    let ref_level = levels
483        .iter()
484        .map(|(_, v)| *v)
485        .reduce(f64::min)
486        .ok_or_else(|| EngineError::Input("no measured main".into()))?;
487    let input = BaInput {
488        measurements: req.measurements,
489        prepared,
490        setup: &setup,
491        config,
492        tuning: req.tuning,
493        xo_of: &xo_of,
494        ref_level,
495        cancel: ctx.cancel(),
496    };
497    let ba_started = std::time::Instant::now();
498    let mut ba_fitter = fitters(FitStage::Ba);
499    let alignment = ba::align(&input, &mut *ba_fitter)?;
500    check(ctx)?;
501    #[cfg(test)]
502    let mut timings = vec![("BA".into(), ba_started.elapsed())];
503    notes.extend(alignment.notes.clone());
504    progress.advance(1);
505    let ba_elapsed = ba_started.elapsed();
506    let mut used = prepared.h.clone();
507    for (p, r, h) in &alignment.h_subs {
508        used.set(*p, r, h.clone());
509    }
510    let top_started = std::time::Instant::now();
511    let mut top_peq = Vec::new();
512    let mut top_responses = Vec::new();
513    if !config.subs.is_empty() && req.tuning.xo_stage {
514        for (role, xo) in &xo_of {
515            check(ctx)?;
516            let lo = if config.xo_peq_lo_hz == 0.0 {
517                xo / 2.0
518            } else {
519                config.xo_peq_lo_hz
520            };
521            let hi = if config.xo_peq_hi_hz == 0.0 {
522                xo * 4.0
523            } else {
524                config.xo_peq_hi_hz
525            };
526            if !config.xo_peq_on || hi <= lo {
527                continue;
528            }
529            let level = levels
530                .iter()
531                .find(|(r, _)| r == role)
532                .ok_or_else(|| EngineError::Input(format!("XO role {role} missing at reference")))?
533                .1;
534            let gain = C::new(10f64.powf((ref_level - level) / 20.0), 0.0);
535            let by_pos: Vec<_> = positions
536                .iter()
537                .zip(&weights)
538                .filter_map(|(&p, &w)| {
539                    used.get(p, role)
540                        .map(|h| (w, h.iter().map(|v| v * gain).collect::<Vec<_>>()))
541                })
542                .collect();
543            let weighted: Vec<_> = by_pos.iter().map(|(w, h)| (*w, h.as_slice())).collect();
544            let peq = fit::xo_target_peq(
545                &weighted,
546                &config.target,
547                ref_level,
548                lo,
549                hi,
550                6,
551                config.max_boost_db.min(6.0),
552                Some(hi),
553                &mut *fitters(FitStage::Top(role)),
554            );
555            check(ctx)?;
556            let response: Arc<[C]> = response::peq_response(design_freqs(), &peq).into();
557            for &p in &positions {
558                if let Some(h) = used.get(p, role) {
559                    used.set(
560                        p,
561                        role,
562                        h.iter().zip(response.iter()).map(|(h, p)| h * p).collect(),
563                    );
564                }
565            }
566            top_responses.push((role.clone(), response));
567            top_peq.push((role.clone(), peq));
568        }
569    }
570    #[cfg(test)]
571    timings.push(("top PEQ".into(), top_started.elapsed()));
572    observed("top", top_started.elapsed());
573    let bus_started = std::time::Instant::now();
574    let (bus_delay_ms, bus_polarity) = if config.subs.is_empty() {
575        (0.0, 1)
576    } else {
577        let earliest = alignment
578            .rows
579            .iter()
580            .map(|r| r.delay_ms)
581            .fold(f64::INFINITY, f64::min);
582        let lo = (-(setup.max_latency_ms - setup.lift_ms) - earliest) * 4.0;
583        let mains: Vec<_> = config
584            .mains
585            .iter()
586            .map(|r| {
587                (
588                    r.clone(),
589                    positions
590                        .iter()
591                        .filter(|p| alignment.sub_sum_by_pos.iter().any(|(q, _)| q == *p))
592                        .filter_map(|&p| used.get(p, r).map(|h| (p, h.clone())))
593                        .collect(),
594                )
595            })
596            .collect();
597        ba::bus_delay(
598            &alignment,
599            &mains,
600            &prepared.align_delay_ms,
601            &xo_of,
602            lo.ceil() / 4.0,
603            &|| ctx.cancel().is_cancelled(),
604        )?
605    };
606    check(ctx)?;
607    let rows = ba::fold(
608        &alignment.rows,
609        alignment.bus_gain,
610        bus_delay_ms,
611        bus_polarity,
612    );
613    observed("ba", ba_elapsed + bus_started.elapsed());
614    let shift = if alignment.plan.as_ref().is_some_and(|p| !p.fixed.is_empty()) {
615        config.sub_main_delay_ms
616    } else {
617        (-rows
618            .iter()
619            .map(|r| r.delay_ms)
620            .reduce(f64::min)
621            .unwrap_or(0.0))
622        .max(0.0)
623    };
624    let roles = if req.subs_only {
625        vec![]
626    } else {
627        config.mains.iter().collect()
628    };
629    let rc_all_started = std::time::Instant::now();
630    // Batches of channels reserve on this thread before entering the pool,
631    // never inside a pool task (TD-23); outputs keep the channel order.
632    let outputs = crate::memory::try_map(
633        ctx,
634        roles,
635        reserve.held,
636        |_| reserve.channel,
637        |role| {
638            check(ctx)?;
639            let Some(before) = prepared.h0.get(config.main_position, role) else {
640                progress.advance(1);
641                return Ok(None);
642            };
643            let rc_started = std::time::Instant::now();
644            let gain_db = ref_level - levels.iter().find(|(r, _)| r == role).unwrap().1;
645            let xo_hz = xo_of.iter().find(|(r, _)| r == role).map(|(_, x)| *x);
646            let align_delay_ms = prepared
647                .align_delay_ms
648                .iter()
649                .find(|(r, _)| r == role)
650                .unwrap()
651                .1;
652            let comb: Vec<(u32, Arc<[C]>)> = positions
653                .iter()
654                .filter_map(|&p| {
655                    used.get(p, role).map(|h| {
656                        let sub = alignment
657                            .sub_sum_by_pos
658                            .iter()
659                            .find(|(q, _)| *q == p)
660                            .map(|(_, h)| h.as_ref());
661                        (
662                            p,
663                            rc::combine(
664                                h,
665                                align_delay_ms,
666                                gain_db,
667                                xo_hz,
668                                sub,
669                                bus_polarity,
670                                bus_delay_ms,
671                            )
672                            .into(),
673                        )
674                    })
675                })
676                .collect();
677            let mut fitter = fitters(FitStage::Rc(role));
678            let result = rc::channel(
679                &RcInput {
680                    role,
681                    comb: &comb,
682                    main_position: config.main_position,
683                    level_db: ref_level,
684                    setup: &setup,
685                    config,
686                    tuning: req.tuning,
687                    positions: &positions,
688                },
689                &mut *fitter,
690                &|| ctx.cancel().is_cancelled(),
691            )?;
692            check(ctx)?;
693            let combined = comb
694                .iter()
695                .find(|(p, _)| *p == config.main_position)
696                .unwrap()
697                .1
698                .clone();
699            let response: Arc<[C]> = result.response.into();
700            let after = combined
701                .iter()
702                .zip(response.iter())
703                .map(|(h, f)| h * f)
704                .collect();
705            let mut peq = result.out.peq;
706            peq.extend(result.gd_cuts);
707            let channel = MainChannel {
708                role: role.clone(),
709                input_peq: peq,
710                fir: result.out.fir,
711                gain_db,
712                delay_ms: align_delay_ms + shift,
713                xo_hz,
714                align_delay_ms,
715                top_peq: top_peq
716                    .iter()
717                    .find(|(r, _)| r == role)
718                    .map_or_else(Vec::new, |(_, p)| p.clone()),
719            };
720            let main = MainDesign {
721                role: role.clone(),
722                gain_db,
723                xo_hz,
724                align_delay_ms,
725                top_response: top_responses
726                    .iter()
727                    .find(|(r, _)| r == role)
728                    .map(|(_, p)| p.clone()),
729                response,
730                phase_strength: result.out.phase_strength,
731                phase_band: result.out.phase_band,
732                phase_frac_used: result.phase_frac_used,
733                before: before.clone(),
734                combined,
735                after,
736            };
737            observed(&format!("rc:{role}"), rc_started.elapsed());
738            progress.advance(1);
739            #[cfg(test)]
740            let elapsed = rc_started.elapsed();
741            #[cfg(not(test))]
742            let elapsed = ();
743            Ok(Some((channel, main, result.out.notes, elapsed)))
744        },
745    )?;
746    observed("rc_all", rc_all_started.elapsed());
747    let mut channels = Vec::new();
748    let mut mains = Vec::new();
749    for (channel, main, channel_notes, _elapsed) in outputs.into_iter().flatten() {
750        #[cfg(test)]
751        timings.push((format!("RC {}", main.role), _elapsed));
752        channels.push(channel);
753        mains.push(main);
754        notes.extend(channel_notes);
755    }
756    let latency_ms = setup.lift_ms + shift;
757    notes.push(Note::Latency {
758        latency_ms,
759        lift_ms: setup.lift_ms,
760        main_extra_ms: shift,
761    });
762    let mut subs = Vec::new();
763    for (role, row) in config.subs.iter().zip(&rows) {
764        let mut sub = SubChannel {
765            role: role.clone(),
766            gain_db: row.gain_db,
767            delay_ms: row.delay_ms + shift,
768            polarity: row.polarity,
769            allpass: row.allpass.clone(),
770            hp_hz: 0.0,
771            hp_type: BandType::Lr24,
772            lp_hz: 0.0,
773            lp_type: BandType::Lr24,
774            peq: vec![],
775            mode: ba::sub_mode_label(role, &setup, &alignment.layout_info),
776        };
777        if alignment
778            .plan
779            .as_ref()
780            .is_some_and(|p| p.fixed.contains(role))
781        {
782            let m = &setup.sub_manual.iter().find(|(r, _)| r == role).unwrap().1;
783            sub.gain_db = m.gain_db;
784            sub.delay_ms = m.delay_ms;
785            sub.polarity = if m.polarity < 0 { -1 } else { 1 };
786            sub.allpass = m.allpass.iter().copied().take(2).collect();
787            sub.mode = "fixed".into();
788        }
789        if let Some((_, f)) = alignment.sub_filters.iter().find(|(r, _)| r == role) {
790            sub.hp_hz = f.hp_hz;
791            sub.hp_type = f.hp_type;
792            sub.lp_hz = f.lp_hz;
793            sub.lp_type = f.lp_type;
794            sub.peq = f.peq.clone();
795        }
796        subs.push(sub);
797    }
798    let sub = if config.subs.is_empty() {
799        None
800    } else {
801        Some(SubDesign {
802            rows: config.subs.iter().cloned().zip(rows).collect(),
803            bus_gain: alignment.bus_gain,
804            bus_delay_ms,
805            bus_polarity,
806            bus_response: (!alignment.bus_peq.is_empty())
807                .then(|| response::peq_response(design_freqs(), &alignment.bus_peq).into()),
808            sum_at_reference: alignment
809                .sub_sum_by_pos
810                .iter()
811                .find(|(p, _)| *p == config.main_position)
812                .unwrap()
813                .1
814                .clone(),
815            sub_level_db: alignment.sub_level_db,
816            layout_info: alignment.layout_info,
817            plan: alignment.plan,
818        })
819    };
820    let f = design_freqs();
821    let target = config
822        .target
823        .sample(&f.iter().map(|f| f.max(1.0)).collect::<Vec<_>>());
824    let target_db: Arc<[f64]> = target.iter().map(|v| v + ref_level).collect();
825    let spl_grid = if include_scores {
826        geomspace(5.0, 20000.0, 1390)
827    } else {
828        Vec::new()
829    };
830    let gd_grid = if include_scores {
831        geomspace(5.0, 5000.0, 1070)
832    } else {
833        Vec::new()
834    };
835    let scores_started = std::time::Instant::now();
836    let scores = if include_scores {
837        // A channel's scores need less than its RC working set.
838        crate::memory::try_map(
839            ctx,
840            mains.iter().collect(),
841            reserve.held,
842            |_| reserve.channel,
843            |main| {
844                check(ctx)?;
845                Ok(RoleScores {
846                    role: main.role.clone(),
847                    before: scorecard::metrics(&main.before, f, &target, (100.0, 400.0)),
848                    after: scorecard::metrics(&main.after, f, &target, (100.0, 400.0)),
849                    spl_before: spl_db(&main.before, &spl_grid),
850                    spl_after: spl_db(&main.after, &spl_grid),
851                    gd_before: response::gd_curve(
852                        &main.before,
853                        f,
854                        &gd_grid,
855                        Fraction::Scalar(24.0),
856                    )?,
857                    gd_after: response::gd_curve(&main.after, f, &gd_grid, Fraction::Scalar(24.0))?,
858                })
859            },
860        )?
861    } else {
862        Vec::new()
863    };
864    observed("scores", scores_started.elapsed());
865    #[cfg(test)]
866    timings.push(("scores".into(), scores_started.elapsed()));
867    notes.push(Note::PhaseStrength(
868        mains
869            .iter()
870            .map(|m| (m.role.clone(), m.phase_strength))
871            .collect(),
872    ));
873    notes.push(Note::PhaseBand(
874        mains
875            .iter()
876            .map(|m| (m.role.clone(), m.phase_band))
877            .collect(),
878    ));
879    check(ctx)?;
880    Ok(Design {
881        chain: SignalChain {
882            sample_rate: SR,
883            mains: channels,
884            subs,
885            sub_bus_peq: alignment.bus_peq,
886            main_extra_ms: shift,
887            lift_ms: setup.lift_ms,
888            latency_ms,
889        },
890        mains,
891        subs: sub,
892        ref_level,
893        rc_hi: setup.rc_hi,
894        rc_auto,
895        schroeder_hz,
896        target_display: spl_grid.iter().map(|&g| interp(g, f, &target_db)).collect(),
897        target_db,
898        spectra: DesignSpectra {
899            raw: Arc::new(prepared.h0.clone()),
900            used: Arc::new(used),
901            main_position: config.main_position,
902            positions,
903        },
904        notes,
905        adapter_notes: req.adapter_notes.to_vec(),
906        groups: config.groups.clone(),
907        target: config.target.clone(),
908        xo: config.xo.clone(),
909        scores,
910        replay: Arc::new(crate::DesignReplay::new(
911            req.measurements,
912            config,
913            setup,
914            prepared,
915        )),
916        #[cfg(test)]
917        timings,
918    })
919}