Proximity Sensing
Use the MPR121's continuous capacitance data for hover detection, theremin-style control, and gestural distance mapping.
The MPR121 does not just report binary touch events — it continuously measures the capacitance on every electrode and compares it against a baseline. The difference between the measured value and the baseline is called the filtered data output, and it changes smoothly as your hand approaches or recedes. Tapping into this data stream turns twelve electrodes into twelve analogue proximity sensors with no additional hardware.
How MPR121 Proximity Data Works
Each electrode has two internal values you can read over I²C:
| Register | What it contains |
|---|---|
| Baseline | Long-term average of the electrode’s capacitance, updated slowly |
| Filtered data | Fast-updating capacitance reading, smoothed by an on-chip IIR filter |
The difference baseline − filtered is the proximity signal. At rest (nothing nearby) it is close to zero. As a hand approaches, the coupling capacitance rises and the filtered value drops, widening the gap. When the difference crosses the touch threshold the MPR121 sets the touch flag — but you can read the raw difference at any time, well before the threshold is crossed.
Reading Raw Data
#include <MPR121.h>
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin();
MPR121.begin(0x5C);
MPR121.setTouchThreshold(40);
MPR121.setReleaseThreshold(20);
}
void loop() {
// Read filtered data and baseline for electrode 0
int filtered = MPR121.getFilteredData(0);
int baseline = MPR121.getBaselineData(0);
int proximity = baseline - filtered; // positive = hand nearby
Serial.print("P:");
Serial.println(proximity);
delay(20); // ~50 Hz update rate
}
A typical proximity value with a hand 10–15 cm away might be 30–80 counts; a direct finger touch is typically 150–300+, depending on electrode area.
Broadcasting All Twelve Electrodes
void loop() {
Serial.print("D:");
for (int i = 0; i < 12; i++) {
int prox = MPR121.getBaselineData(i) - MPR121.getFilteredData(i);
prox = constrain(prox, 0, 500);
Serial.print(prox);
if (i < 11) Serial.print(',');
}
Serial.println();
// Also send touch/release events as before
if (MPR121.touchStatusChanged()) {
MPR121.updateTouchData();
for (int i = 0; i < 12; i++) {
if (MPR121.isNewTouch(i)) { Serial.print("T:"); Serial.println(i); }
if (MPR121.isNewRelease(i)) { Serial.print("R:"); Serial.println(i); }
}
}
delay(20);
}
The D: line now carries a comma-separated array of 12 proximity values at 50 Hz. A value of 0 means resting; higher values mean closer hand.
Calibrating Proximity Range
Capacitive proximity range depends on electrode size, shape, and the dielectric properties of the material between hand and electrode. Larger area electrodes have longer range.
Auto-ranging with Percentiles
Rather than hard-coding min/max, compute them dynamically by collecting a sliding window of samples:
const int WINDOW = 200; // ~4 seconds at 50 Hz
int proxHistory[WINDOW];
int histIdx = 0;
void loop() {
int raw = MPR121.getBaselineData(0) - MPR121.getFilteredData(0);
raw = max(0, raw);
proxHistory[histIdx] = raw;
histIdx = (histIdx + 1) % WINDOW;
// Sort a copy to find 5th and 95th percentile
int sorted[WINDOW];
memcpy(sorted, proxHistory, sizeof(sorted));
// Simple insertion sort (fast enough for 200 elements at 50 Hz)
for (int i = 1; i < WINDOW; i++) {
int key = sorted[i], j = i - 1;
while (j >= 0 && sorted[j] > key) { sorted[j+1] = sorted[j]; j--; }
sorted[j+1] = key;
}
int lo = sorted[WINDOW / 20]; // 5th percentile
int hi = sorted[WINDOW * 19 / 20]; // 95th percentile
float mapped = (hi > lo) ? constrain((float)(raw - lo) / (hi - lo), 0.0, 1.0) : 0.0;
Serial.print("N:"); // normalised
Serial.println(mapped, 3);
delay(20);
}
This lets the system adapt to different electrode materials and environmental humidity without manual calibration.
Theremin-style Pitch Control
The classic proximity use case: map distance to pitch. Place a large conductive strip along the edge of a table; the further the hand is from the strip, the higher the pitch.
Arduino Side
void loop() {
int prox = MPR121.getBaselineData(0) - MPR121.getFilteredData(0);
prox = constrain(prox, 0, 300);
// Map to MIDI note range 36–84 (C2–C6)
int note = map(prox, 0, 300, 84, 36);
Serial.print("N:");
Serial.println(note);
delay(10); // 100 Hz for smooth pitch
}
p5.js Theremin
// Requires Tone.js: <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
let synth;
let currentNote = -1;
let port, reader;
function setup() {
createCanvas(windowWidth, windowHeight);
synth = new Tone.Synth({
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.05, decay: 0.1, sustain: 0.8, release: 0.5 }
}).toDestination();
let btn = createButton('Connect Touch Board');
btn.position(20, 20);
btn.mousePressed(async () => {
await Tone.start();
connectSerial();
});
}
async function connectSerial() {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
reader = port.readable.getReader();
readLoop();
}
async function readLoop() {
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value);
let lines = buf.split('\n');
buf = lines.pop();
lines.forEach(parseLine);
}
}
function parseLine(line) {
line = line.trim();
if (line.startsWith('N:')) {
let midiNote = parseInt(line.slice(2));
if (isNaN(midiNote)) return;
let freq = Tone.Frequency(midiNote, 'midi').toFrequency();
if (currentNote === -1) {
synth.triggerAttack(freq);
} else {
synth.frequency.rampTo(freq, 0.05); // smooth glide
}
currentNote = midiNote;
}
}
function draw() {
background(10, 0, 20);
if (currentNote >= 0) {
let freq = Tone.Frequency(currentNote, 'midi').toFrequency();
let y = map(currentNote, 36, 84, height - 100, 100);
noFill();
stroke(180, 80, 255);
strokeWeight(2);
// Draw a sine wave whose frequency scales with pitch
beginShape();
for (let x = 0; x < width; x++) {
let wave = sin(x * 0.02 * (currentNote / 60) + frameCount * 0.05) * 40;
vertex(x, y + wave);
}
endShape();
fill(180, 80, 255);
noStroke();
textSize(18);
textAlign(CENTER);
text(Tone.Frequency(currentNote, 'midi').toNote(), width / 2, height - 40);
} else {
fill(80);
noStroke();
textSize(18);
textAlign(CENTER);
text('Bring hand near electrode to play', width / 2, height / 2);
}
}
// Stop note when hand fully away
function keyPressed() {
if (key === ' ' && currentNote >= 0) {
synth.triggerRelease();
currentNote = -1;
}
}
Multi-electrode Proximity Field
With all twelve electrodes sending proximity values you can reconstruct a 2D or 1D position field — essentially a hand-tracking system built from capacitive sensors arranged in a grid or strip.
Arduino: Full Array at 50 Hz
void loop() {
Serial.print("D:");
for (int i = 0; i < 12; i++) {
int prox = max(0, MPR121.getBaselineData(i) - MPR121.getFilteredData(i));
Serial.print(prox);
if (i < 11) Serial.print(',');
}
Serial.println();
delay(20);
}
p5.js: Centroid Tracking
If the twelve electrodes are arranged in a straight line you can compute a weighted centroid to find where the hand is positioned along the strip:
let proxValues = new Array(12).fill(0);
function parseLine(line) {
if (line.startsWith('D:')) {
let parts = line.slice(2).split(',');
for (let i = 0; i < 12; i++) {
let v = parseFloat(parts[i]);
if (!isNaN(v)) proxValues[i] = v;
}
}
}
function getCentroid() {
let weightedSum = 0;
let totalWeight = 0;
for (let i = 0; i < 12; i++) {
let w = max(0, proxValues[i]);
weightedSum += i * w;
totalWeight += w;
}
return totalWeight > 10 ? weightedSum / totalWeight : -1;
}
function draw() {
background(10, 10, 20);
// Draw proximity bars
for (let i = 0; i < 12; i++) {
let x = map(i, 0, 11, 80, width - 80);
let h = map(proxValues[i], 0, 300, 0, height * 0.5);
fill(60, 130, 255);
noStroke();
rect(x - 15, height * 0.75 - h, 30, h);
}
// Draw centroid marker
let centroid = getCentroid();
if (centroid >= 0) {
let cx = map(centroid, 0, 11, 80, width - 80);
fill(255, 80, 180);
ellipse(cx, height * 0.75, 20, 20);
// Vertical position from total energy
let totalEnergy = proxValues.reduce((a, b) => a + b, 0);
let cy = map(totalEnergy, 0, 3000, height * 0.8, height * 0.2);
ellipse(cx, cy, 30, 30);
}
}
Tuning the MPR121 for Proximity
The MPR121 has several registers that affect proximity sensitivity:
Electrode Configuration
// After MPR121.begin():
// Reduce filtering for faster response (at cost of noise)
// First stop and reconfigure:
Wire.beginTransmission(0x5C);
Wire.write(0x5E); // ECR register — stop mode
Wire.write(0x00);
Wire.endTransmission();
// Set FFI (First Filter Iterations) and CDC (Charge Discharge Current)
// for the electrode you want to use as proximity sensor
Wire.beginTransmission(0x5C);
Wire.write(0x5C); // AFE Config 2
Wire.write(0x10); // AFES=1 (6 samples), SAMI=0, OORIE=0, ARFIE=0
Wire.endTransmission();
// Restart with all 12 electrodes enabled
Wire.beginTransmission(0x5C);
Wire.write(0x5E);
Wire.write(0x8F); // ELE0-ELE11 enabled, CL=10
Wire.endTransmission();
For most proximity applications the default configuration works well; the main knob is the touch/release threshold pair — lowering both thresholds increases range but also increases false positives.
Environmental Compensation
Humidity and temperature shift the MPR121’s baseline. The chip’s auto-calibration handles slow drift, but rapid environment changes can cause spurious events. If you’re installing outdoors or in a humid venue, raise the baseline tracking rate:
// Baseline Tracking Register (BTR) — default is slow; speed it up
// not exposed in the default MPR121 library; use direct Wire writes
Wire.beginTransmission(0x5C);
Wire.write(0x2B); // MHD rising
Wire.write(0x04);
Wire.endTransmission();
Detailed register maps are in the NXP MPR121 datasheet (AN3889).
Building a Proximity-controlled Visualiser
Here is a complete sketch that uses proximity data from all 12 electrodes to control a flowing particle field. Each electrode’s proximity value controls one stream of particles.
let proxValues = new Array(12).fill(0);
let streams = [];
let port, reader;
function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB, 360, 100, 100, 100);
for (let i = 0; i < 12; i++) {
streams.push([]);
}
let btn = createButton('Connect');
btn.position(20, 20);
btn.mousePressed(connectSerial);
}
async function connectSerial() {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
reader = port.readable.getReader();
readLoop();
}
async function readLoop() {
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value);
let lines = buf.split('\n');
buf = lines.pop();
lines.forEach(parseLine);
}
}
function parseLine(line) {
line = line.trim();
if (line.startsWith('D:')) {
let parts = line.slice(2).split(',');
for (let i = 0; i < 12; i++) {
let v = parseFloat(parts[i]);
if (!isNaN(v)) proxValues[i] = v;
}
}
}
function draw() {
background(240, 30, 10, 25);
for (let i = 0; i < 12; i++) {
let intensity = constrain(proxValues[i] / 300, 0, 1);
if (intensity < 0.05) continue;
let x = map(i, 0, 11, width * 0.1, width * 0.9);
let hue = map(i, 0, 11, 200, 320);
// Spawn particles proportional to proximity
let count = floor(intensity * 5);
for (let p = 0; p < count; p++) {
streams[i].push({
x: x + random(-20, 20),
y: height,
vx: random(-1, 1),
vy: -random(2, 5) * intensity,
life: random(40, 80) * intensity,
maxLife: 0,
hue: hue,
size: random(4, 12) * intensity
});
streams[i][streams[i].length - 1].maxLife = streams[i][streams[i].length - 1].life;
}
// Update + draw particles
for (let j = streams[i].length - 1; j >= 0; j--) {
let p = streams[i][j];
p.x += p.vx;
p.y += p.vy;
p.vy *= 0.98;
p.vx += random(-0.1, 0.1);
p.life--;
let alpha = map(p.life, 0, p.maxLife, 0, 80);
fill(p.hue, 70, 100, alpha);
noStroke();
ellipse(p.x, p.y, p.size);
if (p.life <= 0) streams[i].splice(j, 1);
}
// Column indicator
noFill();
stroke(hue, 60, 80, 40);
strokeWeight(1);
let barH = intensity * height * 0.5;
rect(x - 10, height - barH, 20, barH);
}
}
Electrode Design for Proximity
The physical electrode dramatically affects proximity range:
- Larger area → longer range (capacitance scales with area)
- Rounded edges → smoother field, fewer hotspots
- Multiple small electrodes vs one large → use small ones when you need positional resolution; use one large one for maximum range
- Electric Paint poured into a shape → works well; thicker layers don’t significantly increase range but do lower surface resistance, which can help with stability in humid conditions
- Foil tape → excellent for prototyping; apply to cardboard or foam core and connect with a crocodile clip
For theremin-style installations a 30–40 cm strip of conductive material connected to a single electrode gives a usable range of about 20–30 cm above the surface.
Next Steps
- Combine proximity centroid tracking with OSC output to control Ableton Live parameters
- Add a Kalman filter on the centroid position for smoother tracking
- Use multiple proximity sensors on different sides of an object to reconstruct 3D hand position
- Experiment with the MPR121’s built-in proximity mode (ELEPROX) for even longer range sensing