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