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 _shared = crate::memory::reserve(ctx, shared, candidate)?;
325 let prepared = if candidates.is_empty() {
327 None
328 } else {
329 Some(measurement::prepare_until(
330 &inputs.measurements,
331 &inputs.config,
332 &Setup::new(&inputs.config),
333 &|| ctx.cancel().is_cancelled(),
334 )?)
335 };
336 if prepared.is_some() {
337 check_scored_positions(&inputs.measurements, &inputs.config)?;
338 }
339 let windows = prepared
342 .as_ref()
343 .map(|prepared| {
344 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
345 ScoreWindows::new(&inputs.measurements, prepared, &|| {
346 ctx.cancel().is_cancelled()
347 })
348 })
349 .transpose()?;
350 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
351 let trials = crate::memory::try_map(
355 ctx,
356 candidates.to_vec(),
357 shared,
358 |_| candidate,
359 |fc| {
360 let mut config = inputs.config.clone();
363 let selected: Vec<_> = groups.iter().map(|g| (g.clone(), fc)).collect();
364 config.xo = resolve_xo(&config.groups, &selected, view.project_xo);
365 let prepared = prepared.as_ref().expect("nonempty candidates");
366 let inner = ctx.child_serial();
367 let result = design_prepared_for_xo(
368 &DesignRequest {
369 measurements: &inputs.measurements,
370 config: &config,
371 tuning,
372 subs_only: false,
373 adapter_notes: &inputs.notes,
374 },
375 &inner,
376 &*production_fitters_with(inner.cancel(), peq_derivatives),
377 prepared,
378 )?;
379 let scores = xo_scores_prepared(
382 &result,
383 &inputs.measurements,
384 &config,
385 prepared,
386 windows.as_ref(),
387 );
388 ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
389 progress.advance(1);
390 Ok(scores)
391 },
392 )?;
393 let mut table: Vec<_> = groups
394 .into_iter()
395 .filter_map(|g| {
396 let mut rows = Vec::new();
397 for &fc in candidates {
398 if rows.iter().any(|r: &XoRow| r.fc == fc) {
401 continue;
402 }
403 let index = candidates
404 .iter()
405 .rposition(|c| *c == fc)
406 .expect("candidate key");
407 if let Some((_, scores)) = trials[index].iter().find(|(group, _)| group == &g) {
408 rows.push(XoRow {
409 fc,
410 scores: *scores,
411 score: 0.0,
412 });
413 }
414 }
415 (!rows.is_empty()).then_some((g, rows))
416 })
417 .collect();
418 let best = pick_xo(&mut table);
419 Ok(XoRecommendation {
420 candidates: candidates.to_vec(),
421 table,
422 best,
423 })
424}
425
426#[cfg(test)]
427mod perf_tests {
428 use super::*;
429 use crate::tests::cases;
430 use std::sync::Arc;
431
432 #[test]
433 fn xo_replay_scores_match_fresh_preparation_bits() {
434 for name in ["dolby", "room_filters"] {
435 let case = crate::test_support::replay_case(name);
436 let prepared =
437 measurement::prepare(&case.measurements, &case.config, &Setup::new(&case.config))
438 .unwrap();
439 let expected = xo_scores_prepared(
440 &case.design,
441 &case.measurements,
442 &case.config,
443 &prepared,
444 None,
445 );
446 let builds = measurement::SPECTRA_BUILT.with(|count| count.get());
447 let actual = xo_scores(&case.design, &case.measurements, &case.config);
448 assert_eq!(
449 measurement::SPECTRA_BUILT.with(|count| count.get()) - builds,
450 2,
451 "scoring builds its two windows, not another preparation"
452 );
453 assert_eq!(actual.len(), expected.len());
454 for ((role, a), (expected_role, b)) in actual.iter().zip(&expected) {
455 assert_eq!(role, expected_role);
456 assert_eq!(
457 [a.flat, a.gd, a.late].map(f64::to_bits),
458 [b.flat, b.gd, b.late].map(f64::to_bits)
459 );
460 }
461 }
462 }
463
464 #[test]
465 fn xo_preparation_is_candidate_independent() {
466 for name in ["dolby", "room", "room_dba", "room_filters"] {
467 let case = cases::load(name);
468 let settings: CalibrationSettings = serde_json::from_value(case.settings).unwrap();
469 let view = ProjectView {
470 groups: &case.config.groups,
471 project_xo: &case.config.xo,
472 target: &case.config.target,
473 };
474 let base = engine_inputs(&view, &case.measurements, &settings).unwrap();
475 let prepared =
476 measurement::prepare(&base.measurements, &base.config, &Setup::new(&base.config))
477 .unwrap();
478 for fc in [0.0, 60.0, 80.0, 100.0, 120.0] {
479 let mut trial = settings.clone();
480 trial.xo = xo_groups(&base.config)
481 .into_iter()
482 .map(|g| (g, fc))
483 .collect();
484 let inputs = engine_inputs(&view, &case.measurements, &trial).unwrap();
485 assert_eq!(
486 inputs.config.xo,
487 resolve_xo(&base.config.groups, &trial.xo, view.project_xo)
488 );
489 assert_eq!(inputs.notes, base.notes);
490 let other = measurement::prepare(
491 &inputs.measurements,
492 &inputs.config,
493 &Setup::new(&inputs.config),
494 )
495 .unwrap();
496 assert_eq!(prepared.base_s.to_bits(), other.base_s.to_bits());
497 assert_eq!(prepared.arrival_s, other.arrival_s);
498 assert_eq!(prepared.align_delay_ms, other.align_delay_ms);
499 for &p in prepared.h.positions() {
500 for r in prepared.h.roles_at(p) {
501 let bits = |h: &Arc<[crate::C]>| {
502 h.iter()
503 .map(|z| (z.re.to_bits(), z.im.to_bits()))
504 .collect::<Vec<_>>()
505 };
506 assert_eq!(
507 bits(prepared.h.get(p, r).unwrap()),
508 bits(other.h.get(p, r).unwrap())
509 );
510 assert_eq!(
511 bits(prepared.h0.get(p, r).unwrap()),
512 bits(other.h0.get(p, r).unwrap())
513 );
514 }
515 }
516 }
517 }
518 }
519
520 #[test]
521 fn xo_shared_windows_preserve_filtered_bits() {
522 for name in ["dolby", "room_dba", "room_filters"] {
523 let case = cases::load(name);
524 let setup = Setup::new(&case.config);
525 let prepared = measurement::prepare(&case.measurements, &case.config, &setup).unwrap();
526 let mut filters = setup.sub_filters.clone();
527 filters.retain(|(r, _)| r != &case.config.subs[0]);
528 filters.push((
529 case.config.subs[0].clone(),
530 SubFilter {
531 hp_hz: 23.0,
532 hp_type: secs_types::chain::BandType::Lr24,
533 lp_hz: 157.0,
534 lp_type: secs_types::chain::BandType::Bw12,
535 peq: vec![(71.0, 2.5, -3.0)],
536 },
537 ));
538 for window in [500.0, 2000.0] {
539 let raw =
540 measurement::long_spectra(&case.measurements, &prepared, &[], window).unwrap();
541 let shared = measurement::filtered_spectra(&raw, &filters);
542 let own =
543 measurement::long_spectra(&case.measurements, &prepared, &filters, window)
544 .unwrap();
545 for &p in own.positions() {
546 for role in own.roles_at(p) {
547 let a = own.get(p, role).unwrap();
548 let b = shared.get(p, role).unwrap();
549 for (a, b) in a.iter().zip(b.iter()) {
550 assert_eq!(
551 (a.re.to_bits(), a.im.to_bits()),
552 (b.re.to_bits(), b.im.to_bits())
553 );
554 }
555 if !filters.iter().any(|(r, _)| r == role) {
556 assert!(Arc::ptr_eq(raw.get(p, role).unwrap(), b));
557 }
558 }
559 }
560 }
561 }
562 }
563
564 #[test]
565 fn xo_shared_preparation_preserves_candidate_bits() {
566 let case = cases::load("room_dba");
567 let prepared =
568 measurement::prepare(&case.measurements, &case.config, &Setup::new(&case.config))
569 .unwrap();
570 let ctx = Context::serial();
571 for fc in [60.0, 120.0] {
572 let mut config = case.config.clone();
573 for (_, xo) in &mut config.xo {
574 *xo = fc;
575 }
576 let req = DesignRequest {
577 measurements: &case.measurements,
578 config: &config,
579 tuning: &case.tuning,
580 subs_only: false,
581 adapter_notes: &[],
582 };
583 let fitters = crate::design::production_fitters(ctx.cancel());
584 let own = crate::design::design(&req, &ctx, &*fitters).unwrap();
585 let shared =
586 crate::design::design_prepared(&req, &ctx, &*fitters, &prepared, &|_, _| {})
587 .unwrap();
588 assert_eq!(
589 serde_json::to_vec(&own.chain).unwrap(),
590 serde_json::to_vec(&shared.chain).unwrap()
591 );
592 for (a, b) in own.mains.iter().zip(&shared.mains) {
593 for (x, y) in a.after.iter().zip(b.after.iter()) {
594 assert_eq!(
595 (x.re.to_bits(), x.im.to_bits()),
596 (y.re.to_bits(), y.im.to_bits())
597 );
598 }
599 assert!(Arc::ptr_eq(
600 shared
601 .spectra
602 .raw
603 .get(config.main_position, &b.role)
604 .unwrap(),
605 prepared.h0.get(config.main_position, &b.role).unwrap()
606 ));
607 }
608 for (a, b) in own.scores.iter().zip(&shared.scores) {
609 for (x, y) in [
610 (&a.spl_before, &b.spl_before),
611 (&a.spl_after, &b.spl_after),
612 (&a.gd_before, &b.gd_before),
613 (&a.gd_after, &b.gd_after),
614 ] {
615 assert_eq!(
616 x.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
617 y.iter().map(|v| v.to_bits()).collect::<Vec<_>>()
618 );
619 }
620 }
621 let own_scores = xo_scores(&own, &case.measurements, &config);
622 let windows = ScoreWindows::new(&case.measurements, &prepared, &|| false).unwrap();
623 let shared_scores = xo_scores_prepared(
624 &shared,
625 &case.measurements,
626 &config,
627 &prepared,
628 Some(&windows),
629 );
630 for ((ga, a), (gb, b)) in own_scores.iter().zip(shared_scores) {
631 assert_eq!(ga, &gb);
632 assert_eq!(
633 [a.flat, a.gd, a.late].map(f64::to_bits),
634 [b.flat, b.gd, b.late].map(f64::to_bits)
635 );
636 }
637 }
638 }
639}