Multi-electrode Patterns

Chord detection, gesture sequences, and chained electrode logic for expressive multi-touch interactions.

⏱ 55 min MPR121 chords gestures state machine patterns

A single electrode answers one question: touched or not. Twelve electrodes together can answer far richer questions — which combination is active, in what order did they fire, how long has each been held? This tutorial builds the logic to turn simultaneous and sequential electrode events into expressive, composable gestures.

Chord Detection

A chord is two or more electrodes touched at the same time. Because the MPR121 scans all electrodes before reporting, you can read the full 12-bit touch status register in one call and test any bitmask you like.

Reading the Status Register

#include <MPR121.h>

void setup() {
  Serial.begin(115200);
  MPR121.begin(0x5C);
  MPR121.setTouchThreshold(40);
  MPR121.setReleaseThreshold(20);
}

void loop() {
  if (MPR121.touchStatusChanged()) {
    MPR121.updateTouchData();
    uint16_t status = MPR121.getTouchData(); // bitmask, bit N = electrode N
    Serial.println(status, BIN);
    detectChords(status);
  }
}

getTouchData() returns a 16-bit integer where each set bit corresponds to a currently-touched electrode. Chord detection is then just bitwise comparison.

Defining and Matching Chords

struct Chord {
  const char* name;
  uint16_t    mask;
};

// Define chords as bitmasks (bit 0 = E0, bit 1 = E1, …)
Chord chords[] = {
  { "OCTAVE",   0b000000000011 }, // E0 + E1
  { "FIFTH",    0b000000000101 }, // E0 + E2
  { "TRIAD",    0b000000000111 }, // E0 + E1 + E2
  { "TOP_PAIR", 0b110000000000 }, // E10 + E11
};
const int NUM_CHORDS = sizeof(chords) / sizeof(chords[0]);

void detectChords(uint16_t status) {
  for (int i = 0; i < NUM_CHORDS; i++) {
    // All bits in the mask must be set; extra touches are ignored
    if ((status & chords[i].mask) == chords[i].mask) {
      Serial.print("CHORD: ");
      Serial.println(chords[i].name);
    }
  }
}

This approach lets you layer chords — a two-finger chord can still trigger even when a third electrode is touched simultaneously, as long as you design the masks that way.

Sequential Gesture Detection

Chords capture simultaneity; sequential gestures capture order. A swipe across electrodes 0 → 1 → 2 → 3 is a different interaction from the same four electrodes touched in reverse.

State Machine Approach

// A gesture is a sequence of electrode indices
struct Gesture {
  const char* name;
  int         sequence[8];
  int         length;
  unsigned long timeoutMs; // max ms between steps
};

Gesture gestures[] = {
  { "SWIPE_RIGHT", {0,1,2,3,4,5}, 6, 400 },
  { "SWIPE_LEFT",  {5,4,3,2,1,0}, 6, 400 },
  { "V_SHAPE",     {0,1,2,1,0},   5, 600 },
};
const int NUM_GESTURES = sizeof(gestures) / sizeof(gestures[0]);

// Per-gesture tracking
int           gProgress[3]  = {0};
unsigned long gLastTime[3]  = {0};

void updateGestures(int electrode, bool isTouched) {
  if (!isTouched) return;
  unsigned long now = millis();

  for (int g = 0; g < NUM_GESTURES; g++) {
    Gesture& ges = gestures[g];
    int      idx = gProgress[g];

    // Timeout: reset if too slow
    if (idx > 0 && (now - gLastTime[g]) > ges.timeoutMs) {
      gProgress[g] = 0;
      idx = 0;
    }

    if (electrode == ges.sequence[idx]) {
      gProgress[g]++;
      gLastTime[g] = now;

      if (gProgress[g] == ges.length) {
        Serial.print("GESTURE: ");
        Serial.println(ges.name);
        gProgress[g] = 0;
      }
    } else if (electrode != ges.sequence[0]) {
      // Wrong electrode but not a fresh start — reset
      gProgress[g] = 0;
    } else {
      // Happens to be the start electrode
      gProgress[g] = 1;
      gLastTime[g] = now;
    }
  }
}

void loop() {
  if (MPR121.touchStatusChanged()) {
    MPR121.updateTouchData();
    for (int i = 0; i < 12; i++) {
      if (MPR121.isNewTouch(i))   updateGestures(i, true);
    }
  }
}

Sending Gesture Events over Serial

For use with p5.js or Processing, encode the gesture name and send it as a single line:

// Replace Serial.print("GESTURE: ") + name with:
Serial.print("G:");
Serial.println(ges.name);

// Chord events:
Serial.print("C:");
Serial.println(chords[i].name);

// Individual touch/release (as before):
// T:N  R:N

