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