Skip to main content

pedalkernel/compiler/components/
modulation.rs

1//! Modulation component structs: Lfo, EnvelopeFollower, Photocoupler.
2
3use crate::compiler::component::{
4    Component, ComponentEdge, ControlParam, ControlParamKind, EdgeKind, GraphRole, ModulationSink,
5    ModulationSinkKind, PinConfig, StampResult,
6};
7use crate::compiler::dyn_node::DynNode;
8use crate::compiler::validate::Severity;
9use crate::dsl::{LfoWaveformDsl, PhotocouplerType};
10use crate::elements::{Photocoupler as PhotocouplerElem, PhotocouplerModel};
11use crate::tree::MnaSystem;
12
13use super::impl_component_dyn;
14
15// ═══════════════════════════════════════════════════════════════════════════
16// Lfo
17// ═══════════════════════════════════════════════════════════════════════════
18
19#[derive(Debug, Clone, PartialEq)]
20pub struct Lfo {
21    pub waveform: LfoWaveformDsl,
22    pub timing_r: f64,
23    pub timing_c: f64,
24}
25
26impl Component for Lfo {
27    impl_component_dyn!();
28
29    fn type_tag(&self) -> &'static str {
30        "LFO"
31    }
32
33    fn is_passive(&self) -> bool {
34        false
35    }
36
37    fn pin_config(&self) -> PinConfig {
38        PinConfig {
39            valid_pins: &["out", "output", "rate"],
40            aliases: &[("out", "output")],
41        }
42    }
43
44    fn graph_role(&self) -> GraphRole {
45        GraphRole::Virtual
46    }
47
48    fn stamp_mna(
49        &self,
50        _comp_id: &str,
51        _n1: Option<usize>,
52        _n2: Option<usize>,
53        _mna: &mut MnaSystem,
54        _sample_rate: f64,
55    ) -> StampResult {
56        StampResult::Skip
57    }
58
59    fn validate_values(&self, comp_id: &str) -> Vec<(Severity, String)> {
60        let mut w = Vec::new();
61        if self.timing_r <= 0.0 || self.timing_c <= 0.0 {
62            w.push((
63                Severity::Error,
64                format!(
65                    "LFO '{}' has non-positive timing values (R={:.2}, C={:.2e})",
66                    comp_id, self.timing_r, self.timing_c
67                ),
68            ));
69        }
70        let freq = 1.0 / (2.0 * std::f64::consts::PI * self.timing_r * self.timing_c);
71        if freq > 100.0 {
72            w.push((
73                Severity::Warning,
74                format!(
75                    "LFO '{}' base frequency is {:.1} Hz \u{2014} unusually fast for modulation",
76                    comp_id, freq
77                ),
78            ));
79        }
80        w
81    }
82
83    fn controls(&self) -> Vec<ControlParam> {
84        vec![
85            ControlParam {
86                name: "rate",
87                kind: ControlParamKind::LfoRate,
88            },
89            ControlParam {
90                name: "depth",
91                kind: ControlParamKind::LfoDepth,
92            },
93        ]
94    }
95
96    fn footprint_ref(&self) -> (&'static str, &'static str) {
97        ("", "LFO")
98    }
99
100    fn symbol_name(&self) -> &'static str {
101        "lfo"
102    }
103    fn layout_class(&self) -> &'static str {
104        "lfo"
105    }
106
107    fn is_modulation_source(&self) -> bool {
108        true
109    }
110}
111
112// ═══════════════════════════════════════════════════════════════════════════
113// EnvelopeFollower
114// ═══════════════════════════════════════════════════════════════════════════
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct EnvelopeFollower {
118    pub attack_r: f64,
119    pub attack_c: f64,
120    pub release_r: f64,
121    pub release_c: f64,
122    pub sensitivity_r: f64,
123}
124
125impl Component for EnvelopeFollower {
126    impl_component_dyn!();
127
128    fn type_tag(&self) -> &'static str {
129        "envelope follower"
130    }
131
132    fn is_passive(&self) -> bool {
133        false
134    }
135
136    fn pin_config(&self) -> PinConfig {
137        PinConfig {
138            valid_pins: &["out", "output", "in", "input"],
139            aliases: &[("in", "input"), ("out", "output")],
140        }
141    }
142
143    fn graph_role(&self) -> GraphRole {
144        GraphRole::Virtual
145    }
146
147    fn stamp_mna(
148        &self,
149        _comp_id: &str,
150        _n1: Option<usize>,
151        _n2: Option<usize>,
152        _mna: &mut MnaSystem,
153        _sample_rate: f64,
154    ) -> StampResult {
155        StampResult::Skip
156    }
157
158    fn validate_values(&self, comp_id: &str) -> Vec<(Severity, String)> {
159        let mut w = Vec::new();
160        if self.attack_r <= 0.0
161            || self.attack_c <= 0.0
162            || self.release_r <= 0.0
163            || self.release_c <= 0.0
164            || self.sensitivity_r <= 0.0
165        {
166            w.push((
167                Severity::Error,
168                format!(
169                    "EnvelopeFollower '{}' has non-positive parameter value(s)",
170                    comp_id
171                ),
172            ));
173        }
174        w
175    }
176
177    fn footprint_ref(&self) -> (&'static str, &'static str) {
178        ("", "ENV")
179    }
180
181    fn symbol_name(&self) -> &'static str {
182        "envelope"
183    }
184    fn layout_class(&self) -> &'static str {
185        "envelope"
186    }
187
188    fn is_modulation_source(&self) -> bool {
189        true
190    }
191}
192
193// ═══════════════════════════════════════════════════════════════════════════
194// PhotocouplerComp
195// ═══════════════════════════════════════════════════════════════════════════
196
197#[derive(Debug, Clone, PartialEq)]
198pub struct PhotocouplerComp {
199    pub coupler_type: PhotocouplerType,
200}
201
202impl Component for PhotocouplerComp {
203    impl_component_dyn!();
204
205    fn type_tag(&self) -> &'static str {
206        "photocoupler"
207    }
208
209    fn is_passive(&self) -> bool {
210        false
211    }
212
213    fn pin_config(&self) -> PinConfig {
214        // The LDR side is the electrical `a`/`b` port (the audio-network leaf).
215        // The LED side is a galvanically-ISOLATED electrical port-pair
216        // `led.a`/`led.b` (the sidechain-network drive winding). `led` is kept
217        // as a back-compat alias for `led.a` so the legacy `-> PC1.led`
218        // modulation-sink wiring still parses; faithful circuits wire both
219        // `led.a` and `led.b`. The two sides never share a node — the optical
220        // coupling is non-electrical (Phase 3).
221        PinConfig {
222            valid_pins: &["a", "b", "led", "led.a", "led.b"],
223            aliases: &[("led", "led.a")],
224        }
225    }
226
227    fn modulation_pins(&self) -> &'static [&'static str] {
228        &["led", "led.a", "led.b"]
229    }
230
231    fn graph_role(&self) -> GraphRole {
232        // Only the LDR side (`a`/`b`) becomes a conductive circuit edge. The
233        // LED edge (`led.a`/`led.b`) is declared `Behavioral` in `edges()` and
234        // is deliberately NOT created here, so it can never union the LED-side
235        // network into the LDR-side group (the non-merge isolation barrier).
236        GraphRole::Edge {
237            pin_a: "a",
238            pin_b: "b",
239        }
240    }
241
242    fn stamp_mna(
243        &self,
244        _comp_id: &str,
245        _n1: Option<usize>,
246        _n2: Option<usize>,
247        _mna: &mut MnaSystem,
248        _sample_rate: f64,
249    ) -> StampResult {
250        StampResult::Skip
251    }
252
253    fn make_leaf(&self, comp_id: &str, sample_rate: f64) -> Option<DynNode> {
254        let model = match self.coupler_type {
255            PhotocouplerType::Vtl5c3 => PhotocouplerModel::vtl5c3(),
256            PhotocouplerType::Vtl5c1 => PhotocouplerModel::vtl5c1(),
257            PhotocouplerType::Nsl32 => PhotocouplerModel::nsl32(),
258            PhotocouplerType::T4b => PhotocouplerModel::t4b(),
259        };
260        Some(DynNode::PhotocouplerNode(
261            comp_id.to_string(),
262            PhotocouplerElem::new(model, sample_rate as crate::Wave),
263        ))
264    }
265
266    fn is_variable(&self) -> bool {
267        true
268    }
269
270    fn edges(&self) -> Vec<ComponentEdge> {
271        // The LDR edge MUST stay first: `effective_edge_kind` reads
272        // `edges().first()` for the single conductive graph edge that
273        // `GraphRole::Edge{a,b}` creates, and the LDR side is `Linear`.
274        //
275        // The LED edge is `Behavioral` (the BBD/delay/spring pattern,
276        // delay.rs:59-66). `find_flow_groups` only ever claims
277        // Linear/Reactive/Nonlinear/Vccs/Vcvs edges from the graph, and the
278        // graph builder (`GraphRole::Edge`) never instantiates this edge at
279        // all, so it cannot merge the LED-side (sidechain) network with the
280        // LDR-side (audio) network. This is the non-merge isolation barrier
281        // that mirrors the optocoupler's real galvanic isolation.
282        vec![
283            ComponentEdge {
284                pin_a: "a",
285                pin_b: "b",
286                kind: EdgeKind::Linear,
287                port_group: None,
288            },
289            ComponentEdge {
290                pin_a: "led.a",
291                pin_b: "led.b",
292                kind: EdgeKind::Behavioral,
293                port_group: None,
294            },
295        ]
296    }
297
298    fn signal_adjacencies(&self) -> Vec<(&'static str, &'static str)> {
299        // Only the LDR side carries signal. The LED side is galvanically
300        // isolated, so it must NOT appear as a conductive adjacency (that would
301        // feed `conductive_pin_pairs`/unity-follower detection and validator
302        // reachability across the isolation barrier). Override the default,
303        // which would otherwise fan `a -> led.*` out of `valid_pins`.
304        vec![("a", "b")]
305    }
306
307    fn modulation_sink(&self, pin: &str) -> Option<ModulationSink> {
308        match pin {
309            "led" => Some(ModulationSink {
310                target_kind: ModulationSinkKind::PhotocouplerLed,
311                bias: 0.5,
312                range: 0.5,
313            }),
314            _ => None,
315        }
316    }
317
318    fn footprint_ref(&self) -> (&'static str, &'static str) {
319        ("Isolator:PC817", "OC")
320    }
321
322    fn symbol_name(&self) -> &'static str {
323        "photocoupler"
324    }
325    fn layout_class(&self) -> &'static str {
326        "photocoupler"
327    }
328}