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