Advanced MIDI Programming
Go beyond simple note-on/note-off by implementing pitch bend, control change, MIDI clock sync, SysEx messages, polyphonic aftertouch, and multi-channel routing on the Touch Board.
Overview
The Touch Board ships with a capable MIDI library, but most introductory examples only scratch the surface of the MIDI specification. In this tutorial you will implement pitch bend, control change (CC) messages, MIDI clock synchronisation, System Exclusive (SysEx) messages, polyphonic aftertouch, and multi-channel routing — all from a single Touch Board. By the end you will have a far more expressive instrument that integrates cleanly with any DAW or hardware synth.
Prerequisites
- Touch Board set to MIDI mode (hold the MIDI button on boot, or use the jumper pads)
- Arduino IDE with the Bare Conductive Touch Board libraries installed
- A DAW (Ableton Live, Logic Pro, Reaper) or a hardware synth connected via USB or a MIDI interface
- Basic familiarity with the MIDI 1.0 specification (byte values, channels 1–16)
1. Pitch Bend
Pitch bend is a 14-bit value (0–16383) centred at 8192 (no bend). It is sent on a specific channel as two 7-bit bytes: LSB first, then MSB.
#include <MIDI.h>
#include <MPR121.h>
MIDI_CREATE_DEFAULT_INSTANCE();
void sendPitchBend(int8_t channel, int16_t bendValue) {
// bendValue: -8192 (full down) to +8191 (full up), 0 = centre
int16_t midiValue = bendValue + 8192; // shift to unsigned range
uint8_t lsb = midiValue & 0x7F;
uint8_t msb = (midiValue >> 7) & 0x7F;
MIDI.sendPitchBend(bendValue, channel);
}
void loop() {
MPR121.updateAll();
// Map electrode 0 filtered data to pitch bend range
// getFilteredData returns roughly 100–65535; baseline ~12000
int raw = MPR121.getFilteredData(0);
int baseline = MPR121.getBaselineData(0);
int delta = baseline - raw; // positive delta = closer proximity
// Map delta (0–500 typical) to -8192..+8191
int bend = map(constrain(delta, 0, 500), 0, 500, 0, 16383) - 8192;
sendPitchBend(1, bend);
delay(10);
}
Tip: Pitch bend range on the receiving synth is usually ±2 semitones by default. Send a CC 101 (RPN MSB = 0) + CC 100 (RPN LSB = 0) + CC 6 (data entry = 12) sequence to set the range to ±12 semitones before performing.
2. Control Change (CC) Messages
CC messages are the workhorses of MIDI expression. Each is a combination of a controller number (0–127) and a value (0–127). Common assignments include:
| CC | Purpose |
|---|---|
| 1 | Modulation wheel |
| 7 | Channel volume |
| 10 | Pan |
| 11 | Expression |
| 64 | Sustain pedal (0/127) |
| 74 | Filter cutoff (Cutoff Freq) |
void sendAllCC() {
MPR121.updateAll();
for (uint8_t i = 0; i < 12; i++) {
if (MPR121.isNewTouch(i)) {
// Map each electrode to a different CC number
uint8_t ccNumber = 10 + i; // CC 10 through CC 21
uint8_t ccValue = 127;
MIDI.sendControlChange(ccNumber, ccValue, 1);
}
if (MPR121.isNewRelease(i)) {
uint8_t ccNumber = 10 + i;
MIDI.sendControlChange(ccNumber, 0, 1);
}
}
}
For continuous proximity control of a filter cutoff:
void proximityCCLoop() {
int raw = MPR121.getFilteredData(11); // use electrode 11 as proximity sensor
int baseline = MPR121.getBaselineData(11);
int delta = constrain(baseline - raw, 0, 400);
uint8_t ccVal = map(delta, 0, 400, 0, 127);
MIDI.sendControlChange(74, ccVal, 1); // CC 74 = filter cutoff
}
3. MIDI Clock Sync
MIDI Clock runs at 24 pulses per quarter note (PPQN). The Touch Board can act as a clock master, sending timing pulses that lock a drum machine or second synth to your tempo.
#include <MIDI.h>
MIDI_CREATE_DEFAULT_INSTANCE();
volatile float bpm = 120.0;
volatile uint32_t clockInterval; // microseconds between pulses
void updateClockInterval() {
// 24 pulses per beat, 60,000,000 µs per minute
clockInterval = (uint32_t)(60000000.0 / (bpm * 24.0));
}
void setup() {
MIDI.begin(MIDI_CHANNEL_OMNI);
updateClockInterval();
MIDI.sendRealTime(midi::Start); // tell devices to start
}
uint32_t lastClock = 0;
void loop() {
uint32_t now = micros();
if (now - lastClock >= clockInterval) {
MIDI.sendRealTime(midi::Clock);
lastClock = now;
}
MPR121.updateAll();
// Tap electrode 0 to increase BPM, electrode 1 to decrease
if (MPR121.isNewTouch(0)) { bpm = min(bpm + 1.0f, 240.0f); updateClockInterval(); }
if (MPR121.isNewTouch(1)) { bpm = max(bpm - 1.0f, 40.0f); updateClockInterval(); }
}
Send midi::Stop when you want to halt the downstream device, and midi::Continue to resume from the current position.
4. System Exclusive (SysEx) Messages
SysEx lets you send manufacturer-specific or arbitrary bulk data. A SysEx message starts with 0xF0, ends with 0xF7, and contains any bytes (with the MSB clear).
void sendSysEx() {
// Example: Roland GS reset (resets a Roland synth to General MIDI defaults)
uint8_t gsReset[] = { 0x41, 0x10, 0x42, 0x12, 0x40, 0x00, 0x7F, 0x00, 0x41 };
MIDI.sendSysEx(sizeof(gsReset), gsReset, true);
// The third parameter (true) tells the library the array already
// omits the 0xF0 and 0xF7 framing bytes.
}
// Custom SysEx to send a patch name string to a hypothetical device
void sendPatchName(const char* name) {
uint8_t buf[32];
uint8_t len = 0;
buf[len++] = 0x7D; // non-commercial use manufacturer ID
buf[len++] = 0x01; // device ID
buf[len++] = 0x10; // command: set patch name
while (*name && len < 28) {
buf[len++] = (uint8_t)(*name++) & 0x7F;
}
MIDI.sendSysEx(len, buf, true);
}
Caution: SysEx can cause brief audio dropouts on some hardware synths. Send it only at the start of a performance or between phrases, never mid-note.
5. Polyphonic Aftertouch
Polyphonic aftertouch sends per-note pressure values (note number + pressure byte) on a given channel. Few hardware synths support it, but soft synths in Ableton (e.g. Wavetable) and Max for Live patches often do.
// Assumes notes were triggered on channels matching electrode index
uint8_t noteNumbers[12] = {60, 62, 64, 65, 67, 69, 71, 72, 74, 76, 77, 79};
void sendPolyAT() {
MPR121.updateAll();
for (uint8_t i = 0; i < 12; i++) {
if (MPR121.getTouchData(i)) {
int raw = MPR121.getFilteredData(i);
int baseline = MPR121.getBaselineData(i);
int pressure = constrain(baseline - raw, 0, 300);
uint8_t atVal = map(pressure, 0, 300, 0, 127);
MIDI.sendAfterTouch(noteNumbers[i], atVal, 1);
}
}
}
6. Running Multiple MIDI Channels
Distributing electrodes across channels lets a DAW route each to a different instrument track.
// Assign each electrode to its own MIDI channel (1-based)
// Electrodes 0–11 → channels 1–12
void multiChannelNotes() {
MPR121.updateAll();
for (uint8_t i = 0; i < 12; i++) {
uint8_t ch = i + 1;
uint8_t note = noteNumbers[i];
if (MPR121.isNewTouch(i)) MIDI.sendNoteOn(note, 127, ch);
if (MPR121.isNewRelease(i)) MIDI.sendNoteOff(note, 0, ch);
}
}
In Ableton Live, create 12 MIDI tracks and set each to receive from “Touch Board” on channels 1 through 12 respectively. Drop a different instrument on each track and you have a 12-voice heterogeneous instrument.
Putting It All Together
Below is a minimal sketch that combines note output, per-note polyphonic aftertouch, and MIDI clock on separate channels:
#include <MIDI.h>
#include <MPR121.h>
#include <Wire.h>
MIDI_CREATE_DEFAULT_INSTANCE();
const uint8_t NOTES[12] = {60,62,64,65,67,69,71,72,74,76,77,79};
float bpm = 120.0;
uint32_t clockInterval;
uint32_t lastClock = 0;
void setup() {
Wire.begin();
MPR121.begin(0x5C);
MIDI.begin(MIDI_CHANNEL_OMNI);
clockInterval = (uint32_t)(60000000.0 / (bpm * 24.0));
MIDI.sendRealTime(midi::Start);
}
void loop() {
uint32_t now = micros();
if (now - lastClock >= clockInterval) {
MIDI.sendRealTime(midi::Clock);
lastClock = now;
}
MPR121.updateAll();
for (uint8_t i = 0; i < 12; i++) {
if (MPR121.isNewTouch(i)) MIDI.sendNoteOn(NOTES[i], 100, i + 1);
if (MPR121.isNewRelease(i)) MIDI.sendNoteOff(NOTES[i], 0, i + 1);
if (MPR121.getTouchData(i)) {
int delta = MPR121.getBaselineData(i) - MPR121.getFilteredData(i);
uint8_t at = map(constrain(delta, 0, 300), 0, 300, 0, 127);
MIDI.sendAfterTouch(NOTES[i], at, i + 1);
}
}
}
Troubleshooting
- Notes stuck on: always send a
noteOffin the release handler. Add anAllNotesOffCC (CC 123 = 0) to a dedicated “panic” electrode. - Clock drift:
micros()on the ATmega32U4 is accurate to ±0.5%, which accumulates over long sets. For critical sync, use an external master clock and configure the Touch Board as a slave viaMIDI.setHandleClock(). - SysEx ignored: confirm the manufacturer ID bytes match the target device’s documentation exactly — even a single wrong byte causes silent rejection.