synth_utils/ribbon_controller.rs
1//! # Softpot resistive ribbon controller
2//!
3//! Spectra Symbol and others make resistive linear position sensors which may be used as ribbon controllers.
4//!
5//! Users play the ribbon by sliding their finger up and down a resistive track wired as a voltage divider.
6//!
7//! The position of the user's finger on the ribbon is represented as a number. The farther to the right the user is
8//! pressing the larger the number. The position value is retained even when the user lifts their finger off of the
9//! ribbon, similar to a sample-and-hold system. Some averaging is done to smooth out the raw readings and reduce the
10//! influence of spurious inputs.
11//!
12//! Whether or not the user is pressing on the ribbon is represented as a boolean signal.
13//!
14//! The position value and finger-down signals are then typically used as control signals for other modules, such as
15//! oscillators, filters, and amplifiers.
16//!
17//! # Inputs
18//!
19//! * Samples are fed into the ribbon controller
20//!
21//! # Outputs
22//!
23//! * The average of the most recent samples representing the position of the user's finger on the ribbon
24//!
25//! * Boolean signals related to the user's finger presses
26//!
27//! ---
28//!
29//! ## Note about the hardware
30//!
31//! The intended hardware setup can be [seen here](https://github.com/JordanAceto/synth-utils-rs/blob/main/images/ribbon_schematic_snippet.png)
32//!
33//! Referencing the schematic image linked above:
34//! The ribbon is wired as a voltage divider between ground and a positive reference with a small series resistor `R1`
35//! between the top of the ribbon and the positive ref. When the user is not pressing the ribbon the wiper is open
36//! circuit. In order to detect finger presses pullup resistor `R2` is placed from the wiper to the positive reference.
37//! With this setup, we can tell if the ribbon is not being pressed because `R2` pulls all the way up to the maximum
38//! value. However, when a person is pressing the ribbon the maximum value is limited by the small series resistor `R1`
39//! and is always lower than the "no finger pullup" value.
40//!
41//! Resistor `R1` is chosen to be small enough that not too much range is wasted but large enough that we can reliably
42//! detect no-press conditions even with the presence of some noise. Resistor `R2` is chosen to be large enough that it
43//! doesn't bend the response of the voltage divider too much but small enough that the voltage shoots up to full scale
44//! quickly when the user lifts their finger. The opamp buffer is optional, but recommended to provide a low impedance
45//! source to feed the ADC and provide some noise immunity if the ribbon is physically far from the pcb and connected
46//! by long wires.
47//!
48//! It is expected that software external to this module will read the ADC and convert the raw integer-based ADC value
49//! into a floating point number in the range `[0.0, 1.0]` before interfacing with this ribbon module.
50//!
51//! Note that this software module has no direct connection to the physical hardware. It is assumed that samples come
52//! from to feed the ribbon the specified hardware setup which is sampled by an Analog to Digital Converter, but we
53//! could just as easily feed it made up samples from anywhere. This allows us to have some flexibility in using this
54//! module with various microcontroller setups. The above schematic snippet and description are included to illustrate
55//! one way that this module could be used.
56//!
57//! ---
58
59use heapless::HistoryBuffer;
60
61/// A synthesizer ribbon controller is represented here.
62///
63/// It is expected to use the provided `sample_rate_to_capacity(sr)` const function to calculate the const generic
64/// `BUFFER_CAPACITY` argument. If in the future Rust offers a way to calculate the buffer capacity in a more
65/// straightforward way this should be changed.
66pub struct RibbonController<const BUFFER_CAPACITY: usize> {
67 /// Samples below this value indicate that there is a finger pressed down on the ribbon.
68 ///
69 /// The value must be in [0.0, +1.0], and represents the fraction of the ADC reading which counts as a finger press.
70 ///
71 /// The exact value depends on the resistor chosen that connects the top of the ribbon to the positive voltage
72 /// reference. We "waste" a little bit of the voltage range of the ribbon as a dead-zone so we can clearly detect when
73 /// the user is pressing the ribbon or not.
74 finger_press_high_boundary: f32,
75
76 /// error scaling constant used to un-bend the ribbon which is non-linear due to the pullup resistor
77 error_const: f32,
78
79 /// The current position value of the ribbon
80 current_val: f32,
81
82 /// The current gate value of the ribbon
83 finger_is_pressing: bool,
84
85 /// True iff the gate is rising after being low
86 finger_just_pressed: bool,
87
88 /// True iff the gate is falling after being high
89 finger_just_released: bool,
90
91 /// An internal buffer for storing and averaging samples as they come in via the `poll` method
92 buff: HistoryBuffer<f32, BUFFER_CAPACITY>,
93
94 /// The number of samples to ignore when the user initially presses their finger
95 num_to_ignore_up_front: usize,
96
97 /// The number of the most recent sampes to discard
98 num_to_discard_at_end: usize,
99
100 /// The number of samples revieved since the user pressed their finger down
101 ///
102 /// Resets when the user lifts their finger
103 num_samples_received: usize,
104
105 /// The number of samples actually written to the buffer
106 ///
107 /// Resets when the user lifts their finger
108 num_samples_written: usize,
109}
110
111impl<const BUFFER_CAPACITY: usize> RibbonController<BUFFER_CAPACITY> {
112 /// `Ribbon::new(sr, sp, dr, pu)` is a new Ribbon controller
113 ///
114 /// # Arguments:
115 ///
116 /// * `sample_rate_hz` - The sample rate in Hertz
117 ///
118 /// * `softpot_ohms` - The end-to-end resistance of the softpot used, typically 10k or 20k
119 ///
120 /// * `dropper_resistor_ohms` - The value of the resistor which sits between the top of the softpot and the positive
121 /// voltage reference.
122 ///
123 /// * `pullup_resistor_ohms` - The value of the wiper pullup reistor, shoudl be at least 10x softpot_ohms or larger
124 pub fn new(
125 sample_rate_hz: f32,
126 softpot_ohms: f32,
127 dropper_resistor_ohms: f32,
128 pullup_resistor_ohms: f32,
129 ) -> Self {
130 Self {
131 finger_press_high_boundary: 1.0
132 - (dropper_resistor_ohms / (dropper_resistor_ohms + softpot_ohms)),
133 error_const: (softpot_ohms + dropper_resistor_ohms) / pullup_resistor_ohms,
134 current_val: 0.0_f32,
135 finger_is_pressing: false,
136 finger_just_pressed: false,
137 finger_just_released: false,
138 buff: HistoryBuffer::new(),
139 num_to_ignore_up_front: ((sample_rate_hz as u32 * RIBBON_FALL_TIME_USEC) / 1_000_000)
140 as usize,
141 num_to_discard_at_end: ((sample_rate_hz as u32 * RIBBON_RISE_TIME_USEC) / 1_000_000)
142 as usize,
143 num_samples_received: 0,
144 num_samples_written: 0,
145 }
146 }
147
148 /// `rib.poll(raw_adc_value)` updates the controller by polling the raw ADC signal. Must be called at the sample rate
149 ///
150 /// # Arguments
151 ///
152 /// * `raw_adc_value` - the raw ADC signal to poll in `[0.0, 1.0]`, represents the finger position on the ribbon.
153 /// Inputs outside of the range `[0.0, 1.0]` are undefined.
154 /// Note that a small portion of the range at the top near +1.0 is expected to be "eaten" by the series resistor
155 pub fn poll(&mut self, raw_adc_value: f32) {
156 let user_is_pressing_ribbon = raw_adc_value < self.finger_press_high_boundary;
157
158 if user_is_pressing_ribbon {
159 self.num_samples_received += 1;
160 self.num_samples_received = self.num_samples_received.min(self.num_to_ignore_up_front);
161
162 // only start adding samples to the buffer after we've ignored a few potentially spurious initial samples
163 if self.num_to_ignore_up_front <= self.num_samples_received {
164 self.buff.write(raw_adc_value);
165
166 self.num_samples_written += 1;
167 self.num_samples_written = self.num_samples_written.min(self.buff.capacity());
168
169 // is the buffer full?
170 if self.num_samples_written == self.buff.capacity() {
171 let num_to_take = self.buff.capacity() - self.num_to_discard_at_end;
172
173 // take the average of the most recent samples, minus a few of the very most recent ones which might be
174 // shooting up towards full scale when the user lifts their finger
175 self.current_val = self.buff.oldest_ordered().take(num_to_take).sum::<f32>()
176 / (num_to_take as f32);
177
178 self.current_val -= self.error_estimate(self.current_val);
179
180 // if this flag is false right now then they must have just pressed their finger down
181 if !self.finger_is_pressing {
182 self.finger_just_pressed = true;
183 self.finger_is_pressing = true;
184 }
185 }
186 }
187 } else {
188 // if this flag is true right now then they must have just lifted their finger
189 if self.finger_is_pressing {
190 self.finger_just_released = true;
191 self.num_samples_received = 0;
192 self.num_samples_written = 0;
193 self.finger_is_pressing = false;
194 }
195 }
196 }
197
198 /// `rib.value()` is the current position value of the ribbon in `[0.0, 1.0]`
199 ///
200 /// If the user's finger is not pressing on the ribbon, the last valid value before they lifted their finger
201 /// is returned.
202 ///
203 /// The value is expanded to take up the whole `[0.0, 1.0]`range, so even though the input will not quite reach
204 /// +1.0 at the top end (due to the series resistance) the output will reach or at least come very close to +1.0
205 pub fn value(&self) -> f32 {
206 // scale the value back to full scale since we loose a tiny bit of range to the high-boundary
207 self.current_val / self.finger_press_high_boundary
208 }
209
210 /// `rib.finger_is_pressing()` is `true` iff the user is pressing on the ribbon.
211 pub fn finger_is_pressing(&self) -> bool {
212 self.finger_is_pressing
213 }
214
215 /// `rib.finger_just_pressed()` is `true` iff the user has just pressed the ribbon after having not touched it.
216 ///
217 /// Self clearing
218 pub fn finger_just_pressed(&mut self) -> bool {
219 if self.finger_just_pressed {
220 self.finger_just_pressed = false;
221 true
222 } else {
223 false
224 }
225 }
226
227 /// `rib.finger_just_released()` is `true` iff the user has just lifted their finger off the ribbon.
228 ///
229 /// Self clearing
230 pub fn finger_just_released(&mut self) -> bool {
231 if self.finger_just_released {
232 self.finger_just_released = false;
233 true
234 } else {
235 false
236 }
237 }
238
239 /// `rib.error_estimate(p)` is the estimated error at position `p` resulting from the influence of the pullup resistor
240 ///
241 /// The softpot is wired as a voltage divider with an additional pullup resistor from the wiper to the positive ref.
242 /// The pullup resistor bends the Vout so that it is not linear, Vout rises faster than it would without the pullup.
243 ///
244 /// This error estimation approximate, but can help straighten out the ribbon response
245 ///
246 /// # Arguments:
247 ///
248 /// * `pos` - the position value in `[0.0, 1.0]`
249 fn error_estimate(&self, pos: f32) -> f32 {
250 (pos - pos * pos) * self.error_const
251 }
252}
253
254/// The approximate measured time it takes for the ribbon to settle on a low value after the user presses their finger.
255///
256/// We want to ignore samples taken while the ribbon is settling during a finger-press value.
257///
258/// Rounded up a bit from the actual measured value, better to take a little extra time than to include bad input.
259const RIBBON_FALL_TIME_USEC: u32 = 1_000;
260
261/// The approximate measured time it takes the ribbon to rise to the pull-up value after releasing your finger.
262///
263/// We want to ignore samples that are taken while the ribbon is shooting up towards full scale after lifting a finger.
264///
265/// Rounded up a bit from the actual measured value, better to take a little extra time than to include bad input.
266const RIBBON_RISE_TIME_USEC: u32 = 2_000;
267
268/// The minimum time required to capture a reading
269///
270/// Ideally several times longer than the sum of the RISE and FALL times
271const MIN_CAPTURE_TIME_USEC: u32 = (RIBBON_FALL_TIME_USEC + RIBBON_RISE_TIME_USEC) * 5;
272
273/// `sample_rate_to_capacity(sr_hz)` is the calculated capacity needed for the internal buffer based on the sample rate.
274///
275/// Const function allows us to use the result of this expression as a generic argument when we create ribbon objects.
276/// If rust support for generic expressions improves, this function could be refactored out.
277///
278/// The capacity needs space for the main samples that we will actually care about, as well as room for the most
279/// recent samples to discard. This is to avoid including spurious readings in the average.
280pub const fn sample_rate_to_capacity(sample_rate_hz: u32) -> usize {
281 // can't use floats in const function yet
282 let num_main_samples_to_care_about =
283 ((sample_rate_hz * MIN_CAPTURE_TIME_USEC) / 1_000_000) as usize;
284 let num_to_discard_at_end = ((sample_rate_hz * RIBBON_RISE_TIME_USEC) / 1_000_000) as usize;
285
286 num_main_samples_to_care_about + num_to_discard_at_end + 1
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 const SAMPLE_RATE: f32 = 10_000.0;
294 const RIBBON_BUFF_CAPACITY: usize = sample_rate_to_capacity(SAMPLE_RATE as u32);
295
296 /// `test_ribbon()` is a basic ribbon controller for testing
297 fn test_ribbon() -> RibbonController<RIBBON_BUFF_CAPACITY> {
298 RibbonController::new(SAMPLE_RATE as f32, 20E3, 820.0, 1E6)
299 }
300
301 // a bit glass-boxy, but hard to test otherwise, hand calculated by inspecting the code
302 const _TEST_RIB_NUM_TO_IGNORE_UP_FRONT: u32 = 10;
303 const TEST_RIB_NUM_TO_IGNORE_AT_END: u32 = 20;
304 const _TEST_RIB_NUM_TO_CARE_ABOUT: u32 = 150;
305 const _TEST_RIB_CAPACITY: u32 = 170;
306 const TEST_RIB_NUM_FOR_VALID_READING: u32 = 180;
307
308 #[test]
309 fn should_have_dead_zone_before_value_is_captured() {
310 let mut rib = test_ribbon();
311
312 // poll some samples, but not enough to get a reading yet
313 for _ in 0..(TEST_RIB_NUM_FOR_VALID_READING - 1) {
314 rib.poll(0.42);
315 }
316 assert!(!rib.finger_is_pressing());
317 }
318
319 #[test]
320 fn should_eventually_register_reading_with_enough_polling() {
321 let mut rib = test_ribbon();
322
323 // poll some samples, but not enough to get a reading yet
324 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING - 1 {
325 rib.poll(0.42);
326 }
327 assert!(!rib.finger_is_pressing());
328
329 rib.poll(0.42);
330 assert!(rib.finger_is_pressing());
331 }
332
333 #[test]
334 fn one_oob_poll_means_finger_not_pressing() {
335 let mut rib = test_ribbon();
336
337 // poll enough to register a reading
338 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING {
339 rib.poll(0.42);
340 }
341 assert!(rib.finger_is_pressing());
342
343 // 1.0 is always out-of-bounds, there is always some lost to the resistor
344 rib.poll(1.0);
345 assert!(!rib.finger_is_pressing());
346 }
347
348 #[test]
349 fn last_val_retained_after_finger_lifted() {
350 let mut rib = test_ribbon();
351
352 // poll enough to register a reading
353 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING {
354 rib.poll(0.42);
355 }
356 let old_val = rib.value();
357
358 // 1.0 is always out-of-bounds, there is always some lost to the resistor
359 rib.poll(1.0);
360 assert!(!rib.finger_is_pressing());
361 assert_eq!(rib.value(), old_val);
362 }
363
364 #[test]
365 fn bigger_inputs_increase_output() {
366 let mut rib = test_ribbon();
367
368 // poll enough to register a reading
369 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING {
370 rib.poll(0.1);
371 }
372 let old_val = rib.value();
373
374 // do some polling with the new val but don't fill the buffer entirely with new stuff
375 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING / 2 {
376 rib.poll(0.2);
377 }
378 assert!(old_val < rib.value());
379 }
380
381 #[test]
382 fn smaller_inputs_decrease_output() {
383 let mut rib = test_ribbon();
384
385 // poll enough to register a reading
386 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING {
387 rib.poll(0.7);
388 }
389 let old_val = rib.value();
390
391 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING / 4 {
392 rib.poll(0.6);
393 }
394 assert!(rib.value() < old_val);
395 }
396
397 #[test]
398 fn rising_gate_triggers() {
399 let mut rib = test_ribbon();
400
401 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING {
402 rib.poll(0.1);
403 }
404 assert!(rib.finger_just_pressed());
405 // it's self clearing
406 assert!(!rib.finger_just_pressed());
407
408 // still pressing the ribbon, so no new just-pressed
409 rib.poll(0.1);
410 assert!(!rib.finger_just_pressed());
411 }
412
413 #[test]
414 fn last_few_inputs_are_ignored() {
415 let mut rib = test_ribbon();
416
417 // poll enough to register a reading
418 for _ in 0..TEST_RIB_NUM_FOR_VALID_READING {
419 rib.poll(0.0);
420 }
421 assert!(rib.finger_is_pressing());
422 assert_eq!(rib.value(), 0.0);
423
424 //add a few valid readings at the end, which will be ignored
425 for _ in 0..TEST_RIB_NUM_TO_IGNORE_AT_END {
426 rib.poll(0.9);
427 }
428 assert_eq!(rib.value(), 0.0);
429
430 // one more sample will be factored in to the average
431 rib.poll(0.9);
432 assert!(0.0 < rib.value());
433 }
434}