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 update(&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    fn denormalize_output(&self, normalized: f32) -> TOut {
140        let min_f = self.config.output_min.as_();
141        let max_f = self.config.output_max.as_();
142
143        let output_f = min_f + normalized * (max_f - min_f);
144        output_f.as_()
145    }
146
147    #[cfg(feature = "grab-mode")]
148    fn apply_grab_mode(&mut self, value: f32) -> f32 {
149        match self.config.grab_mode {
150            GrabMode::None => {
151                // Direct control, no grab logic
152                self.state.grabbed = true; // Always consider grabbed
153                self.state.virtual_value = value;
154                value
155            }
156
157            GrabMode::Pickup => {
158                if !self.state.grabbed {
159                    // Check if pot crosses virtual value from below
160                    if value >= self.state.virtual_value {
161                        self.state.grabbed = true;
162                    } else {
163                        // Hold virtual value until grabbed
164                        return self.state.virtual_value;
165                    }
166                }
167                // Pot is grabbed - update virtual value
168                self.state.virtual_value = value;
169                value
170            }
171
172            GrabMode::PassThrough => {
173                if !self.state.grabbed {
174                    // First read after set_virtual_value - just initialize position
175                    if !self.state.passthrough_initialized {
176                        self.state.last_physical = value;
177                        self.state.passthrough_initialized = true;
178                        return self.state.virtual_value;
179                    }
180
181                    // Check if pot crosses virtual value from either direction
182                    let crossing_from_below = value >= self.state.virtual_value
183                        && self.state.last_physical < self.state.virtual_value;
184
185                    let crossing_from_above = value <= self.state.virtual_value
186                        && self.state.last_physical > self.state.virtual_value;
187
188                    if crossing_from_below || crossing_from_above {
189                        self.state.grabbed = true;
190                        self.state.last_physical = value;
191                        self.state.virtual_value = value;
192                        return value;
193                    }
194
195                    // Not grabbed yet - update last physical and hold virtual value
196                    self.state.last_physical = value;
197                    return self.state.virtual_value;
198                }
199                // Pot is grabbed - update both physical and virtual
200                self.state.last_physical = value;
201                self.state.virtual_value = value;
202                value
203            }
204        }
205    }
206
207    /// Returns the current physical input position in normalized 0.0..1.0 range.
208    /// Useful for UI display when grab mode is active.
209    ///
210    /// This always reflects where the pot physically is (after normalize→filter→curve→hysteresis),
211    /// but BEFORE virtual modifications like snap zones and grab mode logic.
212    #[cfg(feature = "grab-mode")]
213    pub fn physical_position(&self) -> f32 {
214        self.state.physical_position
215    }
216
217    /// Returns the current output value in normalized 0.0..1.0 range without updating state.
218    /// Useful for reading the locked virtual value in grab mode.
219    #[cfg(feature = "grab-mode")]
220    pub fn current_output(&self) -> f32 {
221        self.state.virtual_value
222    }
223
224    /// Returns true if grab mode is active but not yet grabbed.
225    /// When true, `physical_position() != current_output()`
226    #[cfg(feature = "grab-mode")]
227    pub fn is_waiting_for_grab(&self) -> bool {
228        matches!(
229            self.config.grab_mode,
230            GrabMode::Pickup | GrabMode::PassThrough
231        ) && !self.state.grabbed
232    }
233
234    /// Set the virtual parameter value (e.g., after preset change or automation).
235    /// This unlocks grab mode, requiring the pot to be grabbed again.
236    #[cfg(feature = "grab-mode")]
237    pub fn set_virtual_value(&mut self, value: f32) {
238        self.state.virtual_value = value;
239        self.state.grabbed = false;
240        self.state.passthrough_initialized = false; // Reset for PassThrough mode
241    }
242
243    /// Release grab and set virtual value to current physical position.
244    /// Useful when switching which parameter a physical pot controls.
245    ///
246    /// After calling this, the pot will be ungrabbed and the virtual value
247    /// will be set to the current physical position. The pot must be moved
248    /// to re-grab (in Pickup/PassThrough modes).
249    #[cfg(feature = "grab-mode")]
250    pub fn release(&mut self) {
251        self.state.virtual_value = self.state.physical_position;
252        self.state.grabbed = false;
253        self.state.passthrough_initialized = false;
254    }
255}