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