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