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