1use secs_compute::Context;
6use secs_dsp::pairwise_sum;
7use secs_types::calibration::CalibrationSettings;
8
9use crate::{
10 Design, DesignRequest, EngineConfig, EngineError, MeasurementSet, ProjectView, SubFilter,
11 Tuning,
12 calibration::{engine_inputs, prepare_inputs, resolve_xo},
13 chain::stage_responses,
14 decay::late_db_20_150,
15 design::{DesignBytes, design_prepared_for_xo, production_fitters_with},
16 fit::PeqDerivatives,
17 grid::design_freqs,
18 levels, measurement, scorecard,
19 setup::Setup,
20};
21
22pub const XO_CANDIDATES: [f64; 4] = [60.0, 80.0, 100.0, 120.0];
24
25const SCORE_SPECTRA: u64 = 8;
29
30pub(crate) fn xo_bytes(set: &MeasurementSet, config: &EngineConfig) -> (u64, u64) {
39 let setup = Setup::new(config);
40 let shared = measurement::spectra_bytes(set)
41 .saturating_mul(3)
42 .saturating_add(measurement::build_scratch_bytes(setup.sub_filters.len()));
43 let design = DesignBytes::new(set, config, &setup, false);
44 let scoring = (2 * measurement::cells(set, Some(&config.subs))
45 + config.subs.len() as u64
46 + SCORE_SPECTRA)
47 .saturating_mul(measurement::SPECTRUM_BYTES);
48 (
49 shared,
50 design
51 .retained
52 .saturating_add(design.channel)
53 .saturating_add(scoring),
54 )
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct XoScores {
60 pub flat: f64,
62 pub gd: f64,
64 pub late: f64,
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct XoRow {
71 pub fc: f64,
73 pub scores: XoScores,
75 pub score: f64,
77}
78
79#[derive(Debug, Clone, PartialEq)]
81pub struct XoRecommendation {
82 pub candidates: Vec<f64>,
84 pub table: Vec<(String, Vec<XoRow>)>,
86 pub best: Vec<(String, f64)>,
88}
89
90pub fn xo_groups(config: &EngineConfig) -> Vec<String> {
92 config
93 .groups
94 .iter()
95 .filter(|(group, _)| !config.subs.is_empty() && group != "sub")
96 .map(|(group, _)| group.clone())
97 .collect()
98}
99
100pub fn xo_scores(
107 design: &Design,
108 measurements: &MeasurementSet,
109 config: &EngineConfig,
110) -> Vec<(String, XoScores)> {
111 let replay = &design.replay;
112 if replay.matches(measurements, config) {
113 xo_scores_prepared(
114 design,
115 replay.measurements(),
116 replay.config(),
117 replay.prepared(),
118 None,
119 )
120 } else {
121 let prepared = measurement::prepare(measurements, config, &Setup::new(config))
122 .expect("measurements belonging to the design");
123 xo_scores_prepared(design, measurements, config, &prepared, None)
124 }
125}
126
127fn check_scored_positions(set: &MeasurementSet, config: &EngineConfig) -> Result<(), EngineError> {
134 let scored = config
135 .groups
136 .iter()
137 .filter(|(group, _)| group != "sub")
138 .flat_map(|(_, roles)| roles)
139 .filter(|role| {
140 config.mains.contains(role) && set.get(config.main_position, role).is_some()
141 });
142 for role in scored {
143 for (position, measured) in &set.positions {
144 let has = |r: &str| measured.iter().any(|(m, _)| m == r);
145 if !has(role) {
146 continue;
147 }
148 if let Some(sub) = config.subs.iter().find(|sub| !has(sub)) {
149 return Err(EngineError::Input(format!(
150 "position {position} {role}: missing {sub} for crossover scoring"
151 )));
152 }
153 }
154 }
155 Ok(())
156}
157
158struct ScoreWindows {
159 short: measurement::Spectra,
160 long: measurement::Spectra,
161}
162
163impl ScoreWindows {
164 fn new(
165 measurements: &MeasurementSet,
166 prepared: &measurement::Prepared,
167 cancelled: &dyn Fn() -> bool,
168 ) -> Result<Self, EngineError> {
169 Ok(Self {
170 short: measurement::long_spectra_until(measurements, prepared, &[], 500.0, cancelled)?,
171 long: measurement::long_spectra_until(measurements, prepared, &[], 2000.0, cancelled)?,
172 })
173 }
174}
175
176fn xo_scores_prepared(
177 design: &Design,
178 measurements: &MeasurementSet,
179 config: &EngineConfig,
180 prepared: &measurement::Prepared,
181 windows: Option<&ScoreWindows>,
182) -> Vec<(String, XoScores)> {
183 let filters: Vec<_> = design
184 .chain
185 .subs
186 .iter()
187 .map(|s| {
188 (
189 s.role.clone(),
190 SubFilter {
191 hp_hz: s.hp_hz,
192 hp_type: s.hp_type,
193 lp_hz: s.lp_hz,
194 lp_type: s.lp_type,
195 peq: s.peq.clone(),
196 },
197 )
198 })
199 .collect();
200 let (short, long) = if let Some(windows) = windows {
201 (
202 measurement::filtered_spectra(&windows.short, &filters),
203 measurement::filtered_spectra(&windows.long, &filters),
204 )
205 } else {
206 (
207 measurement::long_spectra(measurements, prepared, &filters, 500.0)
208 .expect("valid design measurements"),
209 measurement::long_spectra(measurements, prepared, &filters, 2000.0)
210 .expect("valid design measurements"),
211 )
212 };
213 let f = design_freqs();
214 let target = config
215 .target
216 .sample(&f.iter().map(|v| v.max(1.0)).collect::<Vec<_>>());
217 let weights = levels::weights(short.positions(), config.main_position);
218 let mean = |v: &[f64]| pairwise_sum(v) / v.len() as f64;
219 config
220 .groups
221 .iter()
222 .filter(|(g, _)| g != "sub")
223 .filter_map(|(group, roles)| {
224 let (mut flat, mut gd, mut late) = (Vec::new(), Vec::new(), Vec::new());
225 for role in roles
226 .iter()
227 .filter(|r| design.mains.iter().any(|m| &m.role == *r))
228 {
229 let (mut total, mut weight) = (0.0, 0.0);
230 let mut reference_gd = None;
231 for (&position, &w) in short.positions().iter().zip(&weights) {
232 if short.get(position, role).is_none() {
233 continue;
234 }
235 let h = stage_responses(design, role, &short, position)
236 .expect("complete measurement position");
237 total += w * scorecard::flatness_20(&h.output, f, &target, (100.0, 400.0));
238 if position == config.main_position {
239 reference_gd = Some(scorecard::gd_span_20(&h.output, f));
240 }
241 weight += w;
242 }
243 flat.push(total / weight);
244 gd.push(reference_gd.unwrap_or_else(|| {
245 let h = stage_responses(design, role, &short, config.main_position)
246 .expect("reference main");
247 scorecard::gd_span_20(&h.output, f)
248 }));
249 let h = stage_responses(design, role, &long, config.main_position)
250 .expect("reference main");
251 late.push(late_db_20_150(&h.output));
252 }
253 (!flat.is_empty()).then(|| {
254 (
255 group.clone(),
256 XoScores {
257 flat: mean(&flat),
258 gd: mean(&gd),
259 late: mean(&late),
260 },
261 )
262 })
263 })
264 .collect()
265}
266
267pub fn pick_xo(table: &mut [(String, Vec<XoRow>)]) -> Vec<(String, f64)> {
270 table
271 .iter_mut()
272 .filter_map(|(group, rows)| {
273 let low = rows.iter().map(|r| r.scores.late).reduce(f64::min)?;
274 for r in rows.iter_mut() {
275 r.score = r.scores.flat - 0.5 * r.scores.gd - (r.scores.late - low);
276 }
277 let best = rows.iter().reduce(|a, b| {
278 if b.score > a.score || (b.score == a.score && b.fc < a.fc) {
279 b
280 } else {
281 a
282 }
283 })?;
284 Some((group.clone(), best.fc))
285 })
286 .collect()
287}
288
289pub fn recommend_xo(
293 view: &ProjectView<'_>,
294 irs: &MeasurementSet,
295 settings: &CalibrationSettings,
296 tuning: &Tuning,
297 candidates: &[f64],
298 ctx: &Context,
299) -> Result<XoRecommendation, EngineError> {
300 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
301 let mut inputs = engine_inputs(view, irs, settings)?;
302 let groups = xo_groups(&inputs.config);
303 if groups.is_empty() {
304 return Err(EngineError::Input(
305 "no subs for crossover recommendation".into(),
306 ));
307 }
308 if !candidates.is_empty() {
309 inputs = prepare_inputs(inputs, settings.sample_rate_policy, ctx)?.0;
310 }
311 let peq_derivatives = if settings.peq_analytic_derivatives {
312 PeqDerivatives::Analytic
313 } else {
314 PeqDerivatives::TwoPoint
315 };
316 let progress = ctx.stage("engine.xo_recommendation", candidates.len() as u64);
317 let (shared, candidate) = if candidates.is_empty() {
320 (0, 0)
321 } else {
322 xo_bytes(&inputs.measurements, &inputs.config)
323 };
324 let schroeder = if candidates.is_empty() {
327 levels::Schroeder::default()
328 } else {
329 levels::Schroeder::analyze(&inputs.measurements, &inputs.config, ctx)?
330 };
331 let _shared = crate::memory::reserve(ctx, shared, candidate)?;
332 let prepared = if candidates.is_empty() {
334 None
335 } else {
336 Some(measurement::prepare_until(
337 &inputs.measurements,
338 &inputs.config,
339 &Setup::new(&inputs.config),
340 &|| ctx.cancel().is_cancelled(),
341 )?)
342 };
343 if prepared.is_some() {
344 check_scored_positions(&inputs.measurements, &inputs.config)?;
345 }
346 let windows = prepared
349 .as_ref()
350 .map(|prepared| {
351 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
352 ScoreWindows::new(&inputs.measurements, prepared, &|| {
353 ctx.cancel().is_cancelled()
354 })
355 })
356 .transpose()?;
357 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
358 let trials = crate::memory::try_map(
362 ctx,
363 candidates.to_vec(),
364 shared,
365 |_| candidate,
366 |fc| {
367 let mut config = inputs.config.clone();
370 let selected: Vec<_> = groups.iter().map(|g| (g.clone(), fc)).collect();
371 config.xo = resolve_xo(&config.groups, &selected, view.project_xo);
372 let prepared = prepared.as_ref().expect("nonempty candidates");
373 let inner = ctx.child_serial();
374 let result = design_prepared_for_xo(
375 &DesignRequest {
376 measurements: &inputs.measurements,
377 config: &config,
378 tuning,
379 subs_only: false,
380 adapter_notes: &inputs.notes,
381 },
382 &inner,
383 &*production_fitters_with(inner.cancel(), peq_derivatives),
384 prepared,
385 schroeder,
386 )?;
387 let scores = xo_scores_prepared(
390 &result,
391 &inputs.measurements,
392 &config,
393 prepared,
394 windows.as_ref(),
395 );
396 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
397 progress.advance(1);
398 Ok(scores)
399 },
400 )?;
401 let mut table: Vec<_> = groups
402 .into_iter()
403 .filter_map(|g| {
404 let mut rows = Vec::new();
405 for &fc in candidates {
406 if rows.iter().any(|r: &XoRow| r.fc == fc) {
409 continue;
410 }
411 let index = candidates
412 .iter()
413 .rposition(|c| *c == fc)
414 .expect("candidate key");
415 if let Some((_, scores)) = trials[index].iter().find(|(group, _)| group == &g) {
416 rows.push(XoRow {
417 fc,
418 scores: *scores,
419 score: 0.0,
420 });
421 }
422 }
423 (!rows.is_empty()).then_some((g, rows))
424 })
425 .collect();
426 let best = pick_xo(&mut table);
427 Ok(XoRecommendation {
428 candidates: candidates.to_vec(),
429 table,
430 best,
431 })
432}
433
434#[cfg(test)]
435mod perf_tests {
436 use super::*;
437 use crate::tests::cases;
438 use std::sync::Arc;
439
440 #[test]
441 fn xo_replay_scores_match_fresh_preparation_bits() {
442 for name in ["dolby", "room_filters"] {
443 let case = crate::test_support::replay_case(name);
444 let prepared =
445 measurement::prepare(&case.measurements, &case.config, &Setup::new(&case.config))
446 .unwrap();
447 let expected = xo_scores_prepared(
448 &case.design,
449 &case.measurements,
450 &case.config,
451 &prepared,
452 None,
453 );
454 let builds = measurement::SPECTRA_BUILT.with(|count| count.get());
455 let actual = xo_scores(&case.design, &case.measurements, &case.config);
456 assert_eq!(
457 measurement::SPECTRA_BUILT.with(|count| count.get()) - builds,
458 2,
459 "scoring builds its two windows, not another preparation"
460 );
461 assert_eq!(actual.len(), expected.len());
462 for ((role, a), (expected_role, b)) in actual.iter().zip(&expected) {
463 assert_eq!(role, expected_role);
464 assert_eq!(
465 [a.flat, a.gd, a.late].map(f64::to_bits),
466 [b.flat, b.gd, b.late].map(f64::to_bits)
467 );
468 }
469 }
470 }
471
472 #[test]
473 fn xo_preparation_is_candidate_independent() {
474 for name in ["dolby", "room", "room_dba", "room_filters"] {
475 let case = cases::load(name);
476 let settings: CalibrationSettings = serde_json::from_value(case.settings).unwrap();
477 let view = ProjectView {
478 groups: &case.config.groups,
479 project_xo: &case.config.xo,
480 target: &case.config.target,
481 };
482 let base = engine_inputs(&view, &case.measurements, &settings).unwrap();
483 let prepared =
484 measurement::prepare(&base.measurements, &base.config, &Setup::new(&base.config))
485 .unwrap();
486 for fc in [0.0, 60.0, 80.0, 100.0, 120.0] {
487 let mut trial = settings.clone();
488 trial.xo = xo_groups(&base.config)
489 .into_iter()
490 .map(|g| (g, fc))
491 .collect();
492 let inputs = engine_inputs(&view, &case.measurements, &trial).unwrap();
493 assert_eq!(
494 inputs.config.xo,
495 resolve_xo(&base.config.groups, &trial.xo, view.project_xo)
496 );
497 assert_eq!(inputs.notes, base.notes);
498 let other = measurement::prepare(
499 &inputs.measurements,
500 &inputs.config,
501 &Setup::new(&inputs.config),
502 )
503 .unwrap();
504 assert_eq!(prepared.base_s.to_bits(), other.base_s.to_bits());
505 assert_eq!(prepared.arrival_s, other.arrival_s);
506 assert_eq!(prepared.align_delay_ms, other.align_delay_ms);
507 for &p in prepared.h.positions() {
508 for r in prepared.h.roles_at(p) {
509 let bits = |h: &Arc<[crate::C]>| {
510 h.iter()
511 .map(|z| (z.re.to_bits(), z.im.to_bits()))
512 .collect::<Vec<_>>()
513 };
514 assert_eq!(
515 bits(prepared.h.get(p, r).unwrap()),
516 bits(other.h.get(p, r).unwrap())
517 );
518 assert_eq!(
519 bits(prepared.h0.get(p, r).unwrap()),
520 bits(other.h0.get(p, r).unwrap())
521 );
522 }
523 }
524 }
525 }
526 }
527
528 #[test]
529 fn xo_shared_windows_preserve_filtered_bits() {
530 for name in ["dolby", "room_dba", "room_filters"] {
531 let case = cases::load(name);
532 let setup = Setup::new(&case.config);
533 let prepared = measurement::prepare(&case.measurements, &case.config, &setup).unwrap();
534 let mut filters = setup.sub_filters.clone();
535 filters.retain(|(r, _)| r != &case.config.subs[0]);
536 filters.push((
537 case.config.subs[0].clone(),
538 SubFilter {
539 hp_hz: 23.0,
540 hp_type: secs_types::chain::BandType::Lr24,
541 lp_hz: 157.0,
542 lp_type: secs_types::chain::BandType::Bw12,
543 peq: vec![(71.0, 2.5, -3.0)],
544 },
545 ));
546 for window in [500.0, 2000.0] {
547 let raw =
548 measurement::long_spectra(&case.measurements, &prepared, &[], window).unwrap();
549 let shared = measurement::filtered_spectra(&raw, &filters);
550 let own =
551 measurement::long_spectra(&case.measurements, &prepared, &filters, window)
552 .unwrap();
553 for &p in own.positions() {
554 for role in own.roles_at(p) {
555 let a = own.get(p, role).unwrap();
556 let b = shared.get(p, role).unwrap();
557 for (a, b) in a.iter().zip(b.iter()) {
558 assert_eq!(
559 (a.re.to_bits(), a.im.to_bits()),
560 (b.re.to_bits(), b.im.to_bits())
561 );
562 }
563 if !filters.iter().any(|(r, _)| r == role) {
564 assert!(Arc::ptr_eq(raw.get(p, role).unwrap(), b));
565 }
566 }
567 }
568 }
569 }
570 }
571
572 #[test]
573 fn xo_shared_preparation_preserves_candidate_bits() {
574 let case = cases::load("room_dba");
575 let prepared =
576 measurement::prepare(&case.measurements, &case.config, &Setup::new(&case.config))
577 .unwrap();
578 let ctx = Context::serial();
579 for fc in [60.0, 120.0] {
580 let mut config = case.config.clone();
581 for (_, xo) in &mut config.xo {
582 *xo = fc;
583 }
584 let req = DesignRequest {
585 measurements: &case.measurements,
586 config: &config,
587 tuning: &case.tuning,
588 subs_only: false,
589 adapter_notes: &[],
590 };
591 let fitters = crate::design::production_fitters(ctx.cancel());
592 let own = crate::design::design(&req, &ctx, &*fitters).unwrap();
593 let shared =
594 crate::design::design_prepared(&req, &ctx, &*fitters, &prepared, &|_, _| {})
595 .unwrap();
596 assert_eq!(
597 serde_json::to_vec(&own.chain).unwrap(),
598 serde_json::to_vec(&shared.chain).unwrap()
599 );
600 for (a, b) in own.mains.iter().zip(&shared.mains) {
601 for (x, y) in a.after.iter().zip(b.after.iter()) {
602 assert_eq!(
603 (x.re.to_bits(), x.im.to_bits()),
604 (y.re.to_bits(), y.im.to_bits())
605 );
606 }
607 assert!(Arc::ptr_eq(
608 shared
609 .spectra
610 .raw
611 .get(config.main_position, &b.role)
612 .unwrap(),
613 prepared.h0.get(config.main_position, &b.role).unwrap()
614 ));
615 }
616 for (a, b) in own.scores.iter().zip(&shared.scores) {
617 for (x, y) in [
618 (&a.spl_before, &b.spl_before),
619 (&a.spl_after, &b.spl_after),
620 (&a.gd_before, &b.gd_before),
621 (&a.gd_after, &b.gd_after),
622 ] {
623 assert_eq!(
624 x.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
625 y.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
626 );
627 }
628 }
629 let own_scores = xo_scores(&own, &case.measurements, &config);
630 let windows = ScoreWindows::new(&case.measurements, &prepared, &|| false).unwrap();
631 let shared_scores = xo_scores_prepared(
632 &shared,
633 &case.measurements,
634 &config,
635 &prepared,
636 Some(&windows),
637 );
638 for ((ga, a), (gb, b)) in own_scores.iter().zip(shared_scores) {
639 assert_eq!(ga, &gb);
640 assert_eq!(
641 [a.flat, a.gd, a.late].map(f64::to_bits),
642 [b.flat, b.gd, b.late].map(f64::to_bits)
643 );
644 }
645 }
646 }
647}