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