Skip to main content

secs_engine/
notes.rs

1//! Typed engine diagnostics; presentation owns their translated text.
2
3use crate::input::Peq;
4
5#[derive(Debug, Clone, PartialEq)]
6pub enum Note {
7    /// ADR 0033, one per main whose direct sound is opposite to the majority
8    /// of the mains it could be correlated with. `compared` is the number of
9    /// decided pairs, `opposite` how many of them were negative, `confidence`
10    /// the median |r| over the decided pairs.
11    PolarityReversed {
12        role: String,
13        confidence: f64,
14        compared: usize,
15        opposite: usize,
16    },
17    PolaritySplit {
18        groups: (Vec<String>, Vec<String>),
19        confidence: f64,
20    },
21    LiftCapped {
22        from_ms: f64,
23        taps: usize,
24        lift_ms: f64,
25    },
26    SubFilters(Vec<(String, crate::setup::SubFilter)>),
27    LowProtect {
28        role: String,
29        rolloff_hz: f64,
30        hp_hz: Option<f64>,
31        limited: bool,
32        reduced: usize,
33        cuts: usize,
34    },
35    RcLimit {
36        hz: f64,
37        auto: bool,
38        schroeder_hz: Option<f64>,
39        basis: secs_types::calibration::RcHiBasis,
40        factor: u8,
41        band_hz: Option<f64>,
42        band_t60_s: Option<f64>,
43        positions: Option<usize>,
44    },
45    RcBands {
46        bands: Vec<(f64, f64)>,
47    },
48    /// ADR 0025, one per main when `lf_align` is on and the mode has FIR
49    /// taps. `arrival_ms` is the median LF arrival left after correction
50    /// (positive = later than HF); `limited` means the pull clamp cut the
51    /// requested correction by more than 1 ms somewhere in the band.
52    LfAlign {
53        role: String,
54        strength: f64,
55        arrival_ms: Option<f64>,
56        limited: bool,
57    },
58    /// ADR 0025, one per main when `mode_decay` is on. Each mode is
59    /// `(hz, measured T60 s, cut depth dB)` with the depth positive; an empty
60    /// list means no mode qualified.
61    ModeDecay {
62        role: String,
63        target_t60_s: f64,
64        modes: Vec<(f64, f64, f64)>,
65    },
66    /// ADR 0030, one per channel whose FIR realizes excess phase when
67    /// `hf_phase` is on. `rotation_deg` is the wrapped rotation unwound above
68    /// the band, `None` when less than an octave is left below 0.45·SR;
69    /// `limited` means the `[−5 ms, lift]` clamp kept part of it.
70    HfPhase {
71        role: String,
72        rotation_deg: Option<f64>,
73        limited: bool,
74    },
75    /// ADR 0034, one per main when `dip_limit` is on. Each entry is
76    /// `(centre Hz, depth dB, allowed fill dB)`, deepest first.
77    DipLimit {
78        role: String,
79        dips: Vec<(f64, f64, f64)>,
80    },
81    /// ADR 0031, once when `input_hp_hz` is set: the common input high-pass,
82    /// 8th-order Butterworth, at `hz`.
83    InputHp {
84        hz: f64,
85    },
86    Latency {
87        latency_ms: f64,
88        lift_ms: f64,
89        main_extra_ms: f64,
90    },
91    PhaseStrength(Vec<(String, f64)>),
92    PhaseBand(Vec<(String, Option<f64>)>),
93    // RC (P09)
94    LongModeBoost {
95        cap_db: f64,
96        modes: Vec<(f64, f64)>,
97    },
98    GdCuts {
99        role: String,
100        cuts: Vec<Peq>,
101    },
102    // BA (P10)
103    BaCost(f64),
104    BaBusPass {
105        first: f64,
106        second: f64,
107        kept_first: bool,
108    },
109    BaManual,
110    LayoutFallback(String),
111    Layout(String),
112    TwoWay {
113        roles: Vec<String>,
114        gain_db: f64,
115        xo_hz: f64,
116    },
117    BusGainAnchored(f64),
118    Mimo(MimoNote),
119}
120
121/// Semantic diagnostics in producer order; never translated presentation text.
122#[derive(Debug, Clone, PartialEq)]
123pub enum MimoNote {
124    InsufficientPositions {
125        measured: usize,
126    },
127    ExcludedSupports(Vec<String>),
128    SupportRanges(Vec<(String, Option<(f64, f64)>)>),
129    GuardReduced {
130        role: String,
131        gain_db: f64,
132        cause: GuardCause,
133    },
134    GuardDisabled {
135        role: String,
136        cause: GuardCause,
137    },
138    SupportLimit {
139        max_supports: usize,
140    },
141    FirBudget {
142        support_count: usize,
143        taps_total: usize,
144    },
145}
146
147/// What the MIMO guard rejected last: the support level of that trial
148/// (3 dB above `GuardReduced::gain_db`, or −12 dB before `GuardDisabled`)
149/// and the guard values there that broke their 1 dB limits.
150#[derive(Debug, Clone, PartialEq)]
151pub struct GuardCause {
152    pub rejected_db: f64,
153    /// Late energy rise over every position, `(lo Hz, hi Hz, dB)`: a
154    /// third-octave band or a ±1.5 Hz band around a late mode.
155    pub band: Option<(f64, f64, f64)>,
156    /// Modal tail energy rise over every position, `(mode Hz, dB)`.
157    pub tail: Option<(f64, f64)>,
158}
159
160#[cfg(test)]
161impl MimoNote {
162    // Only the oracle adapter renders the legacy diagnostic strings.
163    pub(crate) fn oracle_text(&self) -> String {
164        match self {
165            Self::InsufficientPositions { measured } => {
166                format!("측정 위치 {measured}곳 — MIMO 는 3곳 이상일 때만")
167            }
168            Self::ExcludedSupports(roles) => format!("서포트 제외(사용자): {}", roles.join(", ")),
169            Self::SupportRanges(ranges) => format!(
170                "서포트 대역 {}",
171                ranges
172                    .iter()
173                    .map(|(role, range)| match range {
174                        Some((lo, hi)) => format!("{role} {lo:.0}–{hi:.0}"),
175                        None => format!("{role} 제외"),
176                    })
177                    .collect::<Vec<_>>()
178                    .join(", ")
179            ),
180            Self::GuardReduced { role, gain_db, .. } => {
181                format!("{role}: 대역 늦은 에너지 악화로 서포트 {gain_db:.0} dB")
182            }
183            Self::GuardDisabled { role, .. } => format!("{role}: MIMO 끔(악화)"),
184            Self::SupportLimit { max_supports } => format!("입력당 서포트 ≤ {max_supports}"),
185            Self::FirBudget {
186                support_count,
187                taps_total,
188            } => {
189                let digits = taps_total.to_string();
190                let grouped = digits
191                    .chars()
192                    .enumerate()
193                    .fold(String::new(), |mut out, (i, ch)| {
194                        if i > 0 && (digits.len() - i).is_multiple_of(3) {
195                            out.push(',');
196                        }
197                        out.push(ch);
198                        out
199                    });
200                format!("FIR 서포트 {support_count}개 · 총 {grouped}탭(48 kHz)")
201            }
202        }
203    }
204}