Bare Conductive Expert Article 1

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.

⏱ 90 min firmware ATmega32U4 MPR121 VS1053 USB low-level

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