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