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