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}