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