p5.js: Receiving and Acting on Patterns

let port, reader;
let activeChords  = new Set();
let lastGesture   = '';
let touchStates   = new Array(12).fill(false);

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('T:')) {
    let n = parseInt(line.slice(2));
    if (!isNaN(n)) touchStates[n] = true;
  } else if (line.startsWith('R:')) {
    let n = parseInt(line.slice(2));
    if (!isNaN(n)) touchStates[n] = false;
  } else if (line.startsWith('C:')) {
    let chord = line.slice(2);
    activeChords.add(chord);
    setTimeout(() => activeChords.delete(chord), 500);
  } else if (line.startsWith('G:')) {
    lastGesture = line.slice(2);
    setTimeout(() => { lastGesture = ''; }, 800);
  }
}

Visual Response to Chords and Gestures

let particles = [];

function setup() {
  createCanvas(windowWidth, windowHeight);
  let btn = createButton('Connect Touch Board');
  btn.position(20, 20);
  btn.mousePressed(connectSerial);
}

function draw() {
  background(10, 10, 20, 40);

  // Chord: flash a large ring
  if (activeChords.has('TRIAD')) {
    noFill();
    stroke(255, 200, 50, 180);
    strokeWeight(4);
    ellipse(width / 2, height / 2, 300, 300);
  }

  // Gesture: emit a wave
  if (lastGesture === 'SWIPE_RIGHT') spawnWave(1);
  if (lastGesture === 'SWIPE_LEFT')  spawnWave(-1);

  // Individual touches: dots around a circle
  noStroke();
  for (let i = 0; i < 12; i++) {
    let angle = map(i, 0, 12, 0, TWO_PI) - HALF_PI;
    let r     = 180;
    let cx    = width / 2 + cos(angle) * r;
    let cy    = height / 2 + sin(angle) * r;
    fill(touchStates[i] ? color(80, 200, 255) : color(40, 40, 80));
    ellipse(cx, cy, touchStates[i] ? 28 : 16);
  }

  // Particles
  for (let i = particles.length - 1; i >= 0; i--) {
    let p = particles[i];
    p.x  += p.vx;
    p.y  += p.vy;
    p.vy += 0.15;
    p.life--;
    fill(p.col[0], p.col[1], p.col[2], map(p.life, 0, 60, 0, 255));
    noStroke();
    ellipse(p.x, p.y, 6);
    if (p.life <= 0) particles.splice(i, 1);
  }
}

function spawnWave(dir) {
  for (let i = 0; i < 40; i++) {
    particles.push({
      x: dir > 0 ? 0 : width,
      y: random(height),
      vx: random(3, 8) * dir,
      vy: random(-2, 2),
      life: int(random(40, 80)),
      col: [random(100, 255), random(50, 180), 255]
    });
  }
}

Hold-duration Logic

How long an electrode has been held is another dimension of expression. Use timestamps to measure hold duration and trigger events at thresholds.

unsigned long touchStart[12] = {0};
bool          holdFired[12]  = {false};

void loop() {
  if (MPR121.touchStatusChanged()) {
    MPR121.updateTouchData();
    for (int i = 0; i < 12; i++) {
      if (MPR121.isNewTouch(i)) {
        touchStart[i] = millis();
        holdFired[i]  = false;
        Serial.print("T:"); Serial.println(i);
      }
      if (MPR121.isNewRelease(i)) {
        Serial.print("R:"); Serial.println(i);
      }
    }
  }

  // Poll for hold thresholds (500 ms and 2000 ms)
  unsigned long now = millis();
  for (int i = 0; i < 12; i++) {
    if (MPR121.getTouchData() & (1 << i)) {
      unsigned long held = now - touchStart[i];
      if (!holdFired[i] && held > 500) {
        Serial.print("H:"); Serial.print(i);
        Serial.print(","); Serial.println(held);
        holdFired[i] = true;
      }
    }
  }
}

On the p5.js side, H:N,ms lines let you trigger different behaviours — a short tap opens a panel, a long hold opens a different one.

Combining All Patterns

A complete interaction model might look like:

Event type Serial token Example
Touch T:N T:3
Release R:N R:3
Hold H:N,ms H:3,1200
Chord C:NAME C:TRIAD
Gesture G:NAME G:SWIPE_RIGHT
Proximity D:val,... D:120,80,45,...

Parsing all six in one parseLine() function gives your p5.js sketch a complete vocabulary of touch interactions without any additional libraries.

Next Steps

  • Combine chord detection with proximity data to create velocity-sensitive chords
  • Add a debounce layer: only fire a chord event after all chord electrodes have been stable for 20 ms
  • Map gesture sequences to musical scales: swipe right = next mode, swipe left = previous mode
  • Store gesture history in a ring buffer to replay interactions