Skip to main content

pot_head/
pothead.rs

1use num_traits::AsPrimitive;
2
3use crate::config::{Config, ConfigError};
4use crate::filters::NoiseFilter;
5use crate::state::State;
6
7#[cfg(feature = "grab-mode")]
8use crate::grab_mode::GrabMode;
9
10use crate::filters::EmaFilter;
11
12#[cfg(feature = "moving-average")]
13use crate::filters::MovingAvgFilter;
14
15pub struct PotHead<TIn: 'static, TOut: 'static = TIn> {
16    config: &'static Config<TIn, TOut>,
17    state: State<f32>,
18}
19
20impl<TIn, TOut> PotHead<TIn, TOut>
21where
22    TIn: 'static + Copy + PartialOrd + AsPrimitive<f32>,
23    TOut: 'static + Copy + PartialOrd + AsPrimitive<f32>,
24    f32: AsPrimitive<TOut>,
25{
26    pub fn new(config: &'static Config<TIn, TOut>) -> Result<Self, ConfigError> {
27        config.validate()?;
28
29        let mut state = State::default();
30
31        // Initialize filter state based on configuration
32        if matches!(config.filter, NoiseFilter::ExponentialMovingAverage { .. }) {
33            state.ema_filter = Some(EmaFilter::new());
34        }
35
36        #[cfg(feature = "moving-average")]
37        if let NoiseFilter::MovingAverage { window_size } = config.filter {
38            state.ma_filter = Some(MovingAvgFilter::new(window_size));
39        }
40
41        Ok(Self { config, state })
42    }
43
44    pub fn config(&self) -> &Config<TIn, TOut> {
45        self.config
46    }
47
48    pub fn process(&mut self, input: TIn) -> TOut {
49        // Normalize input to 0.0..1.0
50        let normalized = self.normalize_input(input);
51
52        // Apply noise filter
53        let filtered = self.apply_filter(normalized);
54
55        // Apply response curve
56        let curved = self.config.curve.apply(filtered);
57
58        // Apply hysteresis
59        let hysteresis_applied = self
60            .config
61            .hysteresis
62            .apply(curved, &mut self.state.hysteresis);
63
64        // Capture physical position BEFORE snap zones and grab mode
65        #[cfg(feature = "grab-mode")]
66        {
67            self.state.physical_position = hysteresis_applied;
68        }
69
70        // Apply snap zones
71        let snapped = self.apply_snap_zones(hysteresis_applied);
72
73        // Apply grab mode logic
74        #[cfg(feature = "grab-mode")]
75        let output = self.apply_grab_mode(snapped);
76
77        #[cfg(not(feature = "grab-mode"))]
78        let output = snapped;
79
80        // Update last output for dead zones
81        self.state.last_output = output;
82
83        // Denormalize to output range
84        self.denormalize_output(output)
85    }
86
87    fn apply_filter(&mut self, value: f32) -> f32 {
88        match &self.config.filter {
89            NoiseFilter::None => value,
90
91            NoiseFilter::ExponentialMovingAverage { alpha } => {
92                if let Some(ref mut filter) = self.state.ema_filter {
93                    filter.apply(value, *alpha)
94                } else {
95                    value
96                }
97            }
98
99            #[cfg(feature = "moving-average")]
100            NoiseFilter::MovingAverage { .. } => {
101                if let Some(ref mut filter) = self.state.ma_filter {
102                    filter.apply(value)
103                } else {
104                    value
105                }
106            }
107        }
108    }
109
110    fn apply_snap_zones(&self, value: f32) -> f32 {
111        // Process zones in order - first match wins
112        for zone in self.config.snap_zones {
113            if zone.contains(value) {
114                return zone.apply(value, self.state.last_output);
115            }
116        }
117        value // No zone matched
118    }
119
120    fn normalize_input(&self, input: TIn) -> f32 {
121        let input_f = input.as_();
122        let min_f = self.config.input_min.as_();
123        let max_f = self.config.input_max.as_();
124
125        // Clamp input to valid range
126        let clamped = if input_f < min_f {
127            min_f
128        } else if input_f > max_f {
129            max_f
130        } else {
131            input_f
132        };
133
134        // Normalize to 0.0..1.0
135        // Safe division: validation ensures max_f > min_f
136        (clamped - min_f) / (max_f - min_f)
137    }
138
139    #[cfg(feature = "grab-mode")]
140    fn normalize_output(&self, value: TOut) -> f32 {
141        let value_f = value.as_();
142        let min_f = self.config.output_min.as_();
143        let max_f = self.config.output_max.as_();
144        (value_f - min_f) / (max_f - min_f)
145    }
146
147    fn denormalize_output(&self, normalized: f32) -> TOut {
148        let min_f = self.config.output_min.as_();
149        let max_f = self.config.output_max.as_();
150
151        let output_f = min_f + normalized * (max_f - min_f);
152        output_f.as_()
153    }
154
155    #[cfg(feature = "grab-mode")]
156    fn apply_grab_mode(&mut self, value: f32) -> f32 {
157        match self.config.grab_mode {
158            GrabMode::None => {
159                // Direct control, no grab logic
160                self.state.grabbed = true; // Always consider grabbed
161                self.state.virtual_value = value;
162                value
163            }
164
165            GrabMode::Pickup => {
166                if !self.state.grabbed {
167                    // Check if pot crosses virtual value from below
168                    if value >= self.state.virtual_value {
169                        self.state.grabbed = true;
170                    } else {
171                        // Hold virtual value until grabbed
172                        return self.state.virtual_value;
173                    }
174                }
175                // Pot is grabbed - update virtual value
176                self.state.virtual_value = value;
177                value
178            }
179
180            GrabMode::PassThrough => {
181                if !self.state.grabbed {
182                    // First read after grab reset - initialize last_physical position
183                    if !self.state.passthrough_initialized {
184                        self.state.last_physical = value;
185                        self.state.passthrough_initialized = true;
186                        return self.state.virtual_value;
187                    }
188
189                    // Check if pot crosses virtual value from either direction
190                    let crossing_from_below = value >= self.state.virtual_value
191                        && self.state.last_physical < self.state.virtual_value;
192
193                    let crossing_from_above = value <= self.state.virtual_value
194                        && self.state.last_physical > self.state.virtual_value;
195
196                    if crossing_from_below || crossing_from_above {
197                        self.state.grabbed = true;
198                        self.state.last_physical = value;
199                        self.state.virtual_value = value;
200                        return value;
201                    }
202
203                    // Not grabbed yet - update last physical and hold virtual value
204                    self.state.last_physical = value;
205                    return self.state.virtual_value;
206                }
207                // Pot is grabbed - update both physical and virtual
208                self.state.last_physical = value;
209                self.state.virtual_value = value;
210                value
211            }
212        }
213    }
214
215    /// Returns the current physical input position in normalized 0.0..1.0 range.
216    /// Useful for UI display when grab mode is active.
217    ///
218    /// This always reflects where the pot physically is (after normalize→filter→curve→hysteresis),
219    /// but BEFORE virtual modifications like snap zones and grab mode logic.
220    #[cfg(feature = "grab-mode")]
221    pub fn physical_position(&self) -> f32 {
222        self.state.physical_position
223    }
224
225    /// Returns the current output value in normalized 0.0..1.0 range without updating state.
226    /// Useful for reading the locked virtual value in grab mode.
227    #[cfg(feature = "grab-mode")]
228    pub fn current_output(&self) -> f32 {
229        self.state.virtual_value
230    }
231
232    /// Returns true if grab mode is active but not yet grabbed.
233    /// When true, `physical_position() != current_output()`
234    #[cfg(feature = "grab-mode")]
235    pub fn is_waiting_for_grab(&self) -> bool {
236        matches!(
237            self.config.grab_mode,
238            GrabMode::Pickup | GrabMode::PassThrough
239        ) && !self.state.grabbed
240    }
241
242    /// Set the virtual parameter value (e.g., after preset change or automation).
243    /// This unlocks grab mode, requiring the pot to be grabbed again.
244    ///
245    /// `value` is in the same output space as `process()` returns.
246    ///
247    /// When switching which parameter a physical pot controls, use [`attach`](Self::attach) instead —
248    /// it also seeds the filter to prevent false grabs on reactivation.
249    #[cfg(feature = "grab-mode")]
250    pub fn set_virtual_value(&mut self, value: TOut) {
251        self.state.virtual_value = self.normalize_output(value);
252        self.state.grabbed = false;
253        self.state.passthrough_initialized = false;
254    }
255
256    /// Attach a physical pot to this parameter.
257    ///
258    /// Call this when switching which parameter the pot controls. Seeds the EMA filter
259    /// to the current physical position so grab detection starts from a clean state —
260    /// no cold-start ramp that PassThrough mode could misread as physical movement.
261    ///
262    /// If the parameter value has changed since it was last active (e.g. after a preset
263    /// load), call `set_virtual_value` before `attach`.
264    ///
265    /// Pair with [`detach`](Self::detach) on the outgoing parameter.
266    #[cfg(feature = "grab-mode")]
267    pub fn attach(&mut self, current_input: TIn) {
268        let normalized = self.normalize_input(current_input);
269        if let Some(ref mut ema) = self.state.ema_filter {
270            ema.reset(normalized);
271        }
272        self.state.grabbed = false;
273        self.state.passthrough_initialized = false;
274    }
275
276    /// Detach the physical pot from this parameter.
277    ///
278    /// If the pot was grabbed (actively tracking this parameter), snapshots the current
279    /// physical position into `virtual_value` so the user must pass through it again to
280    /// re-grab on the next [`attach`](Self::attach). If the pot was not yet grabbed
281    /// (still waiting to cross the stored value), `virtual_value` is left unchanged —
282    /// it already holds the correct stored parameter value and overwriting it with the
283    /// physical position would corrupt it.
284    ///
285    /// Safe to call unconditionally when switching pot control to another parameter.
286    /// Pair with [`attach`](Self::attach) on the incoming parameter.
287    #[cfg(feature = "grab-mode")]
288    pub fn detach(&mut self) {
289        if self.state.grabbed {
290            self.state.virtual_value = self.state.physical_position;
291        }
292        self.state.grabbed = false;
293        self.state.passthrough_initialized = false;
294    }
295}