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