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