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