Control surface and multiplexing, solving latency problems

Hello! Beginner in programming and electronics, I made a midi controller with an arduino pro micro, 64 pots 10k connected to 4 multiplexers 16 channels and 2 pots 10k directly to 2 analog pins.

I used the example code supplied with the library and everything worked fine, but I noticed a bit of latency when I turned the pots, which isn't very optimal for live use. I've read that this is due to the sampling time required for a single analog input and how much memory all those Control Surface elements will require.

I therefore deleted the SYSEX data as advised in the Control SURFACE FAQ to save memory, hoping that this would solve my problem, in vain.

I wonder if switching from a pro micro to a teensy would solve my problem? Or if solving this problem is far beyond the skills of a beginner?

Thanks for reading!

Here's my code:

#include <Control_Surface.h>  // Inclure la bibliothèque

USBMIDI_Interface midi;  // Instancier une interface MIDI à utiliser
 
CD74HC4067 mux1 {
  A0,          // Broche d'entrée analogique
  {7, 4, 6, 5} // Broches d'adresse S0, S1, S2, S3
};
CD74HC4067 mux2 {
  A1,          // Broche d'entrée analogique
  {7, 4, 6, 5} // Broches d'adresse S0, S1, S2, S3
};
CD74HC4067 mux3 {
  A2,          // Broche d'entrée analogique
  {7, 4, 6, 5} // Broches d'adresse S0, S1, S2, S3
};
CD74HC4067 mux4 {
  A3,          // Broche d'entrée analogique
  {7, 4, 6, 5} // Broches d'adresse S0, S1, S2, S3
};
CCPotentiometer pot1 { A8, 65};
CCPotentiometer pot2 { A9, 66};
;

// Créer un tableau d'objets CCPotentiometer avec des adresses MIDI numérotées de 1 à 66
CCPotentiometer volumePotentiometers[] {
  { mux1.pin(0), { 1} },
  { mux1.pin(1), { 2} },
  { mux1.pin(2), { 3} },
  { mux1.pin(3), { 4} },
  { mux1.pin(4), { 5} },
  { mux1.pin(5), { 6} },
  { mux1.pin(6), { 7} },
  { mux1.pin(7), { 8} },
  { mux1.pin(8), { 9} },
  { mux1.pin(9), { 10} },
  { mux1.pin(10), { 11} },
  { mux1.pin(11), { 12} },
  { mux1.pin(12), { 13} },
  { mux1.pin(13), { 14} },
  { mux1.pin(14), { 15} },
  { mux1.pin(15), { 16} },

  { mux2.pin(0), { 17} },
  { mux2.pin(1), { 18} },
  { mux2.pin(2), { 19} },
  { mux2.pin(3), { 20} },
  { mux2.pin(4), { 21} },
  { mux2.pin(5), { 22} },
  { mux2.pin(6), { 23} },
  { mux2.pin(7), { 24} },
  { mux2.pin(8), { 25} },
  { mux2.pin(9), { 26} },
  { mux2.pin(10), { 27} },
  { mux2.pin(11), { 28} },
  { mux2.pin(12), { 29} },
  { mux2.pin(13), { 30} },
  { mux2.pin(14), { 31} },
  { mux2.pin(15), { 32} },

  { mux3.pin(0), { 33} },
  { mux3.pin(1), { 34} },
  { mux3.pin(2), { 35} },
  { mux3.pin(3), { 36} },
  { mux3.pin(4), { 37} },
  { mux3.pin(5), { 38} },
  { mux3.pin(6), { 39} },
  { mux3.pin(7), { 40} },
  { mux3.pin(8), { 41} },
  { mux3.pin(9), { 42} },
  { mux3.pin(10), { 43} },
  { mux3.pin(11), { 44} },
  { mux3.pin(12), { 45} },
  { mux3.pin(13), { 46} },
  { mux3.pin(14), { 47} },
  { mux3.pin(15), { 48} },

  { mux4.pin(0), { 49} },
  { mux4.pin(1), { 50} },
  { mux4.pin(2), { 51} },
  { mux4.pin(3), { 52} },
  { mux4.pin(4), { 53} },
  { mux4.pin(5), { 54} },
  { mux4.pin(6), { 55} },
  { mux4.pin(7), { 56} },
  { mux4.pin(8), { 57} },
  { mux4.pin(9), { 58} },
  { mux4.pin(10), { 59} },
  { mux4.pin(11), { 60} },
  { mux4.pin(12), { 61} },
  { mux4.pin(13), { 62} },
  { mux4.pin(14), { 63} },
  { mux4.pin(15), { 64} },
};
 
void setup() {
  Control_Surface.begin();  // Initialiser Control Surface
}
 
void loop() {
  Control_Surface.loop();  // Mettre à jour Control Surface
}

The maximum rate that the Arduino's ADC can sample at (with full resolution) is 125 kHz / (13 cycles/sample)*. This is in ideal circumstances, when you're just constantly polling the ADC status register without doing anything else. If you need to sample 66 potentiometers at that rate, you get an effective sampling time of ~7 ms. Then you probably need to sample every input twice to avoid cross-talk, so it takes ~14 ms to sample all potentiometers. In reality, it's probably going to be significantly slower, since the microcontroller is doing other stuff as well, besides driving the ADC.

Now, 14 ms may not sound too bad, but you have to factor in the digital filtering that's used to remove noise from the inputs. This adds some delay. If the sampling rate is high enough, it's not perceptible, but if you're sampling at only ~73 Hz (and potentially quite a bit slower), it will be very obvious.

There are two low-effort things you could try:

  1. Call mux#.discardFirstReading(false) in your setup for all of your multiplexers. This saves 1 measurement for each input, but it may result in cross-talk between channels of the multiplexers.
  2. Lower the ANALOG_FILTER_SHIFT_FACTOR constant in Control-Surface/AH/Settings/Settings.hpp to 1 or even 0. This increases the cut-off frequency of the digital filter used for the inputs, reducing the latency it introduces, but it also means that the signal may be more noisy.

Finally, using the AnalogMultiplex or CD74HC4067 classes is convenient, but suboptimal. If you already know that that you're going to need to sample all inputs of all multiplexers, you could just do that quickly in a for loop (maybe even in an interrupt-driven fashion). You could for example create your own custom MIDI output element that handles all potentiometers at once.

In your current code, for each one of the 64 potentiometers, Control Surface will:

  1. Check which multiplexer the given potentiometer pin belongs to.
  2. Call the (polymorphic) analogRead function of that multiplexer with the correct offset.
  3. Set the address pins of the multiplexer to the desired state:
    a. Check which IO extender each address pin belongs to (if any).
    b. Call the (polymorphic) digitalWrite function for that IO extender with the correct offset.
  4. Read the analog pin of the multiplexer:
    a. Check which IO extender the analog pin belongs to (if any, this allows having multiple layers of multiplexers).
    b. ...

Some of 3. and 4. could be cached, but I haven't gotten around that, since usually this isn't a bottleneck. Anyway, the takeaway is that there is a significant trade-off between flexibility/ease of use, and performance. Control Surface usually picks the former by default, and leaves room for the user to implement their own specific alternatives if performance is important.

Note that memory usage does not directly affect how quickly you can sample. The overhead of having to iterate over 64 separate elements and looking up individual pins, on the other hand ...

Switching to a Teensy will probably speed things up, a quick online search says that each of the Teensy 4.x's two ADCs can sample up to 362 kHz at 12 bits (in free-running mode), which should be plenty, but I've never tried this myself, and there may be caveats (e.g. oversampling may be necessary to get rid of the noise).


(*) This is for the Uno's ATmega328P, it may be slightly different for the Pro Micro's ATmega32U4, but I don't know the figures by heart.

Pro Micro - no just the same.
Teensy 4.1 - Yes it is a much faster processor, so it can get round all those multiplexer and pots more quickly.

The main problem is that you didn't think in advance about how long it would take you to cover all the inputs you need while planning your system.

It is not a memory issue it is that you are trying to do too much.

Your code is a bit turgid. When you find yourself writing the same lines over and over, then there is a much simpler way of doing things. Like the way you setup the mux pins.

It won't affect the performance but is is a poor bit of code.

Remember you only have two hands, and most of the time you will be reading pots and coming up with a no significant change result.

I lowered the value of the analog filter as you indicated and that solved my problem. Thank you for your diligence and detailed reply!

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.