Skip to main content

secs_types/
calibration.rs

1//! User-visible calibration settings and their mode-derived engine values.
2//!
3//! This is the typed form of `calibration.py::CalibrationSettings`. Ordered
4//! pairs are used for Python dictionaries so insertion order survives a
5//! `.secs` round trip; serde still exposes them as JSON objects.
6
7use std::{collections::BTreeMap, fmt::Write as _};
8
9use serde::{Deserialize, Serialize};
10use ts_rs::TS;
11
12use crate::rules::CALIBRATION_LIMITS;
13
14/// The ultra mode's latency budget range (ms).
15const ULTRA_MS: (f64, f64) = CALIBRATION_LIMITS.ultra_ms;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
18#[serde(rename_all = "lowercase")]
19#[ts(export)]
20pub enum Mode {
21    Zero,
22    Mixed,
23    Linear,
24    Ultra,
25}
26
27/// Evidence used to choose an automatic room-correction ceiling (ADR 0021).
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
29#[serde(rename_all = "snake_case")]
30#[ts(export)]
31pub enum RcHiBasis {
32    #[default]
33    Strict,
34    Lenient,
35    Agreement,
36}
37
38impl RcHiBasis {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Strict => "strict",
42            Self::Lenient => "lenient",
43            Self::Agreement => "agreement",
44        }
45    }
46}
47
48fn default_rc_hi_transition() -> u8 {
49    1
50}
51
52/// How non-48 kHz measurement responses are prepared for calculation.
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
54#[serde(rename_all = "snake_case")]
55#[ts(export)]
56pub enum SampleRatePolicy {
57    /// Preserve the existing validation error for mismatched measurements.
58    #[default]
59    Reject,
60    /// Resample calculation copies to 48 kHz, leaving stored measurements intact.
61    Resample,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
65#[ts(export)]
66pub struct SubEntry {
67    pub mode: String,
68    pub bundle: String,
69    pub gain_db: f64,
70    pub delay_ms: f64,
71    pub polarity: i8,
72    pub allpass: Vec<(f64, f64)>,
73    pub mimo_support: bool,
74    pub hp_hz: f64,
75    pub lp_hz: f64,
76    pub peq: Vec<(f64, f64, f64)>,
77    pub hp_type: String,
78    pub lp_type: String,
79    pub dba_row: String,
80}
81
82impl Default for SubEntry {
83    fn default() -> Self {
84        Self {
85            mode: "auto".into(),
86            bundle: "A".into(),
87            gain_db: 0.0,
88            delay_ms: 0.0,
89            polarity: 1,
90            allpass: Vec::new(),
91            mimo_support: true,
92            hp_hz: 0.0,
93            lp_hz: 0.0,
94            peq: Vec::new(),
95            hp_type: "LR24".into(),
96            lp_type: "LR24".into(),
97            dba_row: "front".into(),
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
103#[ts(export)]
104pub struct BundleLayoutEntry {
105    pub kind: String,
106    pub rear_trim_ms: f64,
107}
108
109impl Default for BundleLayoutEntry {
110    fn default() -> Self {
111        Self {
112            kind: "values".into(),
113            rear_trim_ms: 0.0,
114        }
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
119#[ts(export)]
120pub struct CalibrationSettings {
121    pub mode: Mode,
122    #[serde(default)]
123    pub sample_rate_policy: SampleRatePolicy,
124    #[serde(default)]
125    pub low_protect: bool,
126    pub rc_lo: f64,
127    pub rc_hi: Option<f64>,
128    #[serde(default)]
129    pub rc_hi_basis: RcHiBasis,
130    #[serde(default = "default_rc_hi_transition")]
131    pub rc_hi_transition: u8,
132    pub room_volume_m3: Option<f64>,
133    pub room_dims_m: Option<[f64; 3]>,
134    pub mimo_on: bool,
135    pub fsiso: f64,
136    pub mimo_low: Option<f64>,
137    #[ts(type = "number")]
138    pub mimo_max_supports: i64,
139    pub mimo_fir: bool,
140    /// Experimental: PEQ fits use closed-form Jacobians (results leave SECS 2).
141    #[serde(default)]
142    pub peq_analytic_derivatives: bool,
143    /// Experimental: MIMO IIR fits use closed-form derivatives (results leave SECS 2).
144    #[serde(default)]
145    pub mimo_iir_analytic_derivatives: bool,
146    #[serde(with = "ordered_f64_map")]
147    #[ts(as = "BTreeMap<String, f64>")]
148    pub xo: Vec<(String, f64)>,
149    #[serde(with = "ordered_f64_map")]
150    #[ts(as = "BTreeMap<String, f64>")]
151    pub xo_rec: Vec<(String, f64)>,
152    pub ultra_ms: f64,
153    #[serde(with = "ordered_sub_map")]
154    #[ts(as = "BTreeMap<String, SubEntry>")]
155    pub subs_manual: Vec<(String, SubEntry)>,
156    pub sub_main_delay_ms: f64,
157    #[serde(with = "ordered_layout_map")]
158    #[ts(as = "BTreeMap<String, BundleLayoutEntry>")]
159    pub bundle_layouts: Vec<(String, BundleLayoutEntry)>,
160    pub infra_boost_max_db: f64,
161    pub xo_peq_on: bool,
162    pub boost_max_db: f64,
163    pub xo_peq_lo: f64,
164    pub xo_peq_hi: f64,
165}
166
167impl Default for CalibrationSettings {
168    fn default() -> Self {
169        Self {
170            mode: Mode::Mixed,
171            sample_rate_policy: SampleRatePolicy::Reject,
172            low_protect: false,
173            rc_lo: 20.0,
174            rc_hi: None,
175            rc_hi_basis: RcHiBasis::Strict,
176            rc_hi_transition: 1,
177            room_volume_m3: None,
178            room_dims_m: None,
179            mimo_on: false,
180            fsiso: 150.0,
181            mimo_low: None,
182            mimo_max_supports: 0,
183            // Beta 1.3.0: MIMO supports start as FIR in a new project.
184            mimo_fir: true,
185            peq_analytic_derivatives: false,
186            mimo_iir_analytic_derivatives: false,
187            xo: Vec::new(),
188            xo_rec: Vec::new(),
189            ultra_ms: 200.0,
190            subs_manual: Vec::new(),
191            sub_main_delay_ms: 0.0,
192            bundle_layouts: Vec::new(),
193            infra_boost_max_db: 24.0,
194            xo_peq_on: true,
195            boost_max_db: 10.0,
196            xo_peq_lo: 0.0,
197            xo_peq_hi: 0.0,
198        }
199    }
200}
201
202impl CalibrationSettings {
203    /// Korean user-visible name of the selected calibration mode.
204    pub fn label(&self) -> String {
205        mode_label(self.mode, Some(self.ultra_ms))
206    }
207
208    /// Stable staleness fingerprint with the same inclusions and exclusions as upstream.
209    pub fn engine_key(&self) -> String {
210        let mut out = String::new();
211        write!(&mut out, "{:?}|{}|", self.mode, number(self.rc_lo)).unwrap();
212        optional_number(&mut out, self.rc_hi);
213        out.push('|');
214        optional_number(&mut out, self.room_volume_m3);
215        out.push('|');
216        sorted_f64_map(&mut out, &self.xo);
217        out.push('|');
218        if self.mode == Mode::Ultra {
219            out.push_str(&number(self.ultra_ms));
220        } else {
221            out.push('-');
222        }
223        out.push('|');
224        out.push_str(&self.manual_key());
225        write!(
226            &mut out,
227            "|{}|{}|{}|{}|{}",
228            number(self.infra_boost_max_db),
229            self.xo_peq_on,
230            number(self.xo_peq_lo),
231            number(self.xo_peq_hi),
232            number(self.boost_max_db)
233        )
234        .unwrap();
235        // Preserve upstream keys for strict ×1, including existing caches.
236        if self.rc_hi_basis != RcHiBasis::Strict {
237            write!(&mut out, "|rc_hi_basis={}", self.rc_hi_basis.as_str()).unwrap();
238        }
239        if self.rc_hi_transition != 1 {
240            write!(&mut out, "|rc_hi_transition={}", self.rc_hi_transition).unwrap();
241        }
242        // Keep existing cache fingerprints unchanged for the default policy.
243        if self.sample_rate_policy == SampleRatePolicy::Resample {
244            out.push_str("|sample_rate_policy=resample");
245        }
246        if self.low_protect {
247            out.push_str("|low_protect");
248        }
249        if self.peq_analytic_derivatives {
250            out.push_str("|peq_analytic_derivatives");
251        }
252        out
253    }
254
255    /// Stable fingerprint of the sub settings which affect the correction engine.
256    pub fn manual_key(&self) -> String {
257        let mut entries: Vec<_> = self.subs_manual.iter().collect();
258        entries.sort_by(|a, b| a.0.cmp(&b.0));
259        let mut out = String::new();
260        for (role, entry) in entries {
261            let filters = filter_key(entry);
262            if entry.mode == "auto" {
263                if entry.hp_hz != 0.0 || entry.lp_hz != 0.0 || !entry.peq.is_empty() {
264                    write!(&mut out, "{role}:auto:{filters};").unwrap();
265                }
266                continue;
267            }
268            let layout = if entry.mode == "bundle" {
269                self.bundle_layouts
270                    .iter()
271                    .find(|(name, _)| name == &entry.bundle)
272                    .map_or("values", |(_, layout)| layout.kind.as_str())
273            } else {
274                "values"
275            };
276            if layout != "values" {
277                let row = if layout == "dba" {
278                    entry.dba_row.as_str()
279                } else {
280                    ""
281                };
282                let trim = if layout == "dba" {
283                    self.bundle_layouts
284                        .iter()
285                        .find(|(name, _)| name == &entry.bundle)
286                        .map_or(0.0, |(_, value)| value.rear_trim_ms)
287                } else {
288                    0.0
289                };
290                write!(
291                    &mut out,
292                    "{role}:bundle:{}:{layout}:{row}:{}:{filters};",
293                    entry.bundle,
294                    number(trim)
295                )
296                .unwrap();
297            } else {
298                let bundle = if entry.mode == "bundle" {
299                    entry.bundle.as_str()
300                } else {
301                    ""
302                };
303                write!(
304                    &mut out,
305                    "{role}:{}:{bundle}:{}:{}:{}:",
306                    entry.mode,
307                    number(entry.gain_db),
308                    number(entry.delay_ms),
309                    entry.polarity
310                )
311                .unwrap();
312                for &(frequency, q) in &entry.allpass {
313                    write!(&mut out, "{},{};", number(frequency), number(q)).unwrap();
314                }
315                write!(&mut out, ":{filters};").unwrap();
316            }
317        }
318        let fixed = self.subs_manual.iter().any(|(_, e)| e.mode == "fixed");
319        if fixed {
320            write!(&mut out, "|{}", number(self.sub_main_delay_ms)).unwrap();
321        } else {
322            out.push_str("|-");
323        }
324        out
325    }
326
327    /// Stable fingerprint of settings used only by MIMO.
328    pub fn mimo_key(&self) -> String {
329        let mut excluded: Vec<_> = self
330            .subs_manual
331            .iter()
332            .filter(|(_, entry)| !entry.mimo_support)
333            .map(|(role, _)| role.as_str())
334            .collect();
335        excluded.sort_unstable();
336        let mut out = format!("{}|", number(self.fsiso));
337        optional_number(&mut out, self.mimo_low);
338        write!(
339            &mut out,
340            "|{}|{}|{}",
341            excluded.join(","),
342            self.mimo_max_supports,
343            self.mimo_fir
344        )
345        .unwrap();
346        if self.mimo_iir_analytic_derivatives {
347            out.push_str("|mimo_iir_analytic_derivatives");
348        }
349        out
350    }
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
354#[ts(export)]
355pub struct ModeParams {
356    pub taps: usize,
357    pub lift_ms: f64,
358    pub peq_bands: Option<usize>,
359    pub phase_frac: f64,
360    pub max_latency_ms: f64,
361}
362
363/// Stable ASCII correction-mode tag used by export names and metadata.
364pub fn mode_tag(mode: Mode) -> &'static str {
365    match mode {
366        Mode::Zero => "zero",
367        Mode::Mixed => "mixed",
368        Mode::Linear => "linear",
369        Mode::Ultra => "ultra",
370    }
371}
372
373/// Korean user-visible mode name, including the clamped ultra latency when supplied.
374pub fn mode_label(mode: Mode, ultra_ms: Option<f64>) -> String {
375    let name = match mode {
376        Mode::Zero => "최소 지연 모드",
377        Mode::Mixed => "저지연 모드",
378        Mode::Linear => "표준 모드",
379        Mode::Ultra => "초고지연 모드",
380    };
381    if mode == Mode::Ultra
382        && let Some(ms) = ultra_ms
383    {
384        let (lo, hi) = ULTRA_MS;
385        format!("{name} {} ms", python_g(ms.clamp(lo, hi)))
386    } else {
387        name.into()
388    }
389}
390
391fn python_g(value: f64) -> String {
392    if value == 0.0 {
393        return if value.is_sign_negative() { "-0" } else { "0" }.into();
394    }
395    let mut exponent = value.abs().log10().floor() as i32;
396    let places = 5 - exponent;
397    let scale = 10_f64.powi(places);
398    let rounded = if places >= 0 {
399        (value * scale).round_ties_even() / scale
400    } else {
401        (value / scale.recip()).round_ties_even() * scale.recip()
402    };
403    if rounded != 0.0 {
404        exponent = rounded.abs().log10().floor() as i32;
405    }
406    if !(-4..6).contains(&exponent) {
407        let mut text = format!("{rounded:.5e}");
408        let e = text.find('e').expect("scientific format");
409        while text[..e].ends_with('0') {
410            text.remove(e - 1);
411        }
412        if text[..text.find('e').unwrap()].ends_with('.') {
413            text.remove(text.find('e').unwrap() - 1);
414        }
415        let e = text.find('e').unwrap();
416        let power = text[e + 1..].parse::<i32>().expect("scientific exponent");
417        format!(
418            "{}e{}{:02}",
419            &text[..e],
420            if power >= 0 { '+' } else { '-' },
421            power.abs()
422        )
423    } else {
424        let places = (5 - exponent).max(0) as usize;
425        let mut text = format!("{rounded:.places$}");
426        while text.contains('.') && text.ends_with('0') {
427            text.pop();
428        }
429        if text.ends_with('.') {
430            text.pop();
431        }
432        text
433    }
434}
435
436pub fn mode_params(mode: Mode, ultra_ms: f64) -> ModeParams {
437    match mode {
438        Mode::Zero => ModeParams {
439            taps: 0,
440            lift_ms: 0.0,
441            peq_bands: Some(32),
442            phase_frac: 6.0,
443            max_latency_ms: 0.0,
444        },
445        Mode::Mixed => ModeParams {
446            taps: 2048,
447            lift_ms: 8.0,
448            peq_bands: None,
449            phase_frac: 6.0,
450            max_latency_ms: 10.0,
451        },
452        Mode::Linear => ModeParams {
453            taps: 2048,
454            lift_ms: 28.0,
455            peq_bands: None,
456            phase_frac: 24.0,
457            max_latency_ms: 30.0,
458        },
459        Mode::Ultra => {
460            let ms = ultra_ms.clamp(ULTRA_MS.0, ULTRA_MS.1);
461            let lift_ms = ms - 2.0;
462            let mut taps = 2048;
463            while taps < 16384 && 0.7 * taps as f64 / 48_000.0 * 1000.0 < lift_ms {
464                taps *= 2;
465            }
466            ModeParams {
467                taps,
468                lift_ms,
469                peq_bands: None,
470                phase_frac: 24.0,
471                max_latency_ms: ms,
472            }
473        }
474    }
475}
476
477fn number(value: f64) -> String {
478    if value == 0.0 {
479        "0".into()
480    } else {
481        format!("{:016x}", value.to_bits())
482    }
483}
484
485fn optional_number(out: &mut String, value: Option<f64>) {
486    if let Some(value) = value {
487        out.push_str(&number(value));
488    } else {
489        out.push('-');
490    }
491}
492
493fn sorted_f64_map(out: &mut String, values: &[(String, f64)]) {
494    let mut values: Vec<_> = values.iter().collect();
495    values.sort_by(|a, b| a.0.cmp(&b.0));
496    for (key, value) in values {
497        write!(out, "{key}={};", number(*value)).unwrap();
498    }
499}
500
501fn filter_key(entry: &SubEntry) -> String {
502    let mut out = format!(
503        "{}:{}:{}:{}:",
504        number(entry.hp_hz),
505        if entry.hp_hz != 0.0 {
506            &entry.hp_type
507        } else {
508            ""
509        },
510        number(entry.lp_hz),
511        if entry.lp_hz != 0.0 {
512            &entry.lp_type
513        } else {
514            ""
515        }
516    );
517    for &(frequency, q, gain) in &entry.peq {
518        write!(
519            &mut out,
520            "{},{},{};",
521            number(frequency),
522            number(q),
523            number(gain)
524        )
525        .unwrap();
526    }
527    out
528}
529
530macro_rules! ordered_map {
531    ($module:ident, $value:ty) => {
532        mod $module {
533            use serde::{Deserialize, Deserializer, Serializer};
534            use std::collections::BTreeMap;
535
536            pub fn serialize<S>(
537                values: &Vec<(String, $value)>,
538                serializer: S,
539            ) -> Result<S::Ok, S::Error>
540            where
541                S: Serializer,
542            {
543                use serde::ser::SerializeMap as _;
544                let mut map = serializer.serialize_map(Some(values.len()))?;
545                for (key, value) in values {
546                    map.serialize_entry(key, value)?;
547                }
548                map.end()
549            }
550
551            pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<(String, $value)>, D::Error>
552            where
553                D: Deserializer<'de>,
554            {
555                // Deserialization is for IPC values. `.secs` parsing uses PyJson
556                // directly to retain insertion order.
557                Ok(BTreeMap::<String, $value>::deserialize(deserializer)?
558                    .into_iter()
559                    .collect())
560            }
561        }
562    };
563}
564
565ordered_map!(ordered_f64_map, f64);
566ordered_map!(ordered_sub_map, super::SubEntry);
567ordered_map!(ordered_layout_map, super::BundleLayoutEntry);
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn rc_upper_defaults_and_each_setting_change_engine_key() {
575        let baseline = CalibrationSettings::default();
576        let mut json = serde_json::to_value(&baseline).unwrap();
577        json.as_object_mut().unwrap().remove("rc_hi_basis");
578        json.as_object_mut().unwrap().remove("rc_hi_transition");
579        let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
580        assert_eq!(legacy, baseline);
581        for basis in [RcHiBasis::Lenient, RcHiBasis::Agreement] {
582            let settings = CalibrationSettings {
583                rc_hi_basis: basis,
584                ..baseline.clone()
585            };
586            assert_ne!(settings.engine_key(), baseline.engine_key());
587            assert_eq!(settings.mimo_key(), baseline.mimo_key());
588            assert_eq!(
589                serde_json::to_value(&settings).unwrap()["rc_hi_basis"],
590                basis.as_str()
591            );
592        }
593        for factor in [2, 4] {
594            let settings = CalibrationSettings {
595                rc_hi_transition: factor,
596                ..baseline.clone()
597            };
598            assert_ne!(settings.engine_key(), baseline.engine_key());
599            assert_eq!(settings.mimo_key(), baseline.mimo_key());
600        }
601    }
602
603    #[test]
604    fn sample_rate_policy_defaults_for_legacy_settings_and_changes_engine_key() {
605        let baseline = CalibrationSettings::default();
606        let mut json = serde_json::to_value(&baseline).unwrap();
607        assert_eq!(json["sample_rate_policy"], "reject");
608        json.as_object_mut().unwrap().remove("sample_rate_policy");
609        let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
610        assert_eq!(legacy, baseline);
611        let mut resampled = legacy.clone();
612        resampled.sample_rate_policy = SampleRatePolicy::Resample;
613        assert_ne!(resampled.engine_key(), legacy.engine_key());
614        assert_eq!(
615            serde_json::to_value(&resampled).unwrap()["sample_rate_policy"],
616            "resample"
617        );
618        resampled.sample_rate_policy = SampleRatePolicy::Reject;
619        assert_eq!(resampled.engine_key(), legacy.engine_key());
620    }
621
622    #[test]
623    fn low_protect_defaults_off_and_preserves_legacy_engine_key() {
624        let baseline = CalibrationSettings::default();
625        assert!(!baseline.low_protect);
626        let key = baseline.engine_key();
627        assert!(!key.contains("low_protect"));
628        let mut json = serde_json::to_value(&baseline).unwrap();
629        json.as_object_mut().unwrap().remove("low_protect");
630        let mut legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
631        assert_eq!(legacy, baseline);
632        legacy.low_protect = true;
633        assert_eq!(legacy.engine_key(), format!("{key}|low_protect"));
634        legacy.low_protect = false;
635        assert_eq!(legacy.engine_key(), key);
636        let edit: crate::settings_edit::SettingEdit =
637            serde_json::from_value(serde_json::json!({"kind":"low_protect","on":true})).unwrap();
638        assert_eq!(
639            edit,
640            crate::settings_edit::SettingEdit::LowProtect { on: true }
641        );
642    }
643
644    #[test]
645    fn analytic_derivative_settings_default_off_and_change_only_their_keys() {
646        let baseline = CalibrationSettings::default();
647        let engine_key = baseline.engine_key();
648        let mimo_key = baseline.mimo_key();
649        let mut json = serde_json::to_value(&baseline).unwrap();
650        let object = json.as_object_mut().unwrap();
651        object.remove("peq_analytic_derivatives");
652        object.remove("mimo_iir_analytic_derivatives");
653        let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
654        assert!(!legacy.peq_analytic_derivatives);
655        assert!(!legacy.mimo_iir_analytic_derivatives);
656        assert_eq!(legacy.engine_key(), engine_key);
657        assert_eq!(legacy.mimo_key(), mimo_key);
658
659        let mut peq = legacy.clone();
660        peq.peq_analytic_derivatives = true;
661        assert_eq!(
662            peq.engine_key(),
663            format!("{engine_key}|peq_analytic_derivatives")
664        );
665        assert_eq!(peq.mimo_key(), mimo_key);
666
667        let mut mimo = legacy;
668        mimo.mimo_iir_analytic_derivatives = true;
669        assert_eq!(mimo.engine_key(), engine_key);
670        assert_eq!(
671            mimo.mimo_key(),
672            format!("{mimo_key}|mimo_iir_analytic_derivatives")
673        );
674    }
675
676    #[test]
677    fn mode_labels_match_upstream() {
678        assert_eq!(mode_label(Mode::Zero, None), "최소 지연 모드");
679        assert_eq!(mode_label(Mode::Mixed, Some(120.0)), "저지연 모드");
680        assert_eq!(mode_label(Mode::Linear, None), "표준 모드");
681        assert_eq!(mode_label(Mode::Ultra, None), "초고지연 모드");
682        assert_eq!(mode_label(Mode::Ultra, Some(20.0)), "초고지연 모드 30 ms");
683        assert_eq!(
684            mode_label(Mode::Ultra, Some(123.456_789)),
685            "초고지연 모드 123.457 ms"
686        );
687        assert_eq!(mode_label(Mode::Ultra, Some(250.0)), "초고지연 모드 200 ms");
688
689        let settings = CalibrationSettings {
690            mode: Mode::Ultra,
691            ultra_ms: 30.123_456_7,
692            ..CalibrationSettings::default()
693        };
694        assert_eq!(settings.label(), "초고지연 모드 30.1235 ms");
695    }
696
697    #[test]
698    fn mode_params_match_upstream() {
699        let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
700            env!("CARGO_MANIFEST_DIR"),
701            "/../../oracle/goldens/calibration/mode_params.json"
702        )))
703        .unwrap();
704        for row in fixture["outputs"]["rows"].as_array().unwrap() {
705            let mode = match row["mode"].as_str().unwrap() {
706                "zero" => Mode::Zero,
707                "mixed" => Mode::Mixed,
708                "linear" => Mode::Linear,
709                "ultra" => Mode::Ultra,
710                _ => unreachable!(),
711            };
712            let actual = mode_params(mode, row["ultra_ms"].as_f64().unwrap());
713            let expected = &row["params"];
714            assert_eq!(actual.taps as u64, expected["taps"].as_u64().unwrap());
715            assert_eq!(actual.lift_ms, expected["lift_ms"].as_f64().unwrap());
716            assert_eq!(
717                actual.peq_bands.map(|value| value as u64),
718                expected
719                    .get("peq_bands")
720                    .and_then(serde_json::Value::as_u64)
721            );
722            assert_eq!(
723                actual.phase_frac,
724                expected
725                    .get("phase_frac")
726                    .and_then(serde_json::Value::as_f64)
727                    .unwrap_or(6.0)
728            );
729            assert_eq!(
730                actual.max_latency_ms,
731                expected["max_latency_ms"].as_f64().unwrap()
732            );
733        }
734    }
735
736    #[test]
737    fn keys_have_upstream_exclusions() {
738        let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
739            env!("CARGO_MANIFEST_DIR"),
740            "/../../oracle/goldens/calibration/keys.json"
741        )))
742        .unwrap();
743        let settings: Vec<_> = fixture["inputs"]["settings"]
744            .as_object()
745            .unwrap()
746            .iter()
747            .map(|(name, value)| {
748                (
749                    name.as_str(),
750                    serde_json::from_value::<CalibrationSettings>(value.clone()).unwrap(),
751                )
752            })
753            .collect();
754
755        for (kind, key) in [
756            (
757                "engine",
758                CalibrationSettings::engine_key as fn(&CalibrationSettings) -> String,
759            ),
760            ("manual", CalibrationSettings::manual_key),
761            ("mimo", CalibrationSettings::mimo_key),
762        ] {
763            let expected = fixture["outputs"][kind].as_object().unwrap();
764            for (left_name, left) in &settings {
765                for (right_name, right) in &settings {
766                    assert_eq!(
767                        key(left) == key(right),
768                        expected[*left_name] == expected[*right_name],
769                        "{kind} key equality differs for {left_name} and {right_name}"
770                    );
771                }
772            }
773        }
774    }
775}