Skip to main content

secs_engine/
calibration.rs

1//! Resolve project calibration settings into the engine constructor surface.
2
3use std::collections::BTreeSet;
4
5use secs_types::{
6    calibration::{CalibrationSettings, SubEntry, mode_params},
7    chain::BandType,
8    target::TargetCurve,
9};
10
11use crate::{
12    EngineError,
13    input::{BundleLayout, DbaRow, EngineConfig, LayoutKind, MeasurementSet, SubManual, SubMode},
14};
15
16#[derive(Debug)]
17pub struct ProjectView<'a> {
18    pub groups: &'a [(String, Vec<String>)],
19    pub project_xo: &'a [(String, f64)],
20    pub target: &'a TargetCurve,
21}
22
23#[derive(Debug)]
24pub struct EngineInputs {
25    pub measurements: MeasurementSet,
26    pub config: EngineConfig,
27    pub notes: Vec<String>,
28}
29
30#[derive(Debug)]
31pub struct CalibrationRequest<'a> {
32    pub view: &'a ProjectView<'a>,
33    pub irs: &'a MeasurementSet,
34    pub settings: &'a CalibrationSettings,
35    pub subs_only: bool,
36}
37
38#[derive(Debug)]
39pub struct Calibration {
40    pub design: crate::Design,
41    /// The normalized measurements and config the design was made from.
42    pub measurements: MeasurementSet,
43    pub config: EngineConfig,
44    /// IRs resampled to 48 kHz by the sample-rate policy.
45    pub resampled: usize,
46}
47
48/// Resolve, normalize and design with the context's cancellable production fitters.
49pub fn calibrate(
50    req: &CalibrationRequest<'_>,
51    ctx: &secs_compute::Context,
52) -> Result<Calibration, EngineError> {
53    let derivatives = if req.settings.peq_analytic_derivatives {
54        crate::PeqDerivatives::Analytic
55    } else {
56        crate::PeqDerivatives::TwoPoint
57    };
58    calibrate_with(
59        req,
60        ctx,
61        &*crate::production_fitters_with(ctx.cancel(), derivatives),
62    )
63}
64
65/// The calibration entry point with caller-supplied fitters (oracle replay).
66pub fn calibrate_with(
67    req: &CalibrationRequest<'_>,
68    ctx: &secs_compute::Context,
69    fitters: &crate::FitterFactory<'_>,
70) -> Result<Calibration, EngineError> {
71    ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
72    let inputs = engine_inputs(req.view, req.irs, req.settings)?;
73    let (inputs, resampled) = prepare_inputs(inputs, req.settings.sample_rate_policy, ctx)?;
74    let design = crate::design(
75        &crate::DesignRequest {
76            measurements: &inputs.measurements,
77            config: &inputs.config,
78            tuning: &crate::Tuning::default(),
79            subs_only: req.subs_only,
80            adapter_notes: &inputs.notes,
81        },
82        ctx,
83        fitters,
84    )?;
85    Ok(Calibration {
86        design,
87        measurements: inputs.measurements,
88        config: inputs.config,
89        resampled,
90    })
91}
92
93pub(crate) fn prepare_inputs(
94    mut inputs: EngineInputs,
95    policy: secs_types::calibration::SampleRatePolicy,
96    ctx: &secs_compute::Context,
97) -> Result<(EngineInputs, usize), EngineError> {
98    ctx.cancel().check().map_err(|_| EngineError::Cancelled)?;
99    let resampled = inputs
100        .measurements
101        .positions
102        .iter()
103        .flat_map(|(_, roles)| roles)
104        .filter(|(_, measurement)| measurement.sample_rate != crate::grid::SR)
105        .count();
106    inputs.measurements = crate::normalize_measurements(&inputs.measurements, policy, ctx)?;
107    Ok((inputs, resampled))
108}
109
110pub fn engine_inputs(
111    view: &ProjectView<'_>,
112    irs: &MeasurementSet,
113    settings: &CalibrationSettings,
114) -> Result<EngineInputs, EngineError> {
115    let mut skipped = BTreeSet::new();
116    let mut positions = Vec::new();
117    for (position, roles) in &irs.positions {
118        let clean: Vec<_> = roles
119            .iter()
120            .filter_map(|(role, measurement)| {
121                if role.contains(':') {
122                    skipped.insert(role.clone());
123                    None
124                } else {
125                    Some((role.clone(), measurement.clone()))
126                }
127            })
128            .collect();
129        if !clean.is_empty() {
130            positions.push((*position, clean));
131        }
132    }
133    let Some(main_position) = positions.iter().map(|(position, _)| *position).min() else {
134        return Err(EngineError::Input("no measured impulse responses".into()));
135    };
136    let have: BTreeSet<_> = positions
137        .iter()
138        .find(|(position, _)| *position == main_position)
139        .expect("minimum position exists")
140        .1
141        .iter()
142        .map(|(role, _)| role.as_str())
143        .collect();
144
145    let groups: Vec<_> = view
146        .groups
147        .iter()
148        .filter_map(|(group, roles)| {
149            let roles: Vec<_> = roles
150                .iter()
151                .filter(|role| have.contains(role.as_str()))
152                .cloned()
153                .collect();
154            (!roles.is_empty()).then(|| (group.clone(), roles))
155        })
156        .collect();
157    let subs = groups
158        .iter()
159        .find(|(group, _)| group == "sub")
160        .map_or_else(Vec::new, |(_, roles)| roles.clone());
161    let mains: Vec<_> = groups
162        .iter()
163        .filter(|(group, _)| group != "sub")
164        .flat_map(|(_, roles)| roles.iter().cloned())
165        .collect();
166    if mains.is_empty() {
167        return Err(EngineError::Input("no measured main channels".into()));
168    }
169
170    let keep: BTreeSet<_> = mains.iter().chain(&subs).map(String::as_str).collect();
171    for (_, roles) in &mut positions {
172        roles.retain(|(role, _)| keep.contains(role.as_str()));
173    }
174
175    let xo = resolve_xo(&groups, &settings.xo, view.project_xo);
176    let mode = mode_params(settings.mode, settings.ultra_ms);
177    let sub_manual = settings
178        .subs_manual
179        .iter()
180        .filter(|(_, entry)| entry.mode != "auto" || sub_has_filters(entry))
181        .map(|(role, entry)| (role.clone(), manual(entry)))
182        .collect();
183    let bundle_layouts = settings
184        .bundle_layouts
185        .iter()
186        .filter_map(|(bundle, entry)| {
187            let kind = match entry.kind.as_str() {
188                "dba" => LayoutKind::Dba,
189                "multi" => LayoutKind::Multi,
190                _ => return None,
191            };
192            Some((
193                bundle.clone(),
194                BundleLayout {
195                    kind,
196                    rear_trim_ms: entry.rear_trim_ms,
197                },
198            ))
199        })
200        .collect();
201    let notes = if skipped.is_empty() {
202        Vec::new()
203    } else {
204        vec![format!(
205            "바이앰프 대역 측정 제외: {}",
206            skipped.into_iter().collect::<Vec<_>>().join(", ")
207        )]
208    };
209
210    Ok(EngineInputs {
211        measurements: MeasurementSet { positions },
212        config: EngineConfig {
213            mains,
214            subs,
215            groups,
216            target: view.target.clone(),
217            xo,
218            main_position,
219            taps: mode.taps,
220            lift_ms: mode.lift_ms,
221            max_boost_db: 10.0_f64.min(settings.boost_max_db),
222            peq_bands: mode.peq_bands,
223            max_latency_ms: Some(mode.max_latency_ms),
224            rc_range: (settings.rc_lo, settings.rc_hi),
225            low_protect: settings.low_protect,
226            room_volume_m3: settings.room_volume_m3,
227            rc_hi_basis: settings.rc_hi_basis,
228            rc_hi_transition: settings.rc_hi_transition,
229            phase_frac: mode.phase_frac,
230            sub_manual,
231            sub_main_delay_ms: settings.sub_main_delay_ms,
232            bundle_layouts,
233            infra_boost_max_db: settings.infra_boost_max_db,
234            xo_peq_on: settings.xo_peq_on,
235            xo_peq_lo_hz: settings.xo_peq_lo,
236            xo_peq_hi_hz: settings.xo_peq_hi,
237        },
238        notes,
239    })
240}
241
242pub(crate) fn resolve_xo(
243    groups: &[(String, Vec<String>)],
244    selected: &[(String, f64)],
245    project: &[(String, f64)],
246) -> Vec<(String, f64)> {
247    groups
248        .iter()
249        .filter(|(group, _)| group != "sub")
250        .map(|(group, _)| {
251            let value = selected
252                .iter()
253                .find(|(name, value)| name == group && *value != 0.0)
254                .or_else(|| {
255                    project
256                        .iter()
257                        .find(|(name, value)| name == group && *value != 0.0)
258                })
259                .map_or(80.0, |(_, value)| *value);
260            (group.clone(), value)
261        })
262        .collect()
263}
264
265fn sub_has_filters(entry: &SubEntry) -> bool {
266    entry.hp_hz != 0.0 || entry.lp_hz != 0.0 || entry.peq.iter().any(|band| band.2 != 0.0)
267}
268
269fn manual(entry: &SubEntry) -> SubManual {
270    SubManual {
271        mode: match entry.mode.as_str() {
272            "fixed" => SubMode::Fixed,
273            "bundle" => SubMode::Bundle,
274            _ => SubMode::Auto,
275        },
276        bundle: entry.bundle.clone(),
277        gain_db: entry.gain_db,
278        delay_ms: entry.delay_ms,
279        polarity: entry.polarity,
280        allpass: entry.allpass.clone(),
281        mimo_support: entry.mimo_support,
282        hp_hz: entry.hp_hz,
283        hp_type: BandType::from_name(&entry.hp_type),
284        lp_hz: entry.lp_hz,
285        lp_type: BandType::from_name(&entry.lp_type),
286        peq: entry.peq.clone(),
287        dba_row: if entry.dba_row == "rear" {
288            DbaRow::Rear
289        } else {
290            DbaRow::Front
291        },
292    }
293}
294
295#[cfg(test)]
296mod direct_tests {
297    use super::*;
298    use crate::input::{Measurement, Samples};
299    use std::sync::Arc;
300
301    fn measurement() -> Measurement {
302        Measurement {
303            samples: Samples::F32(Arc::from([1.0])),
304            sample_rate: 48_000,
305            t0: 0.0,
306        }
307    }
308
309    #[test]
310    fn excludes_biamp_and_non_reference_only_roles() {
311        let groups = vec![
312            ("front".into(), vec!["L".into(), "R".into()]),
313            ("sub".into(), vec!["SW1".into()]),
314        ];
315        let target = TargetCurve::default();
316        for (fixture, positions) in [
317            (
318                include_str!(concat!(
319                    env!("CARGO_MANIFEST_DIR"),
320                    "/../../oracle/goldens/calibration/adapter_biamp.json"
321                )),
322                vec![(
323                    1,
324                    vec![("L".into(), measurement()), ("L:LF".into(), measurement())],
325                )],
326            ),
327            (
328                include_str!(concat!(
329                    env!("CARGO_MANIFEST_DIR"),
330                    "/../../oracle/goldens/calibration/adapter_non_reference_only.json"
331                )),
332                vec![
333                    (1, vec![("L".into(), measurement())]),
334                    (
335                        2,
336                        vec![
337                            ("L".into(), measurement()),
338                            ("R".into(), measurement()),
339                            ("SW1".into(), measurement()),
340                        ],
341                    ),
342                ],
343            ),
344        ] {
345            let expected: serde_json::Value = serde_json::from_str(fixture).unwrap();
346            let expected = &expected["outputs"];
347            let out = engine_inputs(
348                &ProjectView {
349                    groups: &groups,
350                    project_xo: &[],
351                    target: &target,
352                },
353                &MeasurementSet { positions },
354                &CalibrationSettings::default(),
355            )
356            .unwrap();
357            assert_eq!(
358                serde_json::to_value(&out.config.mains).unwrap(),
359                expected["mains"]
360            );
361            assert_eq!(
362                serde_json::to_value(&out.config.subs).unwrap(),
363                expected["subs"]
364            );
365            assert_eq!(
366                serde_json::to_value(&out.config.groups).unwrap(),
367                expected["groups"]
368            );
369            assert_eq!(
370                serde_json::to_value(&out.config.xo).unwrap(),
371                expected["xo"]
372            );
373            assert_eq!(
374                out.config.main_position,
375                expected["main_position"].as_u64().unwrap() as u32
376            );
377            assert_eq!(serde_json::to_value(&out.notes).unwrap(), expected["notes"]);
378            let actual_positions: Vec<_> = out
379                .measurements
380                .positions
381                .iter()
382                .map(|(position, roles)| {
383                    (
384                        *position,
385                        roles.iter().map(|(role, _)| role).collect::<Vec<_>>(),
386                    )
387                })
388                .collect();
389            assert_eq!(
390                serde_json::to_value(actual_positions).unwrap(),
391                expected["positions"]
392            );
393        }
394    }
395}