arduino-midi-sequencer

star 0

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.

sebasdv By sebasdv schedule Updated 2/24/2026

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
Install via CLI
npx skills add https://github.com/sebasdv/my-skills --skill arduino-midi-sequencer
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator