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