Custom Touch Board Firmware from Scratch
Rewrite the Touch Board's firmware to own the full hardware stack — MPR121, VS1053, SD card, USB, and power management.
The Touch Board ships with polished firmware that makes getting started effortless. But it makes choices: 12 touch channels mapped to 12 MP3 files, monophonic playback, fixed thresholds. Once you understand the hardware beneath the firmware you can make different choices — bespoke polyphonic behaviour, USB MIDI without Serial, hybrid audio/data modes, or entirely custom protocols.
This tutorial strips away the abstraction layer and writes firmware from first principles against the raw hardware.
Hardware Architecture
The Touch Board’s MCU is an ATmega32U4 at 8 MHz (3.3 V). Its peripherals communicate over two buses:
ATmega32U4
├── I²C (SDA D2, SCL D3)
│ └── MPR121 (addr 0x5C) — touch sensing
├── SPI (SCK D15, MOSI D16, MISO D14)
│ ├── VS1053 (XCS D6, XDCS D7, DREQ D2*) — audio codec
│ └── SD card (CS D4) — audio files
├── USB (hardware D+/D−) — CDC serial or HID/MIDI
└── GPIO
├── MIDI TX (D1) — optional hardware MIDI
└── LED (D13)
*DREQ shares the physical pin with I²C SDA via a resistor-based multiplexer on the PCB — the stock firmware manages this carefully. In custom firmware you may choose to use only one bus at a time.
Starting From a Blank Sketch
Use the Arduino IDE with the SparkFun ATmega32U4 breakout board definition, or better, use PlatformIO with board = sparkfun_promicro8. Set:
; platformio.ini
[env:touchboard]
platform = atmelavr
board = sparkfun_promicro8
framework = arduino
build_flags = -DF_CPU=8000000L
lib_deps =
adafruit/Adafruit MPR121
sparkfun/SparkFun MP3 Player Shield Arduino Library
greiman/SdFat
Blank main.cpp:
#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000); // wait for USB serial up to 3 s
Serial.println(F("Touch Board custom firmware starting"));
}
void loop() {
// blank for now
}
Flash this. If you see the startup message, the MCU, bootloader, and USB CDC are all working.
Driving the MPR121 Directly
The Adafruit MPR121 library is good but hides configuration. For fine-grained control, drive the I²C registers directly.
Register Map Essentials
// MPR121 register addresses
#define MPR121_ADDR 0x5C
#define MPR121_ECR 0x5E // Electrode Config Register
#define MPR121_MHDR 0x2B // Max Half Delta Rising
#define MPR121_NHDR 0x2C // Noise Half Delta Rising
#define MPR121_NCLR 0x2D // Noise Count Limit Rising
#define MPR121_FDLR 0x2E // Filter Delay Rising
#define MPR121_MHDF 0x2F // Max Half Delta Falling
#define MPR121_NHDF 0x30 // Noise Half Delta Falling
#define MPR121_NCLF 0x31 // Noise Count Limit Falling
#define MPR121_FDLF 0x32 // Filter Delay Falling
#define MPR121_AFE1 0x5C // AFE Config 1 (note: same as ADDR — this is intentional, AFE1 is at 0x5C in the datasheet)
#define MPR121_AFE2 0x5D // AFE Config 2
#define MPR121_TOUCHTH(n) (0x41 + 2*(n)) // Touch threshold for electrode n
#define MPR121_RELTH(n) (0x42 + 2*(n)) // Release threshold for electrode n
#define MPR121_FDLR2 0x2E
Low-level Init
void mpr121Write(uint8_t reg, uint8_t val) {
Wire.beginTransmission(MPR121_ADDR);
Wire.write(reg); Wire.write(val);
Wire.endTransmission();
}
uint8_t mpr121Read(uint8_t reg) {
Wire.beginTransmission(MPR121_ADDR);
Wire.write(reg);
Wire.endTransmission(false);
Wire.requestFrom(MPR121_ADDR, 1);
return Wire.available() ? Wire.read() : 0;
}
uint16_t mpr121ReadWord(uint8_t reg) {
Wire.beginTransmission(MPR121_ADDR);
Wire.write(reg);
Wire.endTransmission(false);
Wire.requestFrom(MPR121_ADDR, 2);
uint16_t lo = Wire.read();
uint16_t hi = Wire.read();
return (hi << 8) | lo;
}
void mpr121Init() {
// Must be in stop mode before configuring
mpr121Write(MPR121_ECR, 0x00);
// Baseline tracking
mpr121Write(0x2B, 0x01); // MHDR
mpr121Write(0x2C, 0x01); // NHDR
mpr121Write(0x2D, 0x0E); // NCLR
mpr121Write(0x2E, 0x00); // FDLR
mpr121Write(0x2F, 0x01); // MHDF
mpr121Write(0x30, 0x05); // NHDF
mpr121Write(0x31, 0x01); // NCLF
mpr121Write(0x32, 0x00); // FDLF
// Touch + release thresholds for all 12 electrodes
for (int i = 0; i < 12; i++) {
mpr121Write(0x41 + 2*i, 12); // touch
mpr121Write(0x42 + 2*i, 6); // release
}
// AFE config
mpr121Write(0x5C, 0x10); // AFE1: 16 samples
mpr121Write(0x5D, 0x20); // AFE2: charge time 0.5 µs
// Enable all 12 electrodes, CL=10 (baseline tracking on)
mpr121Write(MPR121_ECR, 0x8F);
}
uint16_t mpr121TouchStatus() {
return mpr121ReadWord(0x00);
}
int mpr121Filtered(int electrode) {
return (int)mpr121ReadWord(0x04 + 2*electrode);
}
int mpr121Baseline(int electrode) {
return (int)mpr121Read(0x1E + electrode) << 2;
}
Custom Touch Loop
uint16_t lastStatus = 0;
void readTouches() {
uint16_t status = mpr121TouchStatus();
if (status == lastStatus) return;
uint16_t changed = status ^ lastStatus;
for (int i = 0; i < 12; i++) {
if (!(changed & (1 << i))) continue;
if (status & (1 << i)) {
onTouch(i);
} else {
onRelease(i);
}
}
lastStatus = status;
}
void onTouch(int e) { Serial.print("T:"); Serial.println(e); }
void onRelease(int e) { Serial.print("R:"); Serial.println(e); }
Driving the VS1053 Directly
The VS1053B is a full audio codec: MP3/AAC/OGG/WAV decode, a DSP patch interface, and a real-time MIDI synthesiser built-in. Driving it directly (without the SparkFun library) gives access to all of this.
SPI Configuration
The VS1053 uses two SPI chip selects:
- XCS (command/data): control registers over SPI at ≤ 250 kHz
- XDCS (data): audio data stream over SPI at ≤ 8 MHz (at 3.3 V)
- DREQ: data request line — the VS1053 asserts high when its FIFO is ready for more data
const int VS_XCS = 6;
const int VS_XDCS = 7;
const int VS_DREQ = 2; // shared with MPR121 IRQ — manage carefully
void vs1053WriteReg(uint8_t reg, uint16_t val) {
// Switch SPI to slow speed for control
SPI.beginTransaction(SPISettings(250000, MSBFIRST, SPI_MODE0));
digitalWrite(VS_XCS, LOW);
SPI.transfer(0x02); // write command
SPI.transfer(reg);
SPI.transfer(val >> 8);
SPI.transfer(val & 0xFF);
digitalWrite(VS_XCS, HIGH);
SPI.endTransaction();
while (!digitalRead(VS_DREQ)); // wait for DREQ
}
uint16_t vs1053ReadReg(uint8_t reg) {
SPI.beginTransaction(SPISettings(250000, MSBFIRST, SPI_MODE0));
digitalWrite(VS_XCS, LOW);
SPI.transfer(0x03); // read command
SPI.transfer(reg);
uint16_t hi = SPI.transfer(0xFF);
uint16_t lo = SPI.transfer(0xFF);
digitalWrite(VS_XCS, HIGH);
SPI.endTransaction();
while (!digitalRead(VS_DREQ));
return (hi << 8) | lo;
}
void vs1053SendData(uint8_t *buf, size_t len) {
SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
digitalWrite(VS_XDCS, LOW);
for (size_t i = 0; i < len; i++) {
while (!digitalRead(VS_DREQ)); // wait before each 32-byte chunk
SPI.transfer(buf[i]);
}
digitalWrite(VS_XDCS, HIGH);
SPI.endTransaction();
}
Soft Reset and Mode
void vs1053Init() {
pinMode(VS_XCS, OUTPUT); digitalWrite(VS_XCS, HIGH);
pinMode(VS_XDCS, OUTPUT); digitalWrite(VS_XDCS, HIGH);
pinMode(VS_DREQ, INPUT);
SPI.begin();
delay(100);
// Soft reset
vs1053WriteReg(0x00, 0x0804); // MODE: SM_RESET | SM_SDINEW
delay(100);
while (!digitalRead(VS_DREQ));
// Set clock multiplier (3x = 36.864 MHz internal)
vs1053WriteReg(0x03, 0x6000);
// Volume: 0x0000 = full volume, 0xFEFE = muted
vs1053WriteReg(0x0B, 0x0000);
}
Streaming MP3 from SD Card
#include <SdFat.h>
SdFat SD;
SdFile mp3File;
const int SD_CS = 4;
void vs1053PlayFile(const char *path) {
if (!mp3File.open(path, O_READ)) return;
uint8_t buf[32];
size_t n;
while ((n = mp3File.read(buf, 32)) > 0) {
while (!digitalRead(VS_DREQ)); // wait for FIFO space
vs1053SendData(buf, n);
// Interleave touch scanning
readTouches();
}
mp3File.close();
}
By controlling the play loop yourself you can interleave touch scanning, MIDI generation, or USB communication at any point in the audio stream.
USB MIDI Without Serial
The ATmega32U4’s USB hardware supports custom HID descriptors. Use the MIDIUSB library (which replaces the CDC serial descriptor with a USB MIDI descriptor) to present the Touch Board as a class-compliant USB MIDI device:
#include <MIDIUSB.h>
void sendNoteOn(uint8_t channel, uint8_t note, uint8_t velocity) {
midiEventPacket_t event = {0x09, 0x90 | channel, note, velocity};
MidiUSB.sendMIDI(event);
MidiUSB.flush();
}
void sendNoteOff(uint8_t channel, uint8_t note) {
midiEventPacket_t event = {0x08, 0x80 | channel, note, 0};
MidiUSB.sendMIDI(event);
MidiUSB.flush();
}
void sendCC(uint8_t channel, uint8_t cc, uint8_t value) {
midiEventPacket_t event = {0x0B, 0xB0 | channel, cc, value};
MidiUSB.sendMIDI(event);
MidiUSB.flush();
}
// Map proximity to pitch bend
void sendPitchBend(uint8_t channel, int16_t bend) {
// bend: -8192 to +8191
bend += 8192;
midiEventPacket_t event = {0x0E, 0xE0 | channel,
(uint8_t)(bend & 0x7F),
(uint8_t)((bend >> 7) & 0x7F)};
MidiUSB.sendMIDI(event);
MidiUSB.flush();
}
With MIDIUSB active, the Touch Board appears as a MIDI device in DAWs, web apps using the WebMIDI API, and Max/MSP — no driver, no configuration.
Combining Everything: A Complete Custom Firmware
#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SdFat.h>
#include <MIDIUSB.h>
// ... (include all helper functions from above)
// Musical mapping
const uint8_t noteMap[12] = {60,62,64,65,67,69,71,72,74,76,77,79}; // C major scale
void onTouch(int e) {
sendNoteOn(0, noteMap[e], 100);
}
void onRelease(int e) {
sendNoteOff(0, noteMap[e]);
}
void setup() {
Wire.begin();
mpr121Init();
vs1053Init();
SD.begin(SD_CS, SD_SCK_MHZ(8));
}
void loop() {
// 1. Read touch events
readTouches();
// 2. Send proximity as pitch bend on electrode 0
static unsigned long lastProx = 0;
if (millis() - lastProx > 20) {
int prox = mpr121Baseline(0) - mpr121Filtered(0);
prox = constrain(prox, 0, 300);
sendCC(0, 1, map(prox, 0, 300, 0, 127)); // modulation wheel
lastProx = millis();
}
}
Debugging Custom Firmware
Without Serial (if you’ve loaded MIDIUSB which replaces CDC), use an OLED or logic analyser:
- Logic analyser on I²C: verify MPR121 is responding and check the ECR register value to confirm electrodes are enabled
- Logic analyser on SPI: monitor VS1053 data writes and DREQ state
- LED heartbeat: blink D13 in the main loop; if blinking stops, you’ve hit a hang (usually DREQ stuck low or I²C freeze)
Next Steps
- Implement a dual-CDC-and-MIDI composite USB descriptor for simultaneous Serial debug and MIDI output
- Port the firmware to the ESP32 for WiFi/Bluetooth connectivity with the same hardware peripherals
- Write a VS1053 real-time effects plugin using the chip’s built-in DSP patch loader
- Add DFU bootloader support for field firmware updates without a programmer