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