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