Skip to main content

secs_types/
chain.rs

1//! The designed DSP as one value: what the engine produces and what the
2//! response simulator (`secs-engine`) and the CamillaDSP writer
3//! (`secs-camilla`) read (docs/ARCHITECTURE.md §4, docs/ENGINE.md §2).
4//!
5//! Upstream spread this over `CorrectionResult.channels` / `.subs` and the
6//! engine attributes `top_peq`, `sub_bus_peq`, `align_delay_ms` and
7//! `main_extra_ms`, which `export_camilladsp.make_config` read directly.
8//!
9//! Delays here are on the **output axis**: what the DSP applies, the internal
10//! (spectral) axis plus [`SignalChain::main_extra_ms`].
11
12use serde::{Deserialize, Serialize};
13use ts_rs::TS;
14
15/// One peaking band `(frequency Hz, Q, gain dB)`, upstream's PEQ tuple.
16pub type Peq = (f64, f64, f64);
17/// One second-order all-pass `(frequency Hz, Q)`.
18pub type Allpass = (f64, f64);
19
20/// Crossover filter family (upstream `BAND_TYPES`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
22#[ts(export)]
23pub enum BandType {
24    /// Linkwitz–Riley 12 dB/oct.
25    #[serde(rename = "LR12")]
26    Lr12,
27    /// Linkwitz–Riley 24 dB/oct (the default).
28    #[serde(rename = "LR24")]
29    Lr24,
30    /// Linkwitz–Riley 48 dB/oct.
31    #[serde(rename = "LR48")]
32    Lr48,
33    /// Butterworth 12 dB/oct.
34    #[serde(rename = "BW12")]
35    Bw12,
36    /// Butterworth 24 dB/oct.
37    #[serde(rename = "BW24")]
38    Bw24,
39    /// Butterworth 48 dB/oct, only the common input high-pass (ADR 0031):
40    /// `from_name` never yields it, so no project or sub filter selects it.
41    #[serde(rename = "BW48")]
42    Bw48,
43}
44
45impl BandType {
46    /// Upstream name (`"LR24"` …).
47    pub fn name(self) -> &'static str {
48        match self {
49            Self::Lr12 => "LR12",
50            Self::Lr24 => "LR24",
51            Self::Lr48 => "LR48",
52            Self::Bw12 => "BW12",
53            Self::Bw24 => "BW24",
54            Self::Bw48 => "BW48",
55        }
56    }
57
58    /// The type named `name`, or `LR24` for anything else (upstream's fallback).
59    pub fn from_name(name: &str) -> Self {
60        match name {
61            "LR12" => Self::Lr12,
62            "LR48" => Self::Lr48,
63            "BW12" => Self::Bw12,
64            "BW24" => Self::Bw24,
65            _ => Self::Lr24,
66        }
67    }
68}
69
70fn is_normal_polarity(value: &Option<i8>) -> bool {
71    value.unwrap_or(1) == 1
72}
73
74/// A main channel: input correction before bass management, then the main
75/// output path.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
77#[ts(export)]
78pub struct MainChannel {
79    /// Speaker role.
80    pub role: String,
81    /// Input PEQ, applied before the FIR (`ChannelOut.peq`).
82    pub input_peq: Vec<Peq>,
83    /// Input correction FIR at 48 kHz; `[1.0]` when the mode has no FIR.
84    pub fir: Vec<f64>,
85    /// Main output gain in dB.
86    pub gain_db: f64,
87    /// Physical output polarity. An absent value means `1`; an inverted output is `-1`.
88    #[serde(default, skip_serializing_if = "is_normal_polarity")]
89    #[ts(optional)]
90    pub polarity: Option<i8>,
91    /// Main output delay in ms, output axis.
92    pub delay_ms: f64,
93    /// Bass-management crossover in Hz (HP on the main, LP on its sub send).
94    pub xo_hz: Option<f64>,
95    /// Acoustic-XO PEQ on the main output path (`engine.top_peq`).
96    pub top_peq: Vec<Peq>,
97    /// Arrival-alignment delay in ms, internal axis (`engine.align_delay_ms`);
98    /// MIMO paths delay by it plus `main_extra_ms`.
99    pub align_delay_ms: f64,
100}
101
102/// A sub output fed by the sub bus.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
104#[ts(export)]
105pub struct SubChannel {
106    /// Speaker role.
107    pub role: String,
108    /// Bus-to-sub gain in dB.
109    pub gain_db: f64,
110    /// Output delay in ms, output axis.
111    pub delay_ms: f64,
112    /// Polarity, `1` or `-1`.
113    pub polarity: i8,
114    /// All-passes, in order.
115    pub allpass: Vec<Allpass>,
116    /// Fixed high-pass in Hz, 0 for none.
117    pub hp_hz: f64,
118    /// High-pass family.
119    pub hp_type: BandType,
120    /// Fixed low-pass in Hz, 0 for none.
121    pub lp_hz: f64,
122    /// Low-pass family.
123    pub lp_type: BandType,
124    /// Fixed output PEQ, then generated bundle PEQ bands.
125    pub peq: Vec<Peq>,
126    /// How BA treated the sub, upstream's label: `auto`, `fixed`,
127    /// `bundle:A`, `bundle:A:multi`, `bundle:A:dba-front`, `bundle:A:dba-rear`.
128    pub mode: String,
129}
130
131/// The whole designed DSP.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
133#[ts(export)]
134pub struct SignalChain {
135    /// Sample rate the coefficients are for (48 kHz).
136    pub sample_rate: u32,
137    /// Main channels in design order (`CorrectionResult.channels`).
138    pub mains: Vec<MainChannel>,
139    /// Subs in configuration order (`CorrectionResult.subs`).
140    pub subs: Vec<SubChannel>,
141    /// PEQ on the summed sub bus, before the per-sub output branches.
142    pub sub_bus_peq: Vec<Peq>,
143    /// Common shift from the internal to the output axis in ms
144    /// (`engine.main_extra_ms`).
145    pub main_extra_ms: f64,
146    /// Excess-phase lift of the input FIRs in ms.
147    pub lift_ms: f64,
148    /// Total filter latency in ms: lift plus `main_extra_ms`.
149    pub latency_ms: f64,
150    /// Common 8th-order Butterworth high-pass in Hz on every input, LFE
151    /// included, before input correction and the MIMO fork (ADR 0031).
152    /// Absent when off, so an off chain serializes and hashes as before.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    #[ts(optional)]
155    pub input_hp_hz: Option<f64>,
156}
157
158/// What a MIMO support path filters with (upstream `mimo_paths`' filter slot).
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
160#[ts(export)]
161pub enum MimoFilter {
162    /// IIR support (`mimo_iir.IirSupport`): gain, polarity, integer-sample
163    /// delay, peaking bands and all-passes, then the path's LR4 high-pass at
164    /// [`MimoPath::low_hz`] and LR4 low-pass at [`MimoPaths::fsiso`].
165    Iir {
166        /// Path gain in dB.
167        gain_db: f64,
168        /// Path delay in ms, added to [`MimoPath::delay_ms`].
169        delay_ms: f64,
170        /// Polarity, `1` or `-1`.
171        polarity: i8,
172        /// Peaking bands, in order.
173        peq: Vec<Peq>,
174        /// All-passes, in order.
175        allpass: Vec<Allpass>,
176    },
177    /// FIR support: the low-rate ideal FIR raised to 48 kHz
178    /// (`export_camilladsp.fir_to_48k`); the Fsiso low-pass is inside it.
179    Fir {
180        /// Taps at 48 kHz.
181        taps_48k: Vec<f64>,
182    },
183    /// FIR support retained at its design rate. Exporters materialize the
184    /// historical 48 kHz representation only when writing convolution files.
185    /// The isolation low-pass is already included in these taps.
186    FirLowRate {
187        /// Design-rate coefficients, including the isolation low-pass.
188        taps_low: Vec<f64>,
189        /// Design sample rate in hertz.
190        fs_low: f64,
191    },
192}
193
194impl MimoFilter {
195    /// Whether this support uses a finite impulse response.
196    pub fn is_fir(&self) -> bool {
197        matches!(self, Self::Fir { .. } | Self::FirLowRate { .. })
198    }
199
200    /// Number of coefficients in the compatible 48 kHz export representation.
201    pub fn fir_len_48k(&self) -> usize {
202        match self {
203            Self::Iir { .. } => 0,
204            Self::Fir { taps_48k } => taps_48k.len(),
205            Self::FirLowRate { taps_low, fs_low } => {
206                if !fs_low.is_finite() || *fs_low <= 0.0 || taps_low.is_empty() {
207                    0
208                } else {
209                    (taps_low.len() as f64 * (48_000.0 / fs_low)).ceil() as usize
210                }
211            }
212        }
213    }
214}
215
216/// One MIMO support path: input `input` also drives speaker `support`.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
218#[ts(export)]
219pub struct MimoPath {
220    /// Input role (the channel whose raw signal the path copies).
221    pub input: String,
222    /// Support speaker role (the output the path adds to).
223    pub support: String,
224    /// The path filter.
225    pub filter: MimoFilter,
226    /// Protective LR4 high-pass in Hz: the support's F-support low edge,
227    /// 50 Hz when the speaker has no range (upstream fallback).
228    pub low_hz: f64,
229    /// The support speaker's delay in ms, output axis: its alignment delay
230    /// plus [`SignalChain::main_extra_ms`].
231    pub delay_ms: f64,
232}
233
234/// Every MIMO support path, in upstream order (input, then support).
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
236#[ts(export)]
237pub struct MimoPaths {
238    /// Isolation frequency in Hz (the IIR paths' LR4 low-pass).
239    pub fsiso: f64,
240    /// The paths.
241    pub paths: Vec<MimoPath>,
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn main(role: &str, polarity: Option<i8>) -> MainChannel {
249        MainChannel {
250            role: role.into(),
251            input_peq: Vec::new(),
252            fir: vec![1.0],
253            gain_db: 0.0,
254            polarity,
255            delay_ms: 0.0,
256            xo_hz: None,
257            top_peq: Vec::new(),
258            align_delay_ms: 0.0,
259        }
260    }
261
262    #[test]
263    fn main_polarity_serializes_only_when_inverted() {
264        let normal = serde_json::to_string(&main("L", None)).unwrap();
265        assert_eq!(
266            normal,
267            r#"{"role":"L","input_peq":[],"fir":[1.0],"gain_db":0.0,"delay_ms":0.0,"xo_hz":null,"top_peq":[],"align_delay_ms":0.0}"#
268        );
269        assert_eq!(serde_json::to_string(&main("L", Some(1))).unwrap(), normal);
270        let inverted = serde_json::to_value(main("L", Some(-1))).unwrap();
271        assert_eq!(inverted["polarity"], -1);
272        let legacy: MainChannel = serde_json::from_str(&normal).unwrap();
273        assert_eq!(legacy.polarity.unwrap_or(1), 1);
274    }
275
276    #[test]
277    fn input_hp_is_absent_from_an_off_chain_and_round_trips_when_set() {
278        let mut chain = SignalChain {
279            sample_rate: 48_000,
280            mains: Vec::new(),
281            subs: Vec::new(),
282            sub_bus_peq: Vec::new(),
283            main_extra_ms: 0.0,
284            lift_ms: 0.0,
285            latency_ms: 0.0,
286            input_hp_hz: None,
287        };
288        let off = serde_json::to_value(&chain).unwrap();
289        assert!(off.get("input_hp_hz").is_none());
290        assert_eq!(serde_json::from_value::<SignalChain>(off).unwrap(), chain);
291        chain.input_hp_hz = Some(27.0);
292        let on = serde_json::to_value(&chain).unwrap();
293        assert_eq!(on["input_hp_hz"], 27.0);
294        assert_eq!(serde_json::from_value::<SignalChain>(on).unwrap(), chain);
295    }
296}