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