secs_graph/lib.rs
1#![forbid(unsafe_code)]
2#![deny(unsafe_op_in_unsafe_fn)]
3//! The export graph: one validated signal chain with its MIMO paths and the
4//! topology every export writer shares (docs/ARCHITECTURE.md ยง4).
5//!
6//! Building an [`ExportGraph`] is the only validation of a [`SignalChain`]
7//! and its [`MimoPaths`]. The CamillaDSP writer (`secs-camilla`), the
8//! filter-text and Equalizer APO writers and the FIR support renderer
9//! (`secs-filters`) read the graph and serialise it. A check that belongs to
10//! one writer only (CamillaDSP options, a filter run's label and stamp, the
11//! renderer's block geometry) stays with that writer.
12//!
13//! Upstream sources: `export_filters.make_plan` for the [`FilterPlan`], and
14//! the input rules both `export_camilladsp.py` and `export_filters.py`
15//! assumed without checking.
16
17pub mod design;
18pub mod files;
19mod plan;
20mod validate;
21
22pub use plan::{FilterPlan, FirView, MimoFeed, Op, SubFeed, headroom_db, plan_responses};
23
24use secs_types::chain::{MimoPaths, SignalChain};
25
26/// Sample rate of every exported FIR and delay.
27pub const SAMPLE_RATE: u32 = 48_000;
28/// Upstream Dolby LFE playback gain.
29pub const LFE_GAIN_DB: f64 = 10.0;
30/// Upstream Dolby LFE content low-pass frequency.
31pub const LFE_LP_HZ: f64 = 120.0;
32
33/// Python/NumPy half-even rounding to an integer.
34pub fn round_ties_even_i64(value: f64) -> i64 {
35 let floor = value.floor();
36 let fraction = value - floor;
37 let rounded = if fraction < 0.5 {
38 floor
39 } else if fraction > 0.5 {
40 floor + 1.0
41 } else if (floor as i64) & 1 == 0 {
42 floor
43 } else {
44 floor + 1.0
45 };
46 rounded as i64
47}
48
49/// Whether a delay is negative at the writers' resolution: below zero after
50/// the half-to-even rounding to whole 48 kHz samples. A smaller negative
51/// value is float noise that every writer emits as no delay (#92 TD-94).
52pub fn is_negative_delay(milliseconds: f64) -> bool {
53 round_ties_even_i64(milliseconds * f64::from(SAMPLE_RATE) / 1000.0) < 0
54}
55
56/// The LFE input the export includes, as upstream's `make_plan` takes it.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum LfeChoice {
59 /// No LFE input.
60 Omit,
61 /// An LFE input with this gain, then an LR24 low-pass, summed into every
62 /// sub bus. It is an input only when the chain has subs.
63 Include {
64 /// LFE gain in dB.
65 gain_db: f64,
66 /// LFE low-pass frequency in hertz.
67 lp_hz: f64,
68 },
69}
70
71impl LfeChoice {
72 /// Upstream's Dolby LFE: +10 dB, then a 120 Hz low-pass.
73 pub const DOLBY: Self = Self::Include {
74 gain_db: LFE_GAIN_DB,
75 lp_hz: LFE_LP_HZ,
76 };
77}
78
79/// Why a chain and its MIMO paths cannot be exported. Each message names
80/// the offending field the way the writers' messages did, for example
81/// `main.L.delay_ms` or `mimo.paths[2].taps_low`.
82#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
83pub enum GraphError {
84 /// An identifier is not ASCII.
85 #[error("non-ASCII export identifier: {0}")]
86 NonAscii(String),
87 /// A number is NaN or infinite.
88 #[error("non-finite export value: {0}")]
89 NonFinite(String),
90 /// Any other broken rule, or a plan its transfer functions cannot read.
91 #[error("invalid export graph: {0}")]
92 Invalid(String),
93}
94
95/// MIMO support paths that feed one output through the same protective
96/// high-pass and filter family, so a writer may apply that high-pass (and an
97/// IIR family's Fsiso low-pass) once, after their sum (F13).
98///
99/// The key is the support output, the family and the exact low edge (its bit
100/// pattern). A writer that writes the high-pass at a coarser precision joins
101/// the groups whose written cutoff coincides; how a group's delays split
102/// into a shared and a private part is also each writer's own.
103#[derive(Debug, Clone, PartialEq)]
104pub struct SupportGroup {
105 /// Support output, an index into [`FilterPlan::outputs`] (mains, then subs).
106 pub output: usize,
107 /// Whether the paths are FIR (`Fir` or `FirLowRate`) rather than IIR.
108 pub fir: bool,
109 /// The members' common [`MimoPath::low_hz`](secs_types::chain::MimoPath::low_hz).
110 pub low_hz: f64,
111 /// Path indices into [`MimoPaths::paths`] (and [`FilterPlan::mimo`]), ascending.
112 pub members: Vec<usize>,
113}
114
115/// A validated signal chain with its MIMO paths and the topology every
116/// export writer shares: inputs, outputs, per-role operations, sub feeds,
117/// MIMO feeds and support groups. Building it is the only validation.
118#[derive(Debug, Clone, PartialEq)]
119pub struct ExportGraph {
120 chain: SignalChain,
121 mimo: Option<MimoPaths>,
122 plan: FilterPlan,
123 support_groups: Vec<SupportGroup>,
124}
125
126impl ExportGraph {
127 /// Validate `chain`, `mimo` and `lfe`, then build the plan and the
128 /// support groups.
129 ///
130 /// The rules: 48 kHz; every number finite; every role and MIMO
131 /// identifier non-empty ASCII letters and digits; no duplicate role and
132 /// no role named `LFE`; a sub mode in ASCII; polarities `1` or `-1`;
133 /// delays not negative after rounding to whole samples
134 /// ([`is_negative_delay`]); a MIMO input is a main and a support an
135 /// output; a low-rate FIR at most 48 kHz whose 48 kHz expansion fits.
136 pub fn build(
137 chain: &SignalChain,
138 mimo: Option<&MimoPaths>,
139 lfe: LfeChoice,
140 ) -> Result<Self, GraphError> {
141 validate::graph(chain, mimo, lfe)?;
142 let plan = plan::make(chain, mimo, lfe);
143 let support_groups = support_groups(&plan.outputs, mimo);
144 Ok(Self {
145 chain: chain.clone(),
146 mimo: mimo.cloned(),
147 plan,
148 support_groups,
149 })
150 }
151
152 /// The validated chain.
153 pub fn chain(&self) -> &SignalChain {
154 &self.chain
155 }
156
157 /// The validated MIMO paths, when the export includes MIMO.
158 pub fn mimo(&self) -> Option<&MimoPaths> {
159 self.mimo.as_ref()
160 }
161
162 /// The upstream `make_plan` topology.
163 pub fn plan(&self) -> &FilterPlan {
164 &self.plan
165 }
166
167 /// The MIMO support groups, in order of their first path.
168 pub fn support_groups(&self) -> &[SupportGroup] {
169 &self.support_groups
170 }
171}
172
173fn support_groups(outputs: &[String], mimo: Option<&MimoPaths>) -> Vec<SupportGroup> {
174 let mut groups: Vec<SupportGroup> = Vec::new();
175 for (index, path) in mimo.map_or(&[][..], |mimo| &mimo.paths).iter().enumerate() {
176 let output = outputs
177 .iter()
178 .position(|role| *role == path.support)
179 .expect("validated support");
180 let fir = path.filter.is_fir();
181 if let Some(group) = groups.iter_mut().find(|group| {
182 group.output == output
183 && group.fir == fir
184 && group.low_hz.to_bits() == path.low_hz.to_bits()
185 }) {
186 group.members.push(index);
187 } else {
188 groups.push(SupportGroup {
189 output,
190 fir,
191 low_hz: path.low_hz,
192 members: vec![index],
193 });
194 }
195 }
196 groups
197}