Skip to main content

secs_engine/
xo.rs

1//! Crossover recommendation from immutable designs and measured responses.
2//!
3//! Upstream: `calibration.xo_groups`, `xo_scores`, `pick_xo`, `recommend_xo`.
4
5use 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
22/// Default crossover candidates in Hz, in upstream order.
23pub const XO_CANDIDATES: [f64; 4] = [60.0, 80.0, 100.0, 120.0];
24
25/// Arrays one XO score evaluation keeps alive, in spectra: `stage_responses`'
26/// delay, `top`, the sub `sum` and one sub term, the `lr4` HP and LP,
27/// `combined` and `output`.
28const SCORE_SPECTRA: u64 = 8;
29
30/// What `recommend_xo` reserves (TD-24), as `(shared, candidate)`.
31///
32/// `shared` is held from before `measurement::prepare` to the end: its `h`
33/// and the unfiltered `ScoreWindows` `short` and `long` (three spectra per IR
34/// cell) plus the build scratch. Each candidate reserves `candidate` before
35/// it starts: its design on a serial child context (`DesignBytes::retained`
36/// and one RC channel at a time) and its scoring (`filtered_spectra` copies of
37/// every sub cell in both windows, their filter responses, `SCORE_SPECTRA`).
38pub(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/// Group-averaged crossover quality before the candidate-relative penalty.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct XoScores {
60    /// Position-weighted percentage within ±1 dB, 20–200 Hz (higher is better).
61    pub flat: f64,
62    /// Reference-position group-delay span in ms, 20–100 Hz (lower is better).
63    pub gd: f64,
64    /// Late/early energy ratio in dB, 20–150 Hz (lower is better).
65    pub late: f64,
66}
67
68/// One candidate's metrics and combined score.
69#[derive(Debug, Clone, PartialEq)]
70pub struct XoRow {
71    /// Crossover frequency in Hz.
72    pub fc: f64,
73    /// Metrics averaged over the group's measured main channels.
74    pub scores: XoScores,
75    /// Flatness minus GD and relative late-energy penalties (higher is better).
76    pub score: f64,
77}
78
79/// Ordered crossover comparison and the best candidate for each group.
80#[derive(Debug, Clone, PartialEq)]
81pub struct XoRecommendation {
82    /// Requested candidates in input order.
83    pub candidates: Vec<f64>,
84    /// Groups in configuration order, rows in candidate order.
85    pub table: Vec<(String, Vec<XoRow>)>,
86    /// Selected frequency per group; equal scores prefer the lower frequency.
87    pub best: Vec<(String, f64)>,
88}
89
90/// Main groups in configuration order, or no groups when there are no subs.
91pub 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
100/// Replays the final chain through 500 ms and 2 s measurement windows.
101///
102/// `measurements` and `config` must be the valid inputs used for `design`.
103/// Panics if they are invalid or a scored main's required sub measurement is missing,
104/// as upstream does; recommendation checks both (`check_scored_positions`) and
105/// returns input errors before reaching here.
106pub 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
127/// What `xo_scores` replays (TD-48): every main it scores (a group member
128/// measured at the reference) at every position where that main was measured,
129/// through `stage_responses`, which needs every sub there whenever the main has
130/// a crossover. BA and RC only use complete positions, so a design succeeds
131/// without them. Recommendation gives every group a nonzero crossover, so each
132/// such position must hold every sub; the first one that does not is named.
133fn 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
267/// Writes candidate-relative scores, selecting the lower Hz on exact ties.
268/// Empty groups are omitted from the returned winners.
269pub 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
289/// Designs independent XO candidates on the context's ordered map, then scores them.
290/// Progress counts finished candidates, not their internal design stages. Cancellation
291/// is shared with every production fitter; no process pool is created.
292pub 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    // Reserve the shared preparation and score windows before allocating them
318    // (TD-24); each batch of candidates reserves its designs out of the rest.
319    let (shared, candidate) = if candidates.is_empty() {
320        (0, 0)
321    } else {
322        xo_bytes(&inputs.measurements, &inputs.config)
323    };
324    // Schroeder does not depend on XO. Analyze before holding shared/candidate
325    // permits, never from a Rayon task that might wait on its own batch.
326    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    // Empty candidate lists retain their old no-preparation behaviour.
333    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    // Window lengths, taper, onset and time origin are all XO-independent.
347    // Applying final sub filters here would not be: BA can change them.
348    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    // A candidate's permit is taken on this thread for its whole batch, never
359    // inside a pool task (TD-23); its design runs on a serial child of this
360    // job, so it reserves nothing more and waits on no pool work.
361    let trials = crate::memory::try_map(
362        ctx,
363        candidates.to_vec(),
364        shared,
365        |_| candidate,
366        |fc| {
367            // engine_inputs depends on the trial only through settings.xo.
368            // Keep its zero-value fallback and ordering in the same helper.
369            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            // Only the unfiltered windows are shared. The final multiplication
388            // by BA-derived sub filters still happens per candidate, in order.
389            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                // Upstream's frequency-keyed dictionaries collapse duplicates,
407                // retaining first insertion order and the last trial's value.
408                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}