Skip to main content

rust_motion_animation/
chop.rs

1//! TouchDesigner-inspired CHOP (Channel Operator) primitives for programmatic motion.
2//!
3//! Provides:
4//! - [`LfoChop`]: Low-frequency oscillator waveforms (Sine, Square, Triangle, Ramp, Pulse).
5//! - [`LagChop`]: Inertial lag smoothing filter for organic motion response.
6//! - [`MathChop`]: Channel combinations and remapping.
7//! - [`AudioEnvelopeChop`]: Audio-reactive envelope tracking with attack and release dynamics.
8
9use std::f64::consts::PI;
10
11/// Waveform shape for [`LfoChop`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum LfoWave {
14    #[default]
15    Sine,
16    Triangle,
17    Square,
18    Ramp,
19    Pulse,
20}
21
22/// Low-Frequency Oscillator (LFO) Channel Operator.
23///
24/// Generates continuous periodic modulation curves synchronized with composition timeline FPS.
25#[derive(Debug, Clone, PartialEq)]
26pub struct LfoChop {
27    pub wave: LfoWave,
28    pub freq_hz: f64,
29    pub amplitude: f64,
30    pub offset: f64,
31    pub phase: f64,
32    pub pulse_width: f64,
33}
34
35impl Default for LfoChop {
36    fn default() -> Self {
37        Self {
38            wave: LfoWave::Sine,
39            freq_hz: 1.0,
40            amplitude: 1.0,
41            offset: 0.0,
42            phase: 0.0,
43            pulse_width: 0.5,
44        }
45    }
46}
47
48impl LfoChop {
49    pub fn new(wave: LfoWave, freq_hz: f64) -> Self {
50        Self {
51            wave,
52            freq_hz,
53            ..Default::default()
54        }
55    }
56
57    pub fn with_amplitude(mut self, amp: f64) -> Self {
58        self.amplitude = amp;
59        self
60    }
61
62    pub fn with_offset(mut self, offset: f64) -> Self {
63        self.offset = offset;
64        self
65    }
66
67    pub fn with_phase(mut self, phase: f64) -> Self {
68        self.phase = phase;
69        self
70    }
71
72    /// Sample the oscillator value at the given composition `frame` and `fps`.
73    pub fn sample(&self, frame: f64, fps: f64) -> f64 {
74        let t = frame / fps.max(1.0);
75        let cycle = (t * self.freq_hz + self.phase).rem_euclid(1.0);
76
77        let raw = match self.wave {
78            LfoWave::Sine => (cycle * 2.0 * PI).sin(),
79            LfoWave::Triangle => {
80                if cycle < 0.5 {
81                    -1.0 + 4.0 * cycle
82                } else {
83                    3.0 - 4.0 * cycle
84                }
85            }
86            LfoWave::Square => {
87                if cycle < self.pulse_width {
88                    1.0
89                } else {
90                    -1.0
91                }
92            }
93            LfoWave::Ramp => -1.0 + 2.0 * cycle,
94            LfoWave::Pulse => {
95                if cycle < self.pulse_width {
96                    1.0
97                } else {
98                    0.0
99                }
100            }
101        };
102
103        self.offset + self.amplitude * raw
104    }
105}
106
107/// Inertial lag smoothing filter matching TouchDesigner's `Lag CHOP`.
108///
109/// Smooths jagged or abrupt value steps with asymmetric rise/fall inertia.
110#[derive(Debug, Clone, PartialEq)]
111pub struct LagChop {
112    pub lag_up: f64,
113    pub lag_down: f64,
114}
115
116impl Default for LagChop {
117    fn default() -> Self {
118        Self {
119            lag_up: 0.2,
120            lag_down: 0.2,
121        }
122    }
123}
124
125impl LagChop {
126    pub fn new(lag_up: f64, lag_down: f64) -> Self {
127        Self { lag_up, lag_down }
128    }
129
130    /// Update smoothed state towards `target` given time step `dt` seconds.
131    pub fn filter(&self, current: f64, target: f64, dt: f64) -> f64 {
132        let lag = if target >= current {
133            self.lag_up.max(0.0001)
134        } else {
135            self.lag_down.max(0.0001)
136        };
137
138        let alpha = 1.0 - (-dt.max(0.0) / lag).exp();
139        current + (target - current) * alpha.clamp(0.0, 1.0)
140    }
141}
142
143/// Audio envelope follower for reactive visual parameters.
144#[derive(Debug, Clone, PartialEq)]
145pub struct AudioEnvelopeChop {
146    pub attack: f64,
147    pub release: f64,
148}
149
150impl Default for AudioEnvelopeChop {
151    fn default() -> Self {
152        Self {
153            attack: 0.01,  // 10ms fast rise
154            release: 0.15, // 150ms smooth decay
155        }
156    }
157}
158
159impl AudioEnvelopeChop {
160    pub fn new(attack: f64, release: f64) -> Self {
161        Self { attack, release }
162    }
163
164    /// Track audio envelope from instantaneous audio amplitude sample.
165    pub fn track(&self, current_env: f64, sample_amp: f64, dt: f64) -> f64 {
166        let target = sample_amp.abs();
167        let rate = if target > current_env {
168            self.attack.max(0.0001)
169        } else {
170            self.release.max(0.0001)
171        };
172
173        let alpha = 1.0 - (-dt.max(0.0) / rate).exp();
174        current_env + (target - current_env) * alpha.clamp(0.0, 1.0)
175    }
176}
177
178/// Operation for combining multi-channel signals in [`MathChop`].
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum MathOp {
181    Add,
182    Multiply,
183    Average,
184    Minimum,
185    Maximum,
186}
187
188/// Math Channel Operator for signal combining and remapping.
189pub struct MathChop;
190
191impl MathChop {
192    /// Remap a value from input range to output range, with optional clamping.
193    pub fn map_range(val: f64, in_range: (f64, f64), out_range: (f64, f64), clamp: bool) -> f64 {
194        let (in_min, in_max) = in_range;
195        let (out_min, out_max) = out_range;
196        let span = in_max - in_min;
197        if span.abs() < 1e-9 {
198            return out_min;
199        }
200
201        let normalized = (val - in_min) / span;
202        let t = if clamp {
203            normalized.clamp(0.0, 1.0)
204        } else {
205            normalized
206        };
207
208        out_min + (out_max - out_min) * t
209    }
210
211    /// Combine multiple channels into a single channel output.
212    pub fn combine(channels: &[f64], op: MathOp) -> f64 {
213        if channels.is_empty() {
214            return 0.0;
215        }
216
217        match op {
218            MathOp::Add => channels.iter().sum(),
219            MathOp::Multiply => channels.iter().product(),
220            MathOp::Average => channels.iter().sum::<f64>() / channels.len() as f64,
221            MathOp::Minimum => channels.iter().copied().fold(f64::INFINITY, f64::min),
222            MathOp::Maximum => channels.iter().copied().fold(f64::NEG_INFINITY, f64::max),
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_lfo_sine_sampling() {
233        let lfo = LfoChop::new(LfoWave::Sine, 1.0); // 1 Hz
234        let s0 = lfo.sample(0.0, 30.0);
235        let s15 = lfo.sample(7.5, 30.0); // 1/4 period -> peak 1.0
236        let s30 = lfo.sample(15.0, 30.0); // 1/2 period -> 0.0
237
238        assert!(s0.abs() < 1e-4);
239        assert!((s15 - 1.0).abs() < 1e-4);
240        assert!(s30.abs() < 1e-4);
241    }
242
243    #[test]
244    fn test_lag_filter_convergence() {
245        let lag = LagChop::new(0.1, 0.1);
246        let mut val = 0.0;
247        // Step to 1.0 across 10 frames at 30 fps (dt = 0.0333s)
248        for _ in 0..10 {
249            val = lag.filter(val, 1.0, 1.0 / 30.0);
250        }
251        assert!(val > 0.9, "Lag filter should converge towards 1.0: {val}");
252    }
253
254    #[test]
255    fn test_math_chop_range_remap() {
256        let mapped = MathChop::map_range(5.0, (0.0, 10.0), (100.0, 200.0), true);
257        assert_eq!(mapped, 150.0);
258    }
259}