Skip to main content

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}