Skip to main content

secs_graph/
design.rs

1//! Stable identity for one exported correction design.
2
3use crate::{LfeChoice, SAMPLE_RATE};
4use secs_types::chain::{MimoPaths, SignalChain};
5use serde::Serialize;
6use sha2::{Digest, Sha256};
7
8#[derive(Serialize)]
9struct CanonicalDesign<'a> {
10    schema: u32,
11    sample_rate: u32,
12    chain: &'a SignalChain,
13    mimo: Option<&'a MimoPaths>,
14    lfe: Option<CanonicalLfe>,
15}
16
17#[derive(Serialize)]
18struct CanonicalLfe {
19    gain_db: f64,
20    lp_hz: f64,
21}
22
23/// Lowercase hexadecimal SHA-256 of the canonical design JSON.
24///
25/// The canonical object's field order is fixed by these structures. The
26/// workspace's `serde_json` configuration emits shortest round-trip floats.
27pub fn design_id(
28    chain: &SignalChain,
29    mimo: Option<&MimoPaths>,
30    lfe: LfeChoice,
31) -> Result<String, serde_json::Error> {
32    let lfe = match lfe {
33        LfeChoice::Omit => None,
34        LfeChoice::Include { gain_db, lp_hz } => Some(CanonicalLfe { gain_db, lp_hz }),
35    };
36    let canonical = CanonicalDesign {
37        schema: 1,
38        sample_rate: SAMPLE_RATE,
39        chain,
40        mimo,
41        lfe,
42    };
43    let digest = Sha256::digest(serde_json::to_vec(&canonical)?);
44    Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect())
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use secs_types::chain::{MainChannel, MimoFilter, MimoPath};
51
52    fn chain() -> SignalChain {
53        SignalChain {
54            input_hp_hz: None,
55            sample_rate: SAMPLE_RATE,
56            mains: vec![MainChannel {
57                role: "L".into(),
58                input_peq: vec![(80.0, 1.0, -2.0)],
59                fir: vec![1.0, 0.25],
60                gain_db: 0.0,
61                delay_ms: 1.0,
62                xo_hz: Some(80.0),
63                top_peq: Vec::new(),
64                align_delay_ms: 0.0,
65            }],
66            subs: Vec::new(),
67            sub_bus_peq: Vec::new(),
68            main_extra_ms: 0.0,
69            lift_ms: 0.0,
70            latency_ms: 1.0,
71        }
72    }
73
74    #[test]
75    fn design_id_is_stable_and_changes_with_taps_delays_and_mimo() {
76        let chain = chain();
77        let first = design_id(&chain, None, LfeChoice::DOLBY).unwrap();
78        assert_eq!(first, design_id(&chain, None, LfeChoice::DOLBY).unwrap());
79        assert_eq!(first.len(), 64);
80        assert!(
81            first
82                .bytes()
83                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
84        );
85
86        let mut tap = chain.clone();
87        tap.mains[0].fir[1] += 1e-12;
88        assert_ne!(first, design_id(&tap, None, LfeChoice::DOLBY).unwrap());
89
90        let mut delay = chain.clone();
91        delay.mains[0].delay_ms += 1.0 / 48.0;
92        assert_ne!(first, design_id(&delay, None, LfeChoice::DOLBY).unwrap());
93
94        let mimo = MimoPaths {
95            fsiso: 120.0,
96            paths: vec![MimoPath {
97                input: "L".into(),
98                support: "L".into(),
99                filter: MimoFilter::Iir {
100                    gain_db: -6.0,
101                    delay_ms: 0.0,
102                    polarity: 1,
103                    peq: Vec::new(),
104                    allpass: Vec::new(),
105                },
106                low_hz: 40.0,
107                delay_ms: 0.0,
108            }],
109        };
110        assert_ne!(
111            first,
112            design_id(&chain, Some(&mimo), LfeChoice::DOLBY).unwrap()
113        );
114    }
115}