1use std::sync::Arc;
6
7use secs_compute::{CancelToken, Context};
8use secs_dsp::{
9 interp, response,
10 smooth::{self, Fraction},
11};
12use secs_types::{
13 calibration::RcHiBasis,
14 chain::{BandType, MainChannel, SignalChain, SubChannel},
15 target::TargetCurve,
16};
17
18use crate::{
19 C, EngineConfig, EngineError, MeasurementSet, Tuning,
20 ba::{self, BaInput, LayoutInfo, ManualPlan},
21 chain::BaRow,
22 fit::{self, Fitter, PeqDerivatives},
23 grid::{N_FFT, SR, design_freqs},
24 levels,
25 measurement::{self, Spectra},
26 notes::Note,
27 rc::{self, RcInput},
28 scorecard::{self, Metrics},
29 setup::Setup,
30};
31
32#[derive(Debug)]
33pub enum FitStage<'a> {
34 Ba,
35 Top(&'a str),
36 Rc(&'a str),
37}
38pub type FitterFactory<'f> = dyn Fn(FitStage<'_>) -> Box<Fitter<'f>> + Sync + 'f;
39pub fn production_fitters(cancel: &CancelToken) -> Box<FitterFactory<'_>> {
40 production_fitters_with(cancel, PeqDerivatives::TwoPoint)
41}
42
43pub fn production_fitters_with(
44 cancel: &CancelToken,
45 derivatives: PeqDerivatives,
46) -> Box<FitterFactory<'_>> {
47 Box::new(move |_| {
48 Box::new(move |request| {
49 if cancel.is_cancelled() {
50 Vec::new()
51 } else {
52 fit::fit_peq_with(request, &|| cancel.is_cancelled(), derivatives)
53 }
54 })
55 })
56}
57
58#[derive(Debug)]
59pub struct DesignRequest<'a> {
60 pub measurements: &'a MeasurementSet,
61 pub config: &'a EngineConfig,
62 pub tuning: &'a Tuning,
63 pub subs_only: bool,
64 pub adapter_notes: &'a [String],
65}
66#[derive(Debug)]
67pub struct Design {
68 pub replay: Arc<crate::DesignReplay>,
69 pub chain: SignalChain,
70 pub mains: Vec<MainDesign>,
71 pub subs: Option<SubDesign>,
72 pub ref_level: f64,
73 pub rc_hi: f64,
74 pub rc_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 pub groups: Vec<(String, Vec<String>)>,
86 pub target: TargetCurve,
88 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
146pub 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 && design.chain.input_hp_hz.is_none()
158 {
159 let Some(main) = design.mains.iter().find(|main| main.role == channel.role) else {
160 continue;
161 };
162 (
163 crate::decay::late_db_20_150(&main.before),
164 crate::decay::late_db_20_150(&main.after),
165 )
166 } else {
167 let Some(before) = view.spectra.get(position, &channel.role) else {
168 continue;
169 };
170 let Some(after) =
171 crate::stage_responses(design, &channel.role, &view.spectra, position)
172 .map(|stages| stages.output)
173 else {
174 continue;
175 };
176 (
177 crate::decay::late_db_20_150(before),
178 crate::decay::late_db_20_150(&after),
179 )
180 };
181 rows.push(RoleLateEnergy {
182 role: channel.role.clone(),
183 position,
184 before_db,
185 after_db,
186 });
187 }
188 }
189 Ok(rows)
190}
191
192fn check(ctx: &Context) -> Result<(), EngineError> {
193 ctx.cancel().check().map_err(|_| EngineError::Cancelled)
194}
195
196pub(crate) fn check_taps(config: &EngineConfig) -> Result<(), EngineError> {
200 if config.taps > N_FFT {
201 return Err(EngineError::Input(format!(
202 "FIR taps {} outside 0..={N_FFT} (0 is PEQ only)",
203 config.taps
204 )));
205 }
206 Ok(())
207}
208
209pub(crate) fn check_input_hp(config: &EngineConfig) -> Result<(), EngineError> {
213 let (lo, hi) = secs_types::rules::CALIBRATION_LIMITS.input_hp;
214 match config.input_hp_hz {
215 Some(hz) if !(hz.is_finite() && (lo..=hi).contains(&hz)) => Err(EngineError::Input(
216 format!("input high-pass {hz} Hz outside {lo}..={hi} Hz"),
217 )),
218 _ => Ok(()),
219 }
220}
221
222const RC_SCRATCH_SPECTRA: u64 = 24;
231const RC_ADR0025_SPECTRA: u64 = 6;
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub(crate) struct DesignBytes {
242 pub retained: u64,
243 pub channel: u64,
244}
245
246impl DesignBytes {
247 pub(crate) fn new(
261 set: &MeasurementSet,
262 config: &EngineConfig,
263 setup: &Setup,
264 subs_only: bool,
265 ) -> Self {
266 let positions = set.positions.len() as u64;
267 let subs = config.subs.len() as u64;
268 let mains = if subs_only
269 && !(config.rc_range.1.is_none() && config.rc_hi_basis == RcHiBasis::Agreement)
270 {
271 0
272 } else {
273 config.mains.len() as u64
274 };
275 let ba = if config.subs.is_empty() {
276 0
277 } else {
278 measurement::cells(set, Some(&config.subs))
279 .saturating_mul(setup.layouts.len() as u64)
280 .saturating_add(measurement::cells(set, Some(&config.mains)))
281 .saturating_add(2 * positions)
282 .saturating_add(subs + 2)
283 .saturating_add(config.mains.len() as u64)
284 };
285 let spectra = |n: u64| n.saturating_mul(measurement::SPECTRUM_BYTES);
286 Self {
287 retained: spectra(ba.saturating_add(3 * mains))
288 .saturating_add(crate::DesignReplay::bytes(set, config, setup)),
289 channel: if mains == 0 {
290 0
291 } else {
292 let extra = if setup.lf_align || setup.mode_decay {
293 RC_ADR0025_SPECTRA
294 } else {
295 0
296 };
297 spectra(positions + 4 + RC_SCRATCH_SPECTRA + extra)
298 },
299 }
300 }
301}
302pub(crate) fn geomspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
303 let start = lo.log10();
304 let step = (hi.log10() - start) / (n - 1) as f64;
305 let mut out: Vec<_> = (0..n)
306 .map(|i| 10f64.powf(start + i as f64 * step))
307 .collect();
308 out[0] = lo;
309 out[n - 1] = hi;
310 out
311}
312pub(crate) fn spl_db(h: &[C], grid: &[f64]) -> Vec<f64> {
313 let p: Vec<_> = h
314 .iter()
315 .map(|v| {
316 let a = v.re.hypot(v.im);
317 a * a
318 })
319 .collect();
320 let db: Vec<_> = smooth::smooth_power(design_freqs(), &p, Fraction::Scalar(24.0))
321 .expect("design grid")
322 .iter()
323 .map(|p| 10.0 * (p + 1e-30).log10())
324 .collect();
325 grid.iter()
326 .map(|&g| interp(g, design_freqs(), &db))
327 .collect()
328}
329
330pub fn design(
331 req: &DesignRequest<'_>,
332 ctx: &Context,
333 fitters: &FitterFactory<'_>,
334) -> Result<Design, EngineError> {
335 design_observed(req, ctx, fitters, &|_, _| {})
336}
337
338pub(crate) fn design_observed(
340 req: &DesignRequest<'_>,
341 ctx: &Context,
342 fitters: &FitterFactory<'_>,
343 observed: &(dyn Fn(&str, std::time::Duration) + Sync),
344) -> Result<Design, EngineError> {
345 check(ctx)?;
346 check_taps(req.config)?;
347 check_input_hp(req.config)?;
348 let prep_started = std::time::Instant::now();
349 let config = req.config;
350 let setup = Setup::new(config);
351 let bytes = DesignBytes::new(req.measurements, config, &setup, req.subs_only);
355 let held =
356 measurement::preparation_bytes(req.measurements, &setup).saturating_add(bytes.retained);
357 let schroeder = levels::Schroeder::analyze(req.measurements, config, ctx)?;
358 let _permit = crate::memory::reserve(ctx, held, bytes.channel)?;
359 let prepared = measurement::prepare_until(req.measurements, config, &setup, &|| {
360 ctx.cancel().is_cancelled()
361 })?;
362 observed("prepare", prep_started.elapsed());
363 design_prepared_scored(
364 req,
365 ctx,
366 fitters,
367 &prepared,
368 observed,
369 true,
370 ChannelBytes {
371 held,
372 channel: bytes.channel,
373 },
374 schroeder,
375 )
376}
377
378pub(crate) fn design_prepared(
384 req: &DesignRequest<'_>,
385 ctx: &Context,
386 fitters: &FitterFactory<'_>,
387 prepared: &measurement::Prepared,
388 observed: &(dyn Fn(&str, std::time::Duration) + Sync),
389) -> Result<Design, EngineError> {
390 let bytes = DesignBytes::new(
391 req.measurements,
392 req.config,
393 &Setup::new(req.config),
394 req.subs_only,
395 );
396 let schroeder = levels::Schroeder::analyze(req.measurements, req.config, ctx)?;
397 let _permit = crate::memory::reserve(ctx, bytes.retained, bytes.channel)?;
398 design_prepared_scored(
399 req,
400 ctx,
401 fitters,
402 prepared,
403 observed,
404 true,
405 ChannelBytes {
406 held: bytes.retained,
407 channel: bytes.channel,
408 },
409 schroeder,
410 )
411}
412
413pub(crate) fn design_prepared_for_xo(
418 req: &DesignRequest<'_>,
419 ctx: &Context,
420 fitters: &FitterFactory<'_>,
421 prepared: &measurement::Prepared,
422 schroeder: levels::Schroeder,
423) -> Result<Design, EngineError> {
424 design_prepared_scored(
425 req,
426 ctx,
427 fitters,
428 prepared,
429 &|_, _| {},
430 false,
431 ChannelBytes::RESERVED,
432 schroeder,
433 )
434}
435
436#[derive(Debug, Clone, Copy)]
439struct ChannelBytes {
440 held: u64,
441 channel: u64,
442}
443
444impl ChannelBytes {
445 const RESERVED: Self = Self {
447 held: 0,
448 channel: 0,
449 };
450}
451
452#[allow(clippy::too_many_arguments)]
453fn design_prepared_scored(
454 req: &DesignRequest<'_>,
455 ctx: &Context,
456 fitters: &FitterFactory<'_>,
457 prepared: &measurement::Prepared,
458 observed: &(dyn Fn(&str, std::time::Duration) + Sync),
459 include_scores: bool,
460 reserve: ChannelBytes,
461 schroeder: levels::Schroeder,
462) -> Result<Design, EngineError> {
463 if req.config.rc_range.1.is_none() && req.config.rc_hi_basis == RcHiBasis::Agreement {
464 let count = agreement_positions(req.measurements, req.config);
465 if count < 3 {
466 return Err(EngineError::Input(format!(
467 "RC agreement requires at least 3 measured positions; found {count}"
468 )));
469 }
470 let (probe, limits) = design_pass(
471 req,
472 ctx,
473 fitters,
474 prepared,
475 observed,
476 false,
477 reserve,
478 schroeder,
479 RcPass::Probe,
480 )?;
481 let limit = levels::median(limits).clamp(100.0, 2000.0);
482 drop(probe);
483 check(ctx)?;
484 return design_pass(
485 req,
486 ctx,
487 fitters,
488 prepared,
489 observed,
490 include_scores,
491 reserve,
492 schroeder,
493 RcPass::Final(limit),
494 )
495 .map(|(design, _)| design);
496 }
497 design_pass(
498 req,
499 ctx,
500 fitters,
501 prepared,
502 observed,
503 include_scores,
504 reserve,
505 schroeder,
506 RcPass::Normal,
507 )
508 .map(|(design, _)| design)
509}
510
511pub(crate) fn agreement_positions(set: &MeasurementSet, config: &EngineConfig) -> usize {
512 set.positions
513 .iter()
514 .filter(|(_, roles)| {
515 roles
516 .iter()
517 .any(|(role, m)| config.mains.contains(role) && !m.samples.is_empty())
518 })
519 .count()
520}
521
522#[derive(Clone, Copy)]
523enum RcPass {
524 Normal,
525 Probe,
526 Final(f64),
527}
528
529#[allow(clippy::too_many_arguments)]
530fn design_pass(
531 req: &DesignRequest<'_>,
532 ctx: &Context,
533 fitters: &FitterFactory<'_>,
534 prepared: &measurement::Prepared,
535 observed: &(dyn Fn(&str, std::time::Duration) + Sync),
536 include_scores: bool,
537 reserve: ChannelBytes,
538 schroeder: levels::Schroeder,
539 pass: RcPass,
540) -> Result<(Design, Vec<f64>), EngineError> {
541 check(ctx)?;
542 check_taps(req.config)?;
543 check_input_hp(req.config)?;
544 let config = req.config;
545 let mut setup = Setup::new(config);
546 if matches!(pass, RcPass::Probe) {
547 setup.rc_bands.clear();
548 setup.lf_align = false;
549 setup.mode_decay = false;
550 setup.hf_phase = false;
551 }
552 let positions = prepared.h.positions().to_vec();
553 let progress = ctx.stage("engine.design", 1 + config.mains.len() as u64);
554 let mut notes = Vec::new();
555 let run_final_checks = !req.subs_only && !matches!(pass, RcPass::Probe);
556 if run_final_checks {
557 notes.extend(crate::polarity::check(
558 req.measurements,
559 &config.mains,
560 config.main_position,
561 ));
562 }
563 if let Some(from_ms) = setup.lift_capped_from_ms {
564 notes.push(Note::LiftCapped {
565 from_ms,
566 taps: config.taps,
567 lift_ms: setup.lift_ms,
568 });
569 }
570 if !setup.sub_filters.is_empty() {
571 notes.push(Note::SubFilters(setup.sub_filters.clone()));
572 }
573 let rc_auto = setup.rc_hi_manual.is_none();
574 let schroeder_hz = schroeder.strict;
575 let lenient = schroeder.lenient;
576 if rc_auto {
577 if !matches!(config.rc_hi_transition, 1 | 2 | 4) {
578 return Err(EngineError::Input(
579 "RC transition factor must be 1, 2 or 4".into(),
580 ));
581 }
582 setup.rc_hi = match pass {
583 RcPass::Probe => 2000.0,
584 RcPass::Final(hz) => hz,
585 RcPass::Normal => {
586 levels::schroeder_limit(schroeder_hz, lenient.map(|v| v.0), config.rc_hi_transition)
587 }
588 };
589 }
590 notes.push(Note::RcLimit {
591 hz: setup.rc_hi,
592 auto: rc_auto,
593 schroeder_hz,
594 basis: config.rc_hi_basis,
595 factor: config.rc_hi_transition,
596 band_hz: lenient.map(|v| v.0),
597 band_t60_s: lenient.map(|v| v.1),
598 positions: (rc_auto && config.rc_hi_basis == RcHiBasis::Agreement)
599 .then(|| agreement_positions(req.measurements, config)),
600 });
601 let rc_bands = if setup.rc_bands.is_empty() {
602 Vec::new()
603 } else {
604 let bands = rc::band_set(setup.rc_lo, setup.rc_hi, &setup.rc_bands);
605 notes.push(Note::RcBands {
606 bands: bands.clone(),
607 });
608 bands
609 };
610 let xo_of: Vec<_> = config
611 .groups
612 .iter()
613 .filter(|(g, _)| g != "sub")
614 .flat_map(|(g, roles)| {
615 roles.iter().filter_map(move |r| {
616 config
617 .xo
618 .iter()
619 .find(|(name, x)| name == g && *x != 0.0 && !config.subs.is_empty())
620 .map(|(_, x)| (r.clone(), *x))
621 })
622 })
623 .collect();
624 let weights = levels::weights(&positions, config.main_position);
625 let levels: Vec<_> = config
626 .mains
627 .iter()
628 .filter(|r| prepared.h.get(config.main_position, r).is_some())
629 .map(|r| {
630 (
631 r.clone(),
632 levels::band_level(&prepared.h, r, &weights, &config.target, 300.0, 3000.0),
633 )
634 })
635 .collect();
636 let ref_level = levels
637 .iter()
638 .map(|(_, v)| *v)
639 .reduce(f64::min)
640 .ok_or_else(|| EngineError::Input("no measured main".into()))?;
641 let input = BaInput {
642 measurements: req.measurements,
643 prepared,
644 setup: &setup,
645 config,
646 tuning: req.tuning,
647 xo_of: &xo_of,
648 ref_level,
649 cancel: ctx.cancel(),
650 };
651 let ba_started = std::time::Instant::now();
652 let mut ba_fitter = fitters(FitStage::Ba);
653 let alignment = ba::align(&input, &mut *ba_fitter)?;
654 check(ctx)?;
655 #[cfg(test)]
656 let mut timings = vec![("BA".into(), ba_started.elapsed())];
657 notes.extend(alignment.notes.clone());
658 progress.advance(1);
659 let ba_elapsed = ba_started.elapsed();
660 let mut used = prepared.h.clone();
661 for (p, r, h) in &alignment.h_subs {
662 used.set(*p, r, h.clone());
663 }
664 let top_started = std::time::Instant::now();
665 let mut top_peq = Vec::new();
666 let mut top_responses = Vec::new();
667 if !config.subs.is_empty() && req.tuning.xo_stage {
668 for (role, xo) in &xo_of {
669 check(ctx)?;
670 let lo = if config.xo_peq_lo_hz == 0.0 {
671 xo / 2.0
672 } else {
673 config.xo_peq_lo_hz
674 };
675 let hi = if config.xo_peq_hi_hz == 0.0 {
676 xo * 4.0
677 } else {
678 config.xo_peq_hi_hz
679 };
680 if !config.xo_peq_on || hi <= lo {
681 continue;
682 }
683 let level = levels
684 .iter()
685 .find(|(r, _)| r == role)
686 .ok_or_else(|| EngineError::Input(format!("XO role {role} missing at reference")))?
687 .1;
688 let gain = C::new(10f64.powf((ref_level - level) / 20.0), 0.0);
689 let by_pos: Vec<_> = positions
690 .iter()
691 .zip(&weights)
692 .filter_map(|(&p, &w)| {
693 used.get(p, role)
694 .map(|h| (w, h.iter().map(|v| v * gain).collect::<Vec<_>>()))
695 })
696 .collect();
697 let weighted: Vec<_> = by_pos.iter().map(|(w, h)| (*w, h.as_slice())).collect();
698 let peq = fit::xo_target_peq(
699 &weighted,
700 &config.target,
701 ref_level,
702 lo,
703 hi,
704 6,
705 config.max_boost_db.min(6.0),
706 Some(hi),
707 &mut *fitters(FitStage::Top(role)),
708 );
709 check(ctx)?;
710 let response: Arc<[C]> = response::peq_response(design_freqs(), &peq).into();
711 for &p in &positions {
712 if let Some(h) = used.get(p, role) {
713 used.set(
714 p,
715 role,
716 h.iter().zip(response.iter()).map(|(h, p)| h * p).collect(),
717 );
718 }
719 }
720 top_responses.push((role.clone(), response));
721 top_peq.push((role.clone(), peq));
722 }
723 }
724 #[cfg(test)]
725 timings.push(("top PEQ".into(), top_started.elapsed()));
726 observed("top", top_started.elapsed());
727 let bus_started = std::time::Instant::now();
728 let (bus_delay_ms, bus_polarity) = if config.subs.is_empty() {
729 (0.0, 1)
730 } else {
731 let earliest = alignment
732 .rows
733 .iter()
734 .map(|r| r.delay_ms)
735 .fold(f64::INFINITY, f64::min);
736 let lo = (-(setup.max_latency_ms - setup.lift_ms) - earliest) * 4.0;
737 let mains: Vec<_> = config
738 .mains
739 .iter()
740 .map(|r| {
741 (
742 r.clone(),
743 positions
744 .iter()
745 .filter(|p| alignment.sub_sum_by_pos.iter().any(|(q, _)| q == *p))
746 .filter_map(|&p| used.get(p, r).map(|h| (p, h.clone())))
747 .collect(),
748 )
749 })
750 .collect();
751 ba::bus_delay(
752 &alignment,
753 &mains,
754 &prepared.align_delay_ms,
755 &xo_of,
756 lo.ceil() / 4.0,
757 &|| ctx.cancel().is_cancelled(),
758 )?
759 };
760 check(ctx)?;
761 let rows = ba::fold(
762 &alignment.rows,
763 alignment.bus_gain,
764 bus_delay_ms,
765 bus_polarity,
766 );
767 observed("ba", ba_elapsed + bus_started.elapsed());
768 let shift = if alignment.plan.as_ref().is_some_and(|p| !p.fixed.is_empty()) {
769 config.sub_main_delay_ms
770 } else {
771 (-rows
772 .iter()
773 .map(|r| r.delay_ms)
774 .reduce(f64::min)
775 .unwrap_or(0.0))
776 .max(0.0)
777 };
778 let roles = if req.subs_only && !matches!(pass, RcPass::Probe) {
779 vec![]
780 } else {
781 config.mains.iter().collect()
782 };
783 let rc_all_started = std::time::Instant::now();
784 let outputs = crate::memory::try_map(
787 ctx,
788 roles,
789 reserve.held,
790 |_| reserve.channel,
791 |role| {
792 check(ctx)?;
793 let Some(before) = prepared.h0.get(config.main_position, role) else {
794 progress.advance(1);
795 return Ok(None);
796 };
797 let rc_started = std::time::Instant::now();
798 let gain_db = ref_level - levels.iter().find(|(r, _)| r == role).unwrap().1;
799 let xo_hz = xo_of.iter().find(|(r, _)| r == role).map(|(_, x)| *x);
800 let align_delay_ms = prepared
801 .align_delay_ms
802 .iter()
803 .find(|(r, _)| r == role)
804 .unwrap()
805 .1;
806 let comb: Vec<(u32, Arc<[C]>)> = positions
807 .iter()
808 .filter_map(|&p| {
809 used.get(p, role).map(|h| {
810 let sub = alignment
811 .sub_sum_by_pos
812 .iter()
813 .find(|(q, _)| *q == p)
814 .map(|(_, h)| h.as_ref());
815 (
816 p,
817 rc::combine(
818 h,
819 align_delay_ms,
820 gain_db,
821 xo_hz,
822 sub,
823 bus_polarity,
824 bus_delay_ms,
825 )
826 .into(),
827 )
828 })
829 })
830 .collect();
831 let mut fitter = fitters(FitStage::Rc(role));
832 let rc_input = RcInput {
833 role,
834 comb: &comb,
835 main_position: config.main_position,
836 level_db: ref_level,
837 setup: &setup,
838 config,
839 tuning: req.tuning,
840 positions: &positions,
841 bus_rolloff_hz: if config.low_protect && xo_hz.is_some_and(|hz| hz != 0.0) {
842 alignment.bus_rolloff_hz
843 } else {
844 None
845 },
846 };
847 let result = rc::channel(&rc_input, &mut *fitter, &|| ctx.cancel().is_cancelled())?;
848 check(ctx)?;
849 let agreement = matches!(pass, RcPass::Probe)
850 .then(|| rc::agreement_limit(&rc_input, &result.response));
851 let combined = comb
852 .iter()
853 .find(|(p, _)| *p == config.main_position)
854 .unwrap()
855 .1
856 .clone();
857 let response: Arc<[C]> = result.response.into();
858 let after = combined
859 .iter()
860 .zip(response.iter())
861 .map(|(h, f)| h * f)
862 .collect();
863 let mut peq = result.out.peq;
864 peq.extend(result.gd_cuts);
865 let channel = MainChannel {
866 role: role.clone(),
867 input_peq: peq,
868 fir: result.out.fir,
869 gain_db,
870 polarity: config.inverted_mains.contains(role).then_some(-1),
871 delay_ms: align_delay_ms + shift,
872 xo_hz,
873 align_delay_ms,
874 top_peq: top_peq
875 .iter()
876 .find(|(r, _)| r == role)
877 .map_or_else(Vec::new, |(_, p)| p.clone()),
878 };
879 let main = MainDesign {
880 role: role.clone(),
881 gain_db,
882 xo_hz,
883 align_delay_ms,
884 top_response: top_responses
885 .iter()
886 .find(|(r, _)| r == role)
887 .map(|(_, p)| p.clone()),
888 response,
889 phase_strength: result.out.phase_strength,
890 phase_band: result.out.phase_band,
891 phase_frac_used: result.phase_frac_used,
892 before: before.clone(),
893 combined,
894 after,
895 };
896 observed(&format!("rc:{role}"), rc_started.elapsed());
897 progress.advance(1);
898 #[cfg(test)]
899 let elapsed = rc_started.elapsed();
900 #[cfg(not(test))]
901 let elapsed = ();
902 Ok(Some((channel, main, result.out.notes, elapsed, agreement)))
903 },
904 )?;
905 observed("rc_all", rc_all_started.elapsed());
906 let mut channels = Vec::new();
907 let mut mains = Vec::new();
908 let mut agreement_limits = Vec::new();
909 for (channel, main, channel_notes, _elapsed, agreement) in outputs.into_iter().flatten() {
910 if let Some(limit) = agreement {
911 agreement_limits.push(limit);
912 }
913 #[cfg(test)]
914 timings.push((format!("RC {}", main.role), _elapsed));
915 channels.push(channel);
916 mains.push(main);
917 notes.extend(channel_notes);
918 }
919 let latency_ms = setup.lift_ms + shift;
920 notes.push(Note::Latency {
921 latency_ms,
922 lift_ms: setup.lift_ms,
923 main_extra_ms: shift,
924 });
925 let mut subs = Vec::new();
926 for (role, row) in config.subs.iter().zip(&rows) {
927 let mut sub = SubChannel {
928 role: role.clone(),
929 gain_db: row.gain_db,
930 delay_ms: row.delay_ms + shift,
931 polarity: row.polarity,
932 allpass: row.allpass.clone(),
933 hp_hz: 0.0,
934 hp_type: BandType::Lr24,
935 lp_hz: 0.0,
936 lp_type: BandType::Lr24,
937 peq: vec![],
938 mode: ba::sub_mode_label(role, &setup, &alignment.layout_info),
939 };
940 if alignment
941 .plan
942 .as_ref()
943 .is_some_and(|p| p.fixed.contains(role))
944 {
945 let m = &setup.sub_manual.iter().find(|(r, _)| r == role).unwrap().1;
946 sub.gain_db = m.gain_db;
947 sub.delay_ms = m.delay_ms;
948 sub.polarity = if m.polarity < 0 { -1 } else { 1 };
949 sub.allpass = m.allpass.iter().copied().take(2).collect();
950 sub.mode = "fixed".into();
951 }
952 if let Some((_, f)) = alignment.sub_filters.iter().find(|(r, _)| r == role) {
953 sub.hp_hz = f.hp_hz;
954 sub.hp_type = f.hp_type;
955 sub.lp_hz = f.lp_hz;
956 sub.lp_type = f.lp_type;
957 sub.peq = f.peq.clone();
958 }
959 if config.low_protect {
960 let by_pos: Vec<_> = positions
961 .iter()
962 .filter_map(|&p| used.get(p, role).map(|h| (p, h.clone())))
963 .collect();
964 if let Some(hz) = crate::low_protect::rolloff(
965 &by_pos,
966 config.main_position,
967 Some((sub.hp_hz, sub.lp_hz)),
968 ) {
969 if sub.hp_hz == 0.0 {
970 sub.hp_hz = hz / 2.0_f64.sqrt();
971 sub.hp_type = BandType::Lr24;
972 }
973 notes.push(Note::LowProtect {
974 role: role.clone(),
975 rolloff_hz: hz,
976 hp_hz: Some(sub.hp_hz),
977 limited: true,
978 reduced: 0,
979 cuts: 0,
980 });
981 }
982 }
983 subs.push(sub);
984 }
985 let sub = if config.subs.is_empty() {
986 None
987 } else {
988 Some(SubDesign {
989 rows: config.subs.iter().cloned().zip(rows).collect(),
990 bus_gain: alignment.bus_gain,
991 bus_delay_ms,
992 bus_polarity,
993 bus_response: (!alignment.bus_peq.is_empty())
994 .then(|| response::peq_response(design_freqs(), &alignment.bus_peq).into()),
995 sum_at_reference: alignment
996 .sub_sum_by_pos
997 .iter()
998 .find(|(p, _)| *p == config.main_position)
999 .unwrap()
1000 .1
1001 .clone(),
1002 sub_level_db: alignment.sub_level_db,
1003 layout_info: alignment.layout_info,
1004 plan: alignment.plan,
1005 })
1006 };
1007 let f = design_freqs();
1008 let target = config
1009 .target
1010 .sample(&f.iter().map(|f| f.max(1.0)).collect::<Vec<_>>());
1011 let target_db: Arc<[f64]> = target.iter().map(|v| v + ref_level).collect();
1012 let spl_grid = if include_scores {
1013 geomspace(5.0, 20000.0, 1390)
1014 } else {
1015 Vec::new()
1016 };
1017 let gd_grid = if include_scores {
1018 geomspace(5.0, 5000.0, 1070)
1019 } else {
1020 Vec::new()
1021 };
1022 let scores_started = std::time::Instant::now();
1023 let scores = if include_scores {
1024 crate::memory::try_map(
1026 ctx,
1027 mains.iter().collect(),
1028 reserve.held,
1029 |_| reserve.channel,
1030 |main| {
1031 check(ctx)?;
1032 Ok(RoleScores {
1033 role: main.role.clone(),
1034 before: scorecard::metrics(&main.before, f, &target, (100.0, 400.0)),
1035 after: scorecard::metrics(&main.after, f, &target, (100.0, 400.0)),
1036 spl_before: spl_db(&main.before, &spl_grid),
1037 spl_after: spl_db(&main.after, &spl_grid),
1038 gd_before: response::gd_curve(
1039 &main.before,
1040 f,
1041 &gd_grid,
1042 Fraction::Scalar(24.0),
1043 )?,
1044 gd_after: response::gd_curve(&main.after, f, &gd_grid, Fraction::Scalar(24.0))?,
1045 })
1046 },
1047 )?
1048 } else {
1049 Vec::new()
1050 };
1051 observed("scores", scores_started.elapsed());
1052 #[cfg(test)]
1053 timings.push(("scores".into(), scores_started.elapsed()));
1054 notes.push(Note::PhaseStrength(
1055 mains
1056 .iter()
1057 .map(|m| (m.role.clone(), m.phase_strength))
1058 .collect(),
1059 ));
1060 notes.push(Note::PhaseBand(
1061 mains
1062 .iter()
1063 .map(|m| (m.role.clone(), m.phase_band))
1064 .collect(),
1065 ));
1066 if let Some(hz) = setup.input_hp_hz {
1067 notes.push(Note::InputHp { hz });
1068 }
1069 check(ctx)?;
1070 let mut design = Design {
1071 chain: SignalChain {
1072 input_hp_hz: setup.input_hp_hz,
1073 sample_rate: SR,
1074 mains: channels,
1075 subs,
1076 sub_bus_peq: alignment.bus_peq,
1077 main_extra_ms: shift,
1078 lift_ms: setup.lift_ms,
1079 latency_ms,
1080 },
1081 mains,
1082 subs: sub,
1083 ref_level,
1084 rc_hi: setup.rc_hi,
1085 rc_bands,
1086 rc_auto,
1087 schroeder_hz,
1088 target_display: spl_grid.iter().map(|&g| interp(g, f, &target_db)).collect(),
1089 target_db,
1090 spectra: DesignSpectra {
1091 raw: Arc::new(prepared.h0.clone()),
1092 used: Arc::new(used),
1093 main_position: config.main_position,
1094 positions,
1095 },
1096 notes,
1097 adapter_notes: req.adapter_notes.to_vec(),
1098 groups: config.groups.clone(),
1099 target: config.target.clone(),
1100 xo: config.xo.clone(),
1101 scores,
1102 replay: Arc::new(crate::DesignReplay::new(
1103 req.measurements,
1104 config,
1105 setup,
1106 prepared,
1107 )),
1108 #[cfg(test)]
1109 timings,
1110 };
1111 if run_final_checks {
1112 let pair_started = std::time::Instant::now();
1113 let (lo, hi) = crate::pair_phase::BAND_HZ;
1116 let kept: Vec<usize> = f
1117 .iter()
1118 .enumerate()
1119 .filter(|&(_, &hz)| hz >= lo && hz <= hi)
1120 .map(|(i, _)| i)
1121 .collect();
1122 let band_f: Vec<f64> = kept.iter().map(|&i| f[i]).collect();
1123 let mut responses = Vec::with_capacity(design.mains.len());
1124 for main in &design.mains {
1125 check(ctx)?;
1126 let Some(stages) =
1127 crate::stage_responses(&design, &main.role, &prepared.h, config.main_position)
1128 else {
1129 continue;
1130 };
1131 let response: Vec<C> = kept
1132 .iter()
1133 .map(|&i| stages.top_xo[i] * main.response[i])
1134 .collect();
1135 responses.push((main.role.clone(), response));
1136 }
1137 design
1138 .notes
1139 .extend(crate::pair_phase::check(&band_f, &responses));
1140 check(ctx)?;
1141 observed("pair_phase", pair_started.elapsed());
1142 }
1143 Ok((design, agreement_limits))
1144}