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