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 SampleRatePolicy {
32 #[default]
34 Reject,
35 Resample,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
40#[ts(export)]
41pub struct SubEntry {
42 pub mode: String,
43 pub bundle: String,
44 pub gain_db: f64,
45 pub delay_ms: f64,
46 pub polarity: i8,
47 pub allpass: Vec<(f64, f64)>,
48 pub mimo_support: bool,
49 pub hp_hz: f64,
50 pub lp_hz: f64,
51 pub peq: Vec<(f64, f64, f64)>,
52 pub hp_type: String,
53 pub lp_type: String,
54 pub dba_row: String,
55}
56
57impl Default for SubEntry {
58 fn default() -> Self {
59 Self {
60 mode: "auto".into(),
61 bundle: "A".into(),
62 gain_db: 0.0,
63 delay_ms: 0.0,
64 polarity: 1,
65 allpass: Vec::new(),
66 mimo_support: true,
67 hp_hz: 0.0,
68 lp_hz: 0.0,
69 peq: Vec::new(),
70 hp_type: "LR24".into(),
71 lp_type: "LR24".into(),
72 dba_row: "front".into(),
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
78#[ts(export)]
79pub struct BundleLayoutEntry {
80 pub kind: String,
81 pub rear_trim_ms: f64,
82}
83
84impl Default for BundleLayoutEntry {
85 fn default() -> Self {
86 Self {
87 kind: "values".into(),
88 rear_trim_ms: 0.0,
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
94#[ts(export)]
95pub struct CalibrationSettings {
96 pub mode: Mode,
97 #[serde(default)]
98 pub sample_rate_policy: SampleRatePolicy,
99 pub rc_lo: f64,
100 pub rc_hi: Option<f64>,
101 pub room_volume_m3: Option<f64>,
102 pub room_dims_m: Option<[f64; 3]>,
103 pub mimo_on: bool,
104 pub fsiso: f64,
105 pub mimo_low: Option<f64>,
106 #[ts(type = "number")]
107 pub mimo_max_supports: i64,
108 pub mimo_fir: bool,
109 #[serde(default)]
111 pub peq_analytic_derivatives: bool,
112 #[serde(default)]
114 pub mimo_iir_analytic_derivatives: bool,
115 #[serde(with = "ordered_f64_map")]
116 #[ts(as = "BTreeMap<String, f64>")]
117 pub xo: Vec<(String, f64)>,
118 #[serde(with = "ordered_f64_map")]
119 #[ts(as = "BTreeMap<String, f64>")]
120 pub xo_rec: Vec<(String, f64)>,
121 pub ultra_ms: f64,
122 #[serde(with = "ordered_sub_map")]
123 #[ts(as = "BTreeMap<String, SubEntry>")]
124 pub subs_manual: Vec<(String, SubEntry)>,
125 pub sub_main_delay_ms: f64,
126 #[serde(with = "ordered_layout_map")]
127 #[ts(as = "BTreeMap<String, BundleLayoutEntry>")]
128 pub bundle_layouts: Vec<(String, BundleLayoutEntry)>,
129 pub infra_boost_max_db: f64,
130 pub xo_peq_on: bool,
131 pub boost_max_db: f64,
132 pub xo_peq_lo: f64,
133 pub xo_peq_hi: f64,
134}
135
136impl Default for CalibrationSettings {
137 fn default() -> Self {
138 Self {
139 mode: Mode::Mixed,
140 sample_rate_policy: SampleRatePolicy::Reject,
141 rc_lo: 20.0,
142 rc_hi: None,
143 room_volume_m3: None,
144 room_dims_m: None,
145 mimo_on: false,
146 fsiso: 150.0,
147 mimo_low: None,
148 mimo_max_supports: 0,
149 mimo_fir: true,
151 peq_analytic_derivatives: false,
152 mimo_iir_analytic_derivatives: false,
153 xo: Vec::new(),
154 xo_rec: Vec::new(),
155 ultra_ms: 200.0,
156 subs_manual: Vec::new(),
157 sub_main_delay_ms: 0.0,
158 bundle_layouts: Vec::new(),
159 infra_boost_max_db: 24.0,
160 xo_peq_on: true,
161 boost_max_db: 10.0,
162 xo_peq_lo: 0.0,
163 xo_peq_hi: 0.0,
164 }
165 }
166}
167
168impl CalibrationSettings {
169 pub fn label(&self) -> String {
171 mode_label(self.mode, Some(self.ultra_ms))
172 }
173
174 pub fn engine_key(&self) -> String {
176 let mut out = String::new();
177 write!(&mut out, "{:?}|{}|", self.mode, number(self.rc_lo)).unwrap();
178 optional_number(&mut out, self.rc_hi);
179 out.push('|');
180 optional_number(&mut out, self.room_volume_m3);
181 out.push('|');
182 sorted_f64_map(&mut out, &self.xo);
183 out.push('|');
184 if self.mode == Mode::Ultra {
185 out.push_str(&number(self.ultra_ms));
186 } else {
187 out.push('-');
188 }
189 out.push('|');
190 out.push_str(&self.manual_key());
191 write!(
192 &mut out,
193 "|{}|{}|{}|{}|{}",
194 number(self.infra_boost_max_db),
195 self.xo_peq_on,
196 number(self.xo_peq_lo),
197 number(self.xo_peq_hi),
198 number(self.boost_max_db)
199 )
200 .unwrap();
201 if self.sample_rate_policy == SampleRatePolicy::Resample {
203 out.push_str("|sample_rate_policy=resample");
204 }
205 if self.peq_analytic_derivatives {
206 out.push_str("|peq_analytic_derivatives");
207 }
208 out
209 }
210
211 pub fn manual_key(&self) -> String {
213 let mut entries: Vec<_> = self.subs_manual.iter().collect();
214 entries.sort_by(|a, b| a.0.cmp(&b.0));
215 let mut out = String::new();
216 for (role, entry) in entries {
217 let filters = filter_key(entry);
218 if entry.mode == "auto" {
219 if entry.hp_hz != 0.0 || entry.lp_hz != 0.0 || !entry.peq.is_empty() {
220 write!(&mut out, "{role}:auto:{filters};").unwrap();
221 }
222 continue;
223 }
224 let layout = if entry.mode == "bundle" {
225 self.bundle_layouts
226 .iter()
227 .find(|(name, _)| name == &entry.bundle)
228 .map_or("values", |(_, layout)| layout.kind.as_str())
229 } else {
230 "values"
231 };
232 if layout != "values" {
233 let row = if layout == "dba" {
234 entry.dba_row.as_str()
235 } else {
236 ""
237 };
238 let trim = if layout == "dba" {
239 self.bundle_layouts
240 .iter()
241 .find(|(name, _)| name == &entry.bundle)
242 .map_or(0.0, |(_, value)| value.rear_trim_ms)
243 } else {
244 0.0
245 };
246 write!(
247 &mut out,
248 "{role}:bundle:{}:{layout}:{row}:{}:{filters};",
249 entry.bundle,
250 number(trim)
251 )
252 .unwrap();
253 } else {
254 let bundle = if entry.mode == "bundle" {
255 entry.bundle.as_str()
256 } else {
257 ""
258 };
259 write!(
260 &mut out,
261 "{role}:{}:{bundle}:{}:{}:{}:",
262 entry.mode,
263 number(entry.gain_db),
264 number(entry.delay_ms),
265 entry.polarity
266 )
267 .unwrap();
268 for &(frequency, q) in &entry.allpass {
269 write!(&mut out, "{},{};", number(frequency), number(q)).unwrap();
270 }
271 write!(&mut out, ":{filters};").unwrap();
272 }
273 }
274 let fixed = self.subs_manual.iter().any(|(_, e)| e.mode == "fixed");
275 if fixed {
276 write!(&mut out, "|{}", number(self.sub_main_delay_ms)).unwrap();
277 } else {
278 out.push_str("|-");
279 }
280 out
281 }
282
283 pub fn mimo_key(&self) -> String {
285 let mut excluded: Vec<_> = self
286 .subs_manual
287 .iter()
288 .filter(|(_, entry)| !entry.mimo_support)
289 .map(|(role, _)| role.as_str())
290 .collect();
291 excluded.sort_unstable();
292 let mut out = format!("{}|", number(self.fsiso));
293 optional_number(&mut out, self.mimo_low);
294 write!(
295 &mut out,
296 "|{}|{}|{}",
297 excluded.join(","),
298 self.mimo_max_supports,
299 self.mimo_fir
300 )
301 .unwrap();
302 if self.mimo_iir_analytic_derivatives {
303 out.push_str("|mimo_iir_analytic_derivatives");
304 }
305 out
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
310#[ts(export)]
311pub struct ModeParams {
312 pub taps: usize,
313 pub lift_ms: f64,
314 pub peq_bands: Option<usize>,
315 pub phase_frac: f64,
316 pub max_latency_ms: f64,
317}
318
319pub fn mode_tag(mode: Mode) -> &'static str {
321 match mode {
322 Mode::Zero => "zero",
323 Mode::Mixed => "mixed",
324 Mode::Linear => "linear",
325 Mode::Ultra => "ultra",
326 }
327}
328
329pub fn mode_label(mode: Mode, ultra_ms: Option<f64>) -> String {
331 let name = match mode {
332 Mode::Zero => "최소 지연 모드",
333 Mode::Mixed => "저지연 모드",
334 Mode::Linear => "표준 모드",
335 Mode::Ultra => "초고지연 모드",
336 };
337 if mode == Mode::Ultra
338 && let Some(ms) = ultra_ms
339 {
340 let (lo, hi) = ULTRA_MS;
341 format!("{name} {} ms", python_g(ms.clamp(lo, hi)))
342 } else {
343 name.into()
344 }
345}
346
347fn python_g(value: f64) -> String {
348 if value == 0.0 {
349 return if value.is_sign_negative() { "-0" } else { "0" }.into();
350 }
351 let mut exponent = value.abs().log10().floor() as i32;
352 let places = 5 - exponent;
353 let scale = 10_f64.powi(places);
354 let rounded = if places >= 0 {
355 (value * scale).round_ties_even() / scale
356 } else {
357 (value / scale.recip()).round_ties_even() * scale.recip()
358 };
359 if rounded != 0.0 {
360 exponent = rounded.abs().log10().floor() as i32;
361 }
362 if !(-4..6).contains(&exponent) {
363 let mut text = format!("{rounded:.5e}");
364 let e = text.find('e').expect("scientific format");
365 while text[..e].ends_with('0') {
366 text.remove(e - 1);
367 }
368 if text[..text.find('e').unwrap()].ends_with('.') {
369 text.remove(text.find('e').unwrap() - 1);
370 }
371 let e = text.find('e').unwrap();
372 let power = text[e + 1..].parse::<i32>().expect("scientific exponent");
373 format!(
374 "{}e{}{:02}",
375 &text[..e],
376 if power >= 0 { '+' } else { '-' },
377 power.abs()
378 )
379 } else {
380 let places = (5 - exponent).max(0) as usize;
381 let mut text = format!("{rounded:.places$}");
382 while text.contains('.') && text.ends_with('0') {
383 text.pop();
384 }
385 if text.ends_with('.') {
386 text.pop();
387 }
388 text
389 }
390}
391
392pub fn mode_params(mode: Mode, ultra_ms: f64) -> ModeParams {
393 match mode {
394 Mode::Zero => ModeParams {
395 taps: 0,
396 lift_ms: 0.0,
397 peq_bands: Some(32),
398 phase_frac: 6.0,
399 max_latency_ms: 0.0,
400 },
401 Mode::Mixed => ModeParams {
402 taps: 2048,
403 lift_ms: 8.0,
404 peq_bands: None,
405 phase_frac: 6.0,
406 max_latency_ms: 10.0,
407 },
408 Mode::Linear => ModeParams {
409 taps: 2048,
410 lift_ms: 28.0,
411 peq_bands: None,
412 phase_frac: 24.0,
413 max_latency_ms: 30.0,
414 },
415 Mode::Ultra => {
416 let ms = ultra_ms.clamp(ULTRA_MS.0, ULTRA_MS.1);
417 let lift_ms = ms - 2.0;
418 let mut taps = 2048;
419 while taps < 16384 && 0.7 * taps as f64 / 48_000.0 * 1000.0 < lift_ms {
420 taps *= 2;
421 }
422 ModeParams {
423 taps,
424 lift_ms,
425 peq_bands: None,
426 phase_frac: 24.0,
427 max_latency_ms: ms,
428 }
429 }
430 }
431}
432
433fn number(value: f64) -> String {
434 if value == 0.0 {
435 "0".into()
436 } else {
437 format!("{:016x}", value.to_bits())
438 }
439}
440
441fn optional_number(out: &mut String, value: Option<f64>) {
442 if let Some(value) = value {
443 out.push_str(&number(value));
444 } else {
445 out.push('-');
446 }
447}
448
449fn sorted_f64_map(out: &mut String, values: &[(String, f64)]) {
450 let mut values: Vec<_> = values.iter().collect();
451 values.sort_by(|a, b| a.0.cmp(&b.0));
452 for (key, value) in values {
453 write!(out, "{key}={};", number(*value)).unwrap();
454 }
455}
456
457fn filter_key(entry: &SubEntry) -> String {
458 let mut out = format!(
459 "{}:{}:{}:{}:",
460 number(entry.hp_hz),
461 if entry.hp_hz != 0.0 {
462 &entry.hp_type
463 } else {
464 ""
465 },
466 number(entry.lp_hz),
467 if entry.lp_hz != 0.0 {
468 &entry.lp_type
469 } else {
470 ""
471 }
472 );
473 for &(frequency, q, gain) in &entry.peq {
474 write!(
475 &mut out,
476 "{},{},{};",
477 number(frequency),
478 number(q),
479 number(gain)
480 )
481 .unwrap();
482 }
483 out
484}
485
486macro_rules! ordered_map {
487 ($module:ident, $value:ty) => {
488 mod $module {
489 use serde::{Deserialize, Deserializer, Serializer};
490 use std::collections::BTreeMap;
491
492 pub fn serialize<S>(
493 values: &Vec<(String, $value)>,
494 serializer: S,
495 ) -> Result<S::Ok, S::Error>
496 where
497 S: Serializer,
498 {
499 use serde::ser::SerializeMap as _;
500 let mut map = serializer.serialize_map(Some(values.len()))?;
501 for (key, value) in values {
502 map.serialize_entry(key, value)?;
503 }
504 map.end()
505 }
506
507 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<(String, $value)>, D::Error>
508 where
509 D: Deserializer<'de>,
510 {
511 Ok(BTreeMap::<String, $value>::deserialize(deserializer)?
514 .into_iter()
515 .collect())
516 }
517 }
518 };
519}
520
521ordered_map!(ordered_f64_map, f64);
522ordered_map!(ordered_sub_map, super::SubEntry);
523ordered_map!(ordered_layout_map, super::BundleLayoutEntry);
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn sample_rate_policy_defaults_for_legacy_settings_and_changes_engine_key() {
531 let baseline = CalibrationSettings::default();
532 let mut json = serde_json::to_value(&baseline).unwrap();
533 assert_eq!(json["sample_rate_policy"], "reject");
534 json.as_object_mut().unwrap().remove("sample_rate_policy");
535 let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
536 assert_eq!(legacy, baseline);
537 let mut resampled = legacy.clone();
538 resampled.sample_rate_policy = SampleRatePolicy::Resample;
539 assert_ne!(resampled.engine_key(), legacy.engine_key());
540 assert_eq!(
541 serde_json::to_value(&resampled).unwrap()["sample_rate_policy"],
542 "resample"
543 );
544 resampled.sample_rate_policy = SampleRatePolicy::Reject;
545 assert_eq!(resampled.engine_key(), legacy.engine_key());
546 }
547
548 #[test]
549 fn analytic_derivative_settings_default_off_and_change_only_their_keys() {
550 let baseline = CalibrationSettings::default();
551 let engine_key = baseline.engine_key();
552 let mimo_key = baseline.mimo_key();
553 let mut json = serde_json::to_value(&baseline).unwrap();
554 let object = json.as_object_mut().unwrap();
555 object.remove("peq_analytic_derivatives");
556 object.remove("mimo_iir_analytic_derivatives");
557 let legacy: CalibrationSettings = serde_json::from_value(json).unwrap();
558 assert!(!legacy.peq_analytic_derivatives);
559 assert!(!legacy.mimo_iir_analytic_derivatives);
560 assert_eq!(legacy.engine_key(), engine_key);
561 assert_eq!(legacy.mimo_key(), mimo_key);
562
563 let mut peq = legacy.clone();
564 peq.peq_analytic_derivatives = true;
565 assert_eq!(
566 peq.engine_key(),
567 format!("{engine_key}|peq_analytic_derivatives")
568 );
569 assert_eq!(peq.mimo_key(), mimo_key);
570
571 let mut mimo = legacy;
572 mimo.mimo_iir_analytic_derivatives = true;
573 assert_eq!(mimo.engine_key(), engine_key);
574 assert_eq!(
575 mimo.mimo_key(),
576 format!("{mimo_key}|mimo_iir_analytic_derivatives")
577 );
578 }
579
580 #[test]
581 fn mode_labels_match_upstream() {
582 assert_eq!(mode_label(Mode::Zero, None), "최소 지연 모드");
583 assert_eq!(mode_label(Mode::Mixed, Some(120.0)), "저지연 모드");
584 assert_eq!(mode_label(Mode::Linear, None), "표준 모드");
585 assert_eq!(mode_label(Mode::Ultra, None), "초고지연 모드");
586 assert_eq!(mode_label(Mode::Ultra, Some(20.0)), "초고지연 모드 30 ms");
587 assert_eq!(
588 mode_label(Mode::Ultra, Some(123.456_789)),
589 "초고지연 모드 123.457 ms"
590 );
591 assert_eq!(mode_label(Mode::Ultra, Some(250.0)), "초고지연 모드 200 ms");
592
593 let settings = CalibrationSettings {
594 mode: Mode::Ultra,
595 ultra_ms: 30.123_456_7,
596 ..CalibrationSettings::default()
597 };
598 assert_eq!(settings.label(), "초고지연 모드 30.1235 ms");
599 }
600
601 #[test]
602 fn mode_params_match_upstream() {
603 let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
604 env!("CARGO_MANIFEST_DIR"),
605 "/../../oracle/goldens/calibration/mode_params.json"
606 )))
607 .unwrap();
608 for row in fixture["outputs"]["rows"].as_array().unwrap() {
609 let mode = match row["mode"].as_str().unwrap() {
610 "zero" => Mode::Zero,
611 "mixed" => Mode::Mixed,
612 "linear" => Mode::Linear,
613 "ultra" => Mode::Ultra,
614 _ => unreachable!(),
615 };
616 let actual = mode_params(mode, row["ultra_ms"].as_f64().unwrap());
617 let expected = &row["params"];
618 assert_eq!(actual.taps as u64, expected["taps"].as_u64().unwrap());
619 assert_eq!(actual.lift_ms, expected["lift_ms"].as_f64().unwrap());
620 assert_eq!(
621 actual.peq_bands.map(|value| value as u64),
622 expected
623 .get("peq_bands")
624 .and_then(serde_json::Value::as_u64)
625 );
626 assert_eq!(
627 actual.phase_frac,
628 expected
629 .get("phase_frac")
630 .and_then(serde_json::Value::as_f64)
631 .unwrap_or(6.0)
632 );
633 assert_eq!(
634 actual.max_latency_ms,
635 expected["max_latency_ms"].as_f64().unwrap()
636 );
637 }
638 }
639
640 #[test]
641 fn keys_have_upstream_exclusions() {
642 let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!(
643 env!("CARGO_MANIFEST_DIR"),
644 "/../../oracle/goldens/calibration/keys.json"
645 )))
646 .unwrap();
647 let settings: Vec<_> = fixture["inputs"]["settings"]
648 .as_object()
649 .unwrap()
650 .iter()
651 .map(|(name, value)| {
652 (
653 name.as_str(),
654 serde_json::from_value::<CalibrationSettings>(value.clone()).unwrap(),
655 )
656 })
657 .collect();
658
659 for (kind, key) in [
660 (
661 "engine",
662 CalibrationSettings::engine_key as fn(&CalibrationSettings) -> String,
663 ),
664 ("manual", CalibrationSettings::manual_key),
665 ("mimo", CalibrationSettings::mimo_key),
666 ] {
667 let expected = fixture["outputs"][kind].as_object().unwrap();
668 for (left_name, left) in &settings {
669 for (right_name, right) in &settings {
670 assert_eq!(
671 key(left) == key(right),
672 expected[*left_name] == expected[*right_name],
673 "{kind} key equality differs for {left_name} and {right_name}"
674 );
675 }
676 }
677 }
678 }
679}