Skip to main content

wavecraft_dsp/combinators/
chain.rs

1//! Chain combinator for serial processor composition.
2
3use crate::traits::{ParamSpec, Processor, ProcessorParams, Transport};
4
5/// Combines two processors in series: A → B.
6///
7/// Audio flows through processor A, then through processor B.
8/// Parameters from both processors are merged.
9pub struct Chain<A, B> {
10    pub first: A,
11    pub second: B,
12}
13
14impl<A, B> Default for Chain<A, B>
15where
16    A: Default,
17    B: Default,
18{
19    fn default() -> Self {
20        Self {
21            first: A::default(),
22            second: B::default(),
23        }
24    }
25}
26
27/// Combined parameters for chained processors.
28///
29/// Merges parameter specs from both processors, prefixing IDs to avoid collisions.
30pub struct ChainParams<PA, PB> {
31    pub first: PA,
32    pub second: PB,
33}
34
35impl<PA, PB> Default for ChainParams<PA, PB>
36where
37    PA: Default,
38    PB: Default,
39{
40    fn default() -> Self {
41        Self {
42            first: PA::default(),
43            second: PB::default(),
44        }
45    }
46}
47
48impl<PA, PB> ProcessorParams for ChainParams<PA, PB>
49where
50    PA: ProcessorParams,
51    PB: ProcessorParams,
52{
53    fn param_specs() -> &'static [ParamSpec] {
54        // WORKAROUND FOR HOT-RELOAD HANG:
55        //
56        // Do NOT use OnceLock or any locking primitive here. On macOS, when the
57        // subprocess calls dlopen() → wavecraft_get_params_json() → param_specs(),
58        // initialization of OnceLock statics can hang indefinitely (30s timeout).
59        //
60        // Instead, we allocate and leak the merged specs on EVERY call. This is
61        // acceptable because:
62        // 1. param_specs() is called at most once per plugin load (startup only)
63        // 2. The leak is ~hundreds of bytes (not per-sample, not per-frame)
64        // 3. Plugin lifetime = process lifetime (no meaningful leak)
65        // 4. Hot-reload works correctly (no 30s hang)
66        //
67        // This is a pragmatic trade-off: small memory leak vs. broken hot-reload.
68        // Future work: investigate root cause of OnceLock hang on macOS dlopen.
69
70        let first_specs = PA::param_specs();
71        let second_specs = PB::param_specs();
72
73        let mut merged = Vec::with_capacity(first_specs.len() + second_specs.len());
74
75        // Add first processor's params
76        for spec in first_specs {
77            merged.push(ParamSpec {
78                name: spec.name,
79                id_suffix: spec.id_suffix,
80                range: spec.range.clone(),
81                default: spec.default,
82                unit: spec.unit,
83                group: spec.group,
84            });
85        }
86
87        // Add second processor's params
88        for spec in second_specs {
89            merged.push(ParamSpec {
90                name: spec.name,
91                id_suffix: spec.id_suffix,
92                range: spec.range.clone(),
93                default: spec.default,
94                unit: spec.unit,
95                group: spec.group,
96            });
97        }
98
99        // Leak to get 'static reference (intentional - see comment above)
100        Box::leak(merged.into_boxed_slice())
101    }
102}
103
104impl<A, B> Processor for Chain<A, B>
105where
106    A: Processor,
107    B: Processor,
108{
109    type Params = ChainParams<A::Params, B::Params>;
110
111    fn process(&mut self, buffer: &mut [&mut [f32]], transport: &Transport, params: &Self::Params) {
112        // Process first, then second (serial chain)
113        self.first.process(buffer, transport, &params.first);
114        self.second.process(buffer, transport, &params.second);
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::builtins::{GainDsp, GainParams, PassthroughDsp, PassthroughParams};
122
123    #[test]
124    fn test_chain_processes_in_order() {
125        let mut chain = Chain {
126            first: GainDsp::default(),
127            second: GainDsp::default(),
128        };
129
130        let mut left = [1.0_f32, 1.0_f32];
131        let mut right = [1.0_f32, 1.0_f32];
132        let mut buffer = [&mut left[..], &mut right[..]];
133
134        let transport = Transport::default();
135        let params = ChainParams {
136            first: GainParams { level: 0.5 },
137            second: GainParams { level: 2.0 },
138        };
139
140        chain.process(&mut buffer, &transport, &params);
141
142        // Expected: 1.0 * 0.5 * 2.0 = 1.0
143        assert!((buffer[0][0] - 1.0_f32).abs() < 1e-6);
144        assert!((buffer[1][0] - 1.0_f32).abs() < 1e-6);
145    }
146
147    #[test]
148    fn test_chain_with_passthrough() {
149        let mut chain = Chain {
150            first: PassthroughDsp,
151            second: GainDsp::default(),
152        };
153
154        let mut left = [2.0_f32, 2.0_f32];
155        let mut right = [2.0_f32, 2.0_f32];
156        let mut buffer = [&mut left[..], &mut right[..]];
157
158        let transport = Transport::default();
159        let params = ChainParams {
160            first: PassthroughParams,
161            second: GainParams { level: 0.5 },
162        };
163
164        chain.process(&mut buffer, &transport, &params);
165
166        // Expected: 2.0 * 1.0 * 0.5 = 1.0
167        assert!((buffer[0][0] - 1.0_f32).abs() < 1e-6);
168    }
169
170    #[test]
171    fn test_chain_params_merge() {
172        let specs = <ChainParams<GainParams, GainParams>>::param_specs();
173        assert_eq!(specs.len(), 2); // Both gain params
174
175        // Both should have "Level" name
176        assert_eq!(specs[0].name, "Level");
177        assert_eq!(specs[1].name, "Level");
178    }
179
180    #[test]
181    fn test_chain_default() {
182        let _chain: Chain<GainDsp, PassthroughDsp> = Chain::default();
183        let _params: ChainParams<GainParams, PassthroughParams> = ChainParams::default();
184    }
185}