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