What the hell are these?
The secret 360º endless-potentiometers that
make rotary encoders obsolete
This article will assume you understand the basics of using normal potentiometers with a single wiper, and that you have experience programming Arduinos.
I recently embarked on an Arduino project that required rotary encoders – or so I thought. And I wasn’t so happy about it, because I really dislike rotary encoders. The affordable ones have click-steps and are usually limited to 20-30 steps – unless you want to dish out some serious cash – and even then, they’re bulky, harder to program, and nearly impossible to multiplex.
While trying to find non-shitty encoders, a thought popped into my mind… “Didn’t I used to have a MIDI controller with like, 16 smooth encoders?!”
Ah yes, the APC40 mk1! Sure enough, it has 16 damn encoders with LED rings! And even with an 8x5 clip launcher, tons of buttons, 9 faders and a cross-fader, it was still relatively affordable. So, where can I get these magically cheap, perfectly smooth encoders?!
This question sent me down one of the deepest internet rabbit-holes I’ve ever been on. And odds are, if you’re reading this now, you’re in the same position.
Well, good news for you: I might have the answers you’re looking for…
The secret is the Alpha 360º Endless Potentiometer with two wipers.
You can buy them from Ali Express. There are cheaper options on Ali Express, but these are the ones I can confirm work properly and have good turning resistance.
I’m pretty sure they’re actually the RV112FF-40B1 model, but I’m not positive, since the documentation is shit.
I also bought some of this other model from Mouser, which are the same, but also include a push-switch, so you can click the knob in like a button. However, the turning-resistance on these is very weak, which makes them feel much cheaper than the non-switch model. I’m still working on a solution…
Unlike normal potentiometers, these 360º pots have 4 pins for the wipers. Two of them are ground and voltage like a normal pot, but instead of having just one pin for the analog data, there are two. This is because there are two wipers (variable resistors), which are offset from each other. Using the analog data you get from both pins, you can find the absolute position of the knob in radians.
However, for an amateur-programmer like me, this is easier said than done. But with the help of my tech-savy sibling and a bit of ChatGPT 4o, I made an Arduino script that can read any number of these pots you hook up to your Arduino and report their absolute values as MIDI or Serial. The key is the 2-argument arctangent function, aka atan2. Using this with the values from both wipers gives you the position of the knob from -pi to +pi.
Once you have the absolute value of the potentiometer, you can do whatever you want with it. In my case (not in the example code), I just needed the relative movement, so I measured the current value against the previous value to get the angle-change over a given amount of milliseconds.
The example code I’ve offered below simply returns the absolute position of the potentiometer as a MIDI CC value (or Serial), scaled from 0-127. Additionally, if you send a value with the same MIDI CC number back to the Arduino controller, it will update the zero-point with that new value.
Example: I turn the first potentiometer on the controller (CC 0) and see the value updating on the computer. I stop, and the value is currently 70. Then, on the computer, with my mouse, I update the same CC 0 value, sending a new value of 42 back to the Arduino controller.
From that point on, the current position of the knob on the Arduino controller will be internally updated to 42, so when I turn the potentiometer again, it will start counting up or down from 42. This is the usual expected behavior for MIDI controllers with endless-encoders. You don’t want them to jump back to the old value - that would defeat the purpose of an endless-turn knob!
In addition to the Arduino code, I’ve also included a simple Max for Live device to serve as a demonstration. It will work for CC values 0-7.
The only “special” thing it’s doing is preventing a feedback loop. The incoming values from the controller are not sent back to the controller, but any manual updates to the dials (with the mouse) are sent back to the controller. This logic can be replicated in an Ableton Remote Script, or any other software of your choice.
To test it, simply drag the device onto an empty MIDI Track in Ableton with no other effects before or after it. On the MIDI Track Routing, set the input and output to your Arduino controller, and set the Track Monitoring to IN.
To add LED-rings or custom Ableton Remote Scripts … you’re on your own! :)
Hints:
On line 27, you can set the total number of potentiometers. It’s set to 2 by default.
On lines 28 and 29, you’ll set the pins you use for wipers 1 and 2. Since there are 2 pots, there are two values for each, but if you change the number of pots, you will need to add or remove the number of values accordingly.
Line 28 contains all of the wiper 1’s from all of your pots, in ascending order.
Line 29 contains all of the wiper 2’s in the same order.
So, in my code, wipers 1 and 2 of the first pot are on pins 14 and 16, respectively. For the second pot, it’s pins 15 and 17. Hope that makes sense.On line 37, you can change the starting CC number. Your pots will step up incrementally from this number. The default is 0.
On line 132, you’ll find the magical atan2 function.
This code uses the libraries MIDIUSB.h and ResponsiveAnalogRead.h. You’ll need to install those.
Here is the code that comes in the Arduino file. Unfortunately, it is not possible to include line numbers or format the code properly here on Squarespace. For copying and pasting into your own projects, I recommend downloading the Arduino file.
/* Pins: 14 - pot1 wiper 1 15 - pot2 wiper 1 16 - pot1 wiper 2 17 - pot2 wiper 2 */ // Turns on or off all use of MIDIUSB #define ENABLE_MIDI true #define DEBUG 0 // Set to 1 to enable Serial.print messages #define MIDI_CHANNEL 0 // "Channel 1" #if ENABLE_MIDI #include "MIDIUSB.h" #endif #include <ResponsiveAnalogRead.h> ///// Potentiometer definitions ////// const int NPots = 2; //*** total number of pots const int wiper1Pin[NPots] = {14, 15}; const int wiper2Pin[NPots] = {16, 17}; int wiper1Reading[NPots] = {0}; int wiper2Reading[NPots] = {0}; float snapMultiplier = 0.01; ResponsiveAnalogRead responsiveWiper1[NPots] = {}; ResponsiveAnalogRead responsiveWiper2[NPots] = {}; byte potCC = 0; // MIDI CC for first pot. Subsequent pots will increase from this point (pot1 = CC 0 , pot2 = CC 1, etc.) double prevAngle[NPots] = {}; double angle[NPots] = {}; double angleChange[NPots] = {}; int midiAngleChange[NPots] = {}; int wiper1[NPots] = {}; int wiper2[NPots] = {}; double fx[NPots] = {}; double fy[NPots] = {}; int prevDialPosition[NPots] = {}; int dialPosition[NPots] = {}; int internalDialValue[NPots] = {}; int prevInternalDialValue[NPots] = {}; uint8_t incomingControl = 0; uint8_t incomingControlValue = 0; void setup() { // Initialize serial communication #if DEBUG Serial.begin(115200); //115200 recommended #endif // Initialize responsive analog reads for (int i = 0; i < NPots; i++) { responsiveWiper1[i] = ResponsiveAnalogRead(0, true, snapMultiplier); responsiveWiper1[i].setAnalogResolution(1023); responsiveWiper2[i] = ResponsiveAnalogRead(0, true, snapMultiplier); responsiveWiper2[i].setAnalogResolution(1023); // Mark as uninitialized prevDialPosition[i] = -1; // A value outside the 0-127 range to indicate uninitialized prevInternalDialValue[i] = -1; // Same as above for internal MIDI value internalDialValue[i] = -1; } } void loop() { potentiometers(); delay(10); // Adjust this to change potentiometer sensitivity } int computeDelta(int prevPos, int currentPos) { int delta = currentPos - prevPos; if (delta > 64) { delta -= 128; } else if (delta < -64) { delta += 128; } return delta; } void potentiometers() { // Process all incoming MIDI messages midiEventPacket_t event; while ((event = MidiUSB.read()).header != 0) { if ((event.byte1 & 0x0F) == MIDI_CHANNEL) { incomingControl = event.byte2; incomingControlValue = event.byte3; for (int i = 0; i < NPots; i++) { if (incomingControl == potCC + i) { internalDialValue[i] = incomingControlValue; prevInternalDialValue[i] = incomingControlValue; // Sync previous value #if DEBUG Serial.print("Pot "); Serial.print(i); Serial.print(" Initialized via MIDI: "); Serial.println(internalDialValue[i]); #endif } } } } for (int i = 0; i < NPots; i++) { wiper1Reading[i] = analogRead(wiper1Pin[i]); wiper2Reading[i] = analogRead(wiper2Pin[i]); responsiveWiper1[i].update(wiper1Reading[i]); // Smooth the value responsiveWiper2[i].update(wiper2Reading[i]); wiper1[i] = responsiveWiper1[i].getValue(); wiper2[i] = responsiveWiper2[i].getValue(); // Compute current angle and dial position fx[i] = ((double)wiper1[i] / 511.5) - 1; // range -1 to +1 fy[i] = ((double)wiper2[i] / 511.5) - 1; angle[i] = atan2(fy[i], fx[i]); // range -pi to +pi dialPosition[i] = map(angle[i], -PI, PI, 0, 128); // Skip uninitialized knobs if (prevDialPosition[i] == -1) { prevDialPosition[i] = dialPosition[i]; continue; // Wait for the first movement } int deltaDialPosition = computeDelta(prevDialPosition[i], dialPosition[i]); if (deltaDialPosition != 0) { // Only update if there is a change if (internalDialValue[i] == -1) { // Initialize internal value on first movement internalDialValue[i] = dialPosition[i]; prevInternalDialValue[i] = internalDialValue[i]; } else { // Update internal value based on delta internalDialValue[i] += deltaDialPosition; // Wrap around if necessary if (internalDialValue[i] < 0) internalDialValue[i] += 128; else if (internalDialValue[i] > 127) internalDialValue[i] -= 128; } // Send MIDI if the value has changed if (internalDialValue[i] != prevInternalDialValue[i]) { controlChange(MIDI_CHANNEL, potCC + i, internalDialValue[i]); prevInternalDialValue[i] = internalDialValue[i]; #if DEBUG Serial.print("Pot "); Serial.print(i); Serial.print(" Internal Value: "); Serial.println(internalDialValue[i]); #endif } } prevDialPosition[i] = dialPosition[i]; } } /////// MIDI Functions ////// void noteOn(uint8_t channel, uint8_t pitch, uint8_t velocity) { #if ENABLE_MIDI midiEventPacket_t noteOn = {0x09, (uint8_t)(0x90 | channel), pitch, velocity}; MidiUSB.sendMIDI(noteOn); MidiUSB.flush(); #endif } void noteOff(uint8_t channel, uint8_t pitch) { #if ENABLE_MIDI midiEventPacket_t noteOff = {0x08, (uint8_t)(0x80 | channel), pitch, 0}; MidiUSB.sendMIDI(noteOff); MidiUSB.flush(); #endif } void controlChange(uint8_t channel, uint8_t control, uint8_t value) { #if ENABLE_MIDI midiEventPacket_t event = {0x0B, (uint8_t)(0xB0 | channel), control, value}; MidiUSB.sendMIDI(event); MidiUSB.flush(); #endif }