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