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            dip_limit: settings.dip_limit,
259            room_volume_m3: settings.room_volume_m3,
260            rc_hi_basis: settings.rc_hi_basis,
261            rc_hi_transition: settings.rc_hi_transition,
262            phase_frac: mode.phase_frac,
263            sub_manual,
264            sub_main_delay_ms: settings.sub_main_delay_ms,
265            bundle_layouts,
266            infra_boost_max_db: settings.infra_boost_max_db,
267            xo_peq_on: settings.xo_peq_on,
268            xo_peq_lo_hz: settings.xo_peq_lo,
269            xo_peq_hi_hz: settings.xo_peq_hi,
270        },
271        notes,
272    })
273}
274
275/// The extra bands a calculation uses (ADR 0024). Settings a request carries
276/// reach here unchecked, so this applies the project reader's rule: a band
277/// must be finite with `20 <= lo < hi <= 20000`, the rest are dropped, and the
278/// first four by lower edge are kept.
279fn rc_bands(bands: &[RcBand]) -> Vec<(f64, f64)> {
280    let limits = CALIBRATION_LIMITS.rc_band.0..=CALIBRATION_LIMITS.rc_band.1;
281    let mut bands: Vec<_> = bands
282        .iter()
283        .filter(|band| {
284            band.lo_hz.is_finite()
285                && band.hi_hz.is_finite()
286                && limits.contains(&band.lo_hz)
287                && limits.contains(&band.hi_hz)
288                && band.lo_hz < band.hi_hz
289        })
290        .map(|band| (band.lo_hz, band.hi_hz))
291        .collect();
292    bands.sort_by(|left, right| left.0.total_cmp(&right.0));
293    bands.truncate(CALIBRATION_LIMITS.rc_bands_max.1 as usize);
294    bands
295}
296
297pub(crate) fn resolve_xo(
298    groups: &[(String, Vec<String>)],
299    selected: &[(String, f64)],
300    project: &[(String, f64)],
301) -> Vec<(String, f64)> {
302    groups
303        .iter()
304        .filter(|(group, _)| group != "sub")
305        .map(|(group, _)| {
306            let value = selected
307                .iter()
308                .find(|(name, value)| name == group && *value != 0.0)
309                .or_else(|| {
310                    project
311                        .iter()
312                        .find(|(name, value)| name == group && *value != 0.0)
313                })
314                .map_or(80.0, |(_, value)| *value);
315            (group.clone(), value)
316        })
317        .collect()
318}
319
320fn sub_has_filters(entry: &SubEntry) -> bool {
321    entry.hp_hz != 0.0 || entry.lp_hz != 0.0 || entry.peq.iter().any(|band| band.2 != 0.0)
322}
323
324fn manual(entry: &SubEntry) -> SubManual {
325    SubManual {
326        mode: match entry.mode.as_str() {
327            "fixed" => SubMode::Fixed,
328            "bundle" => SubMode::Bundle,
329            _ => SubMode::Auto,
330        },
331        bundle: entry.bundle.clone(),
332        gain_db: entry.gain_db,
333        delay_ms: entry.delay_ms,
334        polarity: entry.polarity,
335        allpass: entry.allpass.clone(),
336        mimo_support: entry.mimo_support,
337        hp_hz: entry.hp_hz,
338        hp_type: BandType::from_name(&entry.hp_type),
339        lp_hz: entry.lp_hz,
340        lp_type: BandType::from_name(&entry.lp_type),
341        peq: entry.peq.clone(),
342        dba_row: if entry.dba_row == "rear" {
343            DbaRow::Rear
344        } else {
345            DbaRow::Front
346        },
347    }
348}
349
350#[cfg(test)]
351mod direct_tests {
352    use super::*;
353    use crate::input::{Measurement, Samples};
354    use std::sync::Arc;
355
356    fn measurement() -> Measurement {
357        Measurement {
358            samples: Samples::F32(Arc::from([1.0])),
359            sample_rate: 48_000,
360            t0: 0.0,
361        }
362    }
363
364    #[test]
365    fn excludes_biamp_and_non_reference_only_roles() {
366        let groups = vec![
367            ("front".into(), vec!["L".into(), "R".into()]),
368            ("sub".into(), vec!["SW1".into()]),
369        ];
370        let target = TargetCurve::default();
371        for (fixture, positions) in [
372            (
373                include_str!(concat!(
374                    env!("CARGO_MANIFEST_DIR"),
375                    "/../../oracle/goldens/calibration/adapter_biamp.json"
376                )),
377                vec![(
378                    1,
379                    vec![("L".into(), measurement()), ("L:LF".into(), measurement())],
380                )],
381            ),
382            (
383                include_str!(concat!(
384                    env!("CARGO_MANIFEST_DIR"),
385                    "/../../oracle/goldens/calibration/adapter_non_reference_only.json"
386                )),
387                vec![
388                    (1, vec![("L".into(), measurement())]),
389                    (
390                        2,
391                        vec![
392                            ("L".into(), measurement()),
393                            ("R".into(), measurement()),
394                            ("SW1".into(), measurement()),
395                        ],
396                    ),
397                ],
398            ),
399        ] {
400            let expected: serde_json::Value = serde_json::from_str(fixture).unwrap();
401            let expected = &expected["outputs"];
402            let out = engine_inputs(
403                &ProjectView {
404                    groups: &groups,
405                    project_xo: &[],
406                    target: &target,
407                    inverted: &[],
408                },
409                &MeasurementSet { positions },
410                &CalibrationSettings::default(),
411            )
412            .unwrap();
413            assert_eq!(
414                serde_json::to_value(&out.config.mains).unwrap(),
415                expected["mains"]
416            );
417            assert_eq!(
418                serde_json::to_value(&out.config.subs).unwrap(),
419                expected["subs"]
420            );
421            assert_eq!(
422                serde_json::to_value(&out.config.groups).unwrap(),
423                expected["groups"]
424            );
425            assert_eq!(
426                serde_json::to_value(&out.config.xo).unwrap(),
427                expected["xo"]
428            );
429            assert_eq!(
430                out.config.main_position,
431                expected["main_position"].as_u64().unwrap() as u32
432            );
433            assert_eq!(serde_json::to_value(&out.notes).unwrap(), expected["notes"]);
434            let actual_positions: Vec<_> = out
435                .measurements
436                .positions
437                .iter()
438                .map(|(position, roles)| {
439                    (
440                        *position,
441                        roles.iter().map(|(role, _)| role).collect::<Vec<_>>(),
442                    )
443                })
444                .collect();
445            assert_eq!(
446                serde_json::to_value(actual_positions).unwrap(),
447                expected["positions"]
448            );
449        }
450    }
451}