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