name: arduino-midi-sequencer description: "Build or modify a step sequencer on Arduino/RP2040 with USB MIDI output, EEPROM pattern storage, and external clock sync. Use this skill whenever the user works on EMBRYO_FIRMWARE, Zuno Labs sequencer, RP2040 MIDI project, Arduino step sequencer, or mentions TinyUSB MIDI, 16-step/64-step sequencer, CD4067 multiplexer, PPQN clock, EEPROM pattern slots, or any Arduino-based MIDI controller with sequencing functionality. Also trigger for questions about Adafruit_TinyUSB MIDI, USB device MIDI on RP2040, or anti-drift timing in embedded sequencers."
Arduino MIDI Step Sequencer Skill
Reference project: EMBRYO_FIRMWARE (RP2040/Pico, Arduino IDE) 4 tracks x 64 steps, USB MIDI via Adafruit_TinyUSB, 4 EEPROM pattern slots, external clock slave.
The EXPORTS/ folder in EMBRYO_FIRMWARE has the cleanest modular version of all data structures and engines -- prefer reading those headers first.
Project Structure
ProjectName/
+-- ProjectName.ino (Arduino entry point: setup/loop)
+-- Config.h (Pin defs, constants, compile flags)
+-- SEQ_DataStructures.h (Step, Track, SequencerState structs)
+-- SEQ_TimingEngine.h (BPM, tick, external clock)
+-- SEQ_NoteScheduler.h (noteOn/noteOff with active note buffer)
+-- SEQ_PatternStorage.h (EEPROM save/load, magic byte)
+-- USB_MidiEngine.h (Adafruit_TinyUSB send helpers)
+-- Display.h (SSD1306 UIManager, dirty flag)
+-- InputManager.h (Multiplexer4067 + encoder polling)
+-- libraries.txt (Adafruit_TinyUSB, SSD1306, GFX, Encoder)
Data Structures
// SEQ_DataStructures.h
struct Step {
bool active = false;
uint8_t velocity = 100; // 0-127
uint8_t probability = 100; // 0-100, increments of 5
uint8_t midiNote = 60; // 36-84 (C2-C6)
};
struct Track {
Step steps[64];
uint8_t length = 16; // active steps (1-64)
uint8_t channel = 1; // MIDI ch 1-16
bool muted = false;
};
struct SequencerState {
Track tracks[4];
uint8_t bpm = 120; // 40-240
uint8_t swing = 0; // 0-75 (%)
uint8_t currentStep = 0;
bool playing = false;
uint8_t currentPattern = 0; // 0-3 (EEPROM slots)
};
Timing Engine -- Anti-Drift Pattern
The key rule: use lastMicros += (not = micros()) to prevent timing drift
accumulating over many steps.
// SEQ_TimingEngine.h
static unsigned long lastClockMicros = 0;
static uint8_t ppqnCount = 0; // 24 PPQN = 1 quarter note
unsigned long stepIntervalMicros(uint8_t bpm) {
// 1 quarter note = 24 ticks; 16th note step = 6 ticks
return (60000000UL / bpm) / 6;
}
// Call from loop() -- internal clock
void seqUpdate(SequencerState& seq) {
if (!seq.playing) return;
unsigned long now = micros();
if (now - lastClockMicros >= stepIntervalMicros(seq.bpm)) {
lastClockMicros += stepIntervalMicros(seq.bpm); // anti-drift!
advanceStep(seq);
}
}
// External clock slave -- call from MIDI receive callback
// 0xF8 = clock tick (24 per quarter note)
// 0xFA = start, 0xFC = stop
void handleMidiClock(uint8_t status, SequencerState& seq) {
switch (status) {
case 0xFA: seq.playing = true; seq.currentStep = 0; break;
case 0xFC: seq.playing = false; allNotesOff(); break;
case 0xF8:
if (!seq.playing) return;
ppqnCount++;
if (ppqnCount >= 6) { // every 6 ticks = 1/16 note
ppqnCount = 0;
advanceStep(seq);
}
break;
}
}
USB MIDI via Adafruit_TinyUSB
// In .ino or USB_MidiEngine.h
#include <Adafruit_TinyUSB.h>
Adafruit_USBD_MIDI usb_midi;
uint8_t const desc_midi_jack_itf[] = { TUD_MIDI_DESCRIPTOR(0, 0, EPNUM_MIDI, 64) };
void setupMidi() {
usb_midi.setStringDescriptor("EMBRYO Midi Controller");
TinyUSBDevice.setManufacturerDescriptor("Zuno Labs");
usb_midi.begin();
while (!TinyUSBDevice.mounted()) delay(1);
}
void midiNoteOn(uint8_t ch, uint8_t note, uint8_t vel) {
uint8_t msg[3] = { uint8_t(0x90 | (ch - 1)), note, vel };
usb_midi.write(msg, 3);
}
void midiNoteOff(uint8_t ch, uint8_t note) {
uint8_t msg[3] = { uint8_t(0x80 | (ch - 1)), note, 0 };
usb_midi.write(msg, 3);
}
void midiCC(uint8_t ch, uint8_t cc, uint8_t val) {
uint8_t msg[3] = { uint8_t(0xB0 | (ch - 1)), cc, val };
usb_midi.write(msg, 3);
}
// Send clock tick (24 PPQN)
void midiClockTick() {
uint8_t msg[1] = { 0xF8 };
usb_midi.write(msg, 1);
}
// Receive -- poll in loop()
void midiReceive(SequencerState& seq) {
uint8_t buf[64];
uint32_t count = usb_midi.read(buf, sizeof(buf));
for (uint32_t i = 0; i + 3 <= count; i += 4) {
uint8_t status = buf[i + 1];
handleMidiClock(status, seq);
}
}
Note-Off Scheduler (Active Note Buffer)
Prevent stuck notes by tracking all active noteOns and scheduling noteOffs.
struct ActiveNote {
bool active = false;
uint8_t channel;
uint8_t note;
unsigned long offTime; // micros()
};
static ActiveNote activeNotes[32];
void scheduleNoteOn(uint8_t ch, uint8_t note, uint8_t vel,
unsigned long durationMicros) {
midiNoteOn(ch, note, vel);
for (auto& n : activeNotes) {
if (!n.active) {
n = { true, ch, note, micros() + durationMicros };
return;
}
}
}
// Call from loop() every iteration
void processNoteOffs() {
unsigned long now = micros();
for (auto& n : activeNotes) {
if (n.active && now >= n.offTime) {
midiNoteOff(n.channel, n.note);
n.active = false;
}
}
}
void allNotesOff() {
for (auto& n : activeNotes) {
if (n.active) {
midiNoteOff(n.channel, n.note);
n.active = false;
}
}
}
Advancing a Step
void advanceStep(SequencerState& seq) {
unsigned long stepDur = stepIntervalMicros(seq.bpm) * 3 / 4; // 75% gate
for (int t = 0; t < 4; t++) {
Track& track = seq.tracks[t];
if (track.muted) continue;
uint8_t si = seq.currentStep % track.length;
Step& step = track.steps[si];
if (step.active && step.probability > 0) {
if (step.probability >= 100 || random(100) < step.probability) {
scheduleNoteOn(track.channel, step.midiNote, step.velocity, stepDur);
}
}
}
seq.currentStep = (seq.currentStep + 1) % 64;
}
EEPROM Pattern Storage
// SEQ_PatternStorage.h
#include <EEPROM.h>
static const uint8_t MAGIC_BYTE = 0xEB;
static const uint16_t PATTERN_SIZE = sizeof(SequencerState); // ~1031 bytes
uint16_t patternAddress(uint8_t slot) {
return 1 + slot * (PATTERN_SIZE + 1);
}
void savePattern(uint8_t slot, const SequencerState& seq) {
uint16_t addr = patternAddress(slot);
EEPROM.write(addr, MAGIC_BYTE);
EEPROM.put(addr + 1, seq);
EEPROM.commit(); // required on RP2040
}
bool loadPattern(uint8_t slot, SequencerState& seq) {
uint16_t addr = patternAddress(slot);
if (EEPROM.read(addr) != MAGIC_BYTE) return false;
EEPROM.get(addr + 1, seq);
return true;
}
void initStorage(SequencerState& seq) {
if (!loadPattern(seq.currentPattern, seq)) {
for (int t = 0; t < 4; t++) seq.tracks[t] = Track{};
savePattern(0, seq);
}
}
Multiplexer Input (CD4067)
16-channel analog mux -- pads 1-16 on one mux, buttons on another.
// InputManager.h
const uint8_t MUX_SIG_PADS = 26; // GP26 -- pad mux signal
const uint8_t MUX_SIG_BUTTONS = 27; // GP27 -- button mux signal
const uint8_t MUX_SEL[4] = {10, 11, 12, 13};
void muxSelect(uint8_t ch) {
for (int i = 0; i < 4; i++)
digitalWrite(MUX_SEL[i], (ch >> i) & 1);
}
bool readPad(uint8_t padIndex) {
muxSelect(padIndex & 0x0F);
delayMicroseconds(10); // settle time
return analogRead(MUX_SIG_PADS) > 512;
}
// Scan all 16 pads -- call at ~200Hz (every 5ms)
void scanPads(bool padState[16]) {
for (int i = 0; i < 16; i++) {
padState[i] = readPad(i);
}
}
Display (SSD1306, 128x32)
// Display.h
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
Adafruit_SSD1306 display(128, 32, &Wire, -1);
class UIManager {
bool dirty_ = true;
uint32_t lastDraw_ = 0;
uint32_t rateMs_ = 250; // throttle during playback (4 Hz)
public:
void begin() {
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.clearDisplay();
display.display();
}
void markDirty() { dirty_ = true; }
void setRate(uint32_t ms) { rateMs_ = ms; }
void update(const SequencerState& seq) {
if (!dirty_) return;
uint32_t now = millis();
if (now - lastDraw_ < rateMs_) return;
lastDraw_ = now;
dirty_ = false;
display.clearDisplay();
drawStepRow(seq);
drawStatus(seq);
display.display();
}
// Draw current track's 16 active steps as small squares
void drawStepRow(const SequencerState& seq) {
const Track& t = seq.tracks[0];
for (int i = 0; i < 16; i++) {
int x = 2 + i * 8;
bool active = t.steps[i].active;
bool current = (seq.currentStep % 16 == i) && seq.playing;
if (active) display.fillRect(x, 20, 6, 6, SSD1306_WHITE);
else display.drawRect(x, 20, 6, 6, SSD1306_WHITE);
if (current) display.drawRect(x-1, 19, 8, 8, SSD1306_WHITE);
}
}
void drawStatus(const SequencerState& seq) {
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.print(seq.playing ? "PLAY " : "STOP ");
display.print(seq.bpm);
display.print(" BPM P");
display.print(seq.currentPattern + 1);
}
};
Pin Reference (RP2040 / Pico)
| Signal | GPIO |
|---|---|
| SDA (I2C0) | GP4 |
| SCL (I2C0) | GP5 |
| MUX signal pads | GP26 |
| MUX signal btns | GP27 |
| MUX SEL A | GP10 |
| MUX SEL B | GP11 |
| MUX SEL C | GP12 |
| MUX SEL D | GP13 |
| Encoder interrupt | GP3 |
Required Libraries (Arduino IDE)
Adafruit TinyUSB Library (USB MIDI device)
Adafruit SSD1306 (OLED driver)
Adafruit GFX Library (Graphics primitives)
Adafruit BusIO (I2C dependency)
Encoder (Rotary encoder or custom polling)
Board: Raspberry Pi Pico via Earle Philhower RP2040 core. USB Stack: set to Adafruit TinyUSB in Tools menu.
When to Read Reference Files
- For the full modular header breakdown (EMBRYO EXPORTS/): read
references/embryo-firmware-architecture.md - For encoder + MCP23X17 I2C expander input: read
references/encoder-input.md