Community project

ESP32 Chord Synthesizer

ESP32
Photo of ESP32 Chord Synthesizer
Generated with AI

Paul G (paulG)

Published October 30, 2026

Build a MIDI synthesizer controller that generates chord progressions from seven buttons and a rotary encoder. This ESP32-based instrument displays the current key, chord quality, and scale degree on a small OLED screen, then sends the corresponding MIDI note data out through a standard 5-pin DIN connector to any MIDI-compatible synth or DAW.

The guide includes a complete wiring diagram showing how to connect the rotary encoder for octave and quality selection, seven chord buttons for scale degrees, and the MIDI output circuit built with a simple transistor switch. Assembly steps cover mounting the display and controls, wiring the button matrix, constructing the MIDI output stage, and uploading the firmware to get playing immediately.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

0.96" OLED Display 128x64 I2C (Blue)

Monochrome 0.96 inch OLED display with 128 x 64 blue pixels, driven by an SSD1306 controller over I2C (address 0x3C). It runs from 3.3 to 5 V and connects through a four-pin VCC, GND, SCL, SDA header.

1

KY-040 Rotary Encoder Module

5-pin incremental rotary encoder breakout with integrated momentary push switch. CLK and DT are the quadrature outputs; SW is the built-in push-button output and should not be modelled as a separate Push Button component.

1

Push Button

C chord button

Momentary push button switch

1

Push Button

D chord button

Momentary push button switch

1

Push Button

E chord button

Momentary push button switch

1

Push Button

F chord button

Momentary push button switch

1

Push Button

G chord button

Momentary push button switch

1

Push Button

A chord button

Momentary push button switch

1

Push Button

B chord button

Momentary push button switch

1

5-pin DIN MIDI output socket

A panel-mount five-pin DIN socket that sends MIDI notes to an external synthesizer.

1

2N3904 NPN Transistor

General-purpose NPN bipolar junction transistor (BJT) in a TO-92 package. Rated for 60V collector-emitter voltage and 200mA continuous collector current with an hFE (gain) of 100–300. Commonly used as a low-side switch or signal amplifier driven by a microcontroller digital output pin via a current-limiting base resistor.

1

Resistor

1 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

220 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

220 Ω

Through-hole resistor (current-limiting in series with an LED)

Assemble it in 5 steps

1. Place the board and controls

Put the ESP32 DevKit v1, OLED screen, rotary encoder, seven momentary buttons, and the DIN socket on a breadboard or front panel. Label the buttons C, D, E, F, G, A, and B from left to right so the labels match the firmware.

  • Keep the OLED label side facing you so you can read each printed pin name.
  • Use separate rows for the four legs of each small push button; legs on the same side are already connected inside the button.

2. Wire the screen and knob

Connect OLED VCC to the ESP32 3V3 pin (power), OLED GND to ESP32 GND (ground), OLED SDA to GPIO21 (data), and OLED SCL to GPIO22 (clock). Connect the KY-040 encoder VCC to 3V3 (power), GND to GND (ground), CLK to GPIO32 (turning signal), DT to GPIO33 (turning signal), and SW to GPIO4 (knob-push signal).

  • Some OLED boards print their header in a different physical order, so follow the printed VCC, GND, SDA, and SCL labels rather than the left-to-right order.
  • Power the encoder from 3V3, not 5V, so its output signals stay safe for the ESP32.

3. Wire the seven chord buttons

For every chord button, connect one side to GND (ground). Connect the other side of C to GPIO13, D to GPIO14, E to GPIO16, F to GPIO17, G to GPIO25, A to GPIO26, and B to GPIO27 (button signals).

  • All seven ground wires may join the same ground rail.
  • The firmware supplies the small internal pull-up resistors, so each button needs only its signal wire and ground wire.

4. Build the MIDI output switch

With the flat face of a standard 2N3904 toward you, confirm its lead order is Emitter, Base, Collector from left to right. Connect the emitter to GND (ground). Connect GPIO18 to one end of the 1 kΩ resistor, then connect its other end to the transistor base (signal). Connect the transistor collector to one end of a 220 Ω resistor, then connect that resistor's other end to the DIN socket pin marked 5 (MIDI signal). Connect the ESP32 5V/VIN pin to one end of the other 220 Ω resistor, then connect its other end to DIN pin 4 (MIDI loop power).

  • Use the tiny molded pin numbers on the DIN socket itself; they are more reliable than a drawing because the numbering looks mirrored when you turn the socket around.
  • Only DIN pins 4 and 5 are used for this MIDI output. Leave pins 1, 2, and 3 unconnected.

5. Power it safely from USB

Before connecting USB, inspect the breadboard for a wire accidentally joining 3V3, 5V, and GND. Then plug the ESP32 into USB; USB powers the board, the OLED, and the MIDI output circuit.

  • Do not connect the ESP32 3V3 pin to the 5V/VIN pin; that can damage the board.
  • Do not swap OLED VCC and GND; swapped power can damage the screen.
  • If the 2N3904 lead order does not match the marking on your particular transistor, use that part's datasheet instead; a wrong transistor orientation prevents MIDI output and may heat the part.

Review all connections

1. Connections between "oled" and "ESP32"

FunctionoledESP32
powerVCC → OLED power3V3
groundGND → OLED groundGND
i2cSCL → OLED clockGPIO 22
i2cSDA → OLED dataGPIO 21

2. Connections between "encoder" and "ESP32"

FunctionencoderESP32
powerVCC → encoder power3V3
groundGND → encoder groundGND
digitalCLK → knob rotation channel AGPIO 32
digitalDT → knob rotation channel BGPIO 33
digitalSW → knob pressGPIO 4

3. Connections between "chord_c" and "ESP32"

Functionchord_cESP32
groundGND → button groundGND
digitalSIGNAL → C chord triggerGPIO 13

4. Connections between "chord_d" and "ESP32"

Functionchord_dESP32
groundGND → button groundGND
digitalSIGNAL → D chord triggerGPIO 14

5. Connections between "chord_e" and "ESP32"

Functionchord_eESP32
groundGND → button groundGND
digitalSIGNAL → E chord triggerGPIO 16

6. Connections between "chord_f" and "ESP32"

Functionchord_fESP32
groundGND → button groundGND
digitalSIGNAL → F chord triggerGPIO 17

7. Connections between "chord_g" and "ESP32"

Functionchord_gESP32
groundGND → button groundGND
digitalSIGNAL → G chord triggerGPIO 25

8. Connections between "chord_a" and "ESP32"

Functionchord_aESP32
groundGND → button groundGND
digitalSIGNAL → A chord triggerGPIO 26

9. Connections between "chord_b" and "ESP32"

Functionchord_bESP32
groundGND → button groundGND
digitalSIGNAL → B chord triggerGPIO 27

10. Connections between "midi_base_r" and "ESP32"

Functionmidi_base_rESP32
digitalP1 → inverted MIDI serial signalGPIO 18
digitalP2 → 2N3904 NPN Transistor Base (B)EXT

11. Connections between "midi_q1" and "ESP32"

Functionmidi_q1ESP32
groundEmitter (E) → MIDI output returnGND
dataCollector (C) → Resistor P1EXT

12. Connections between "midi_signal_r" and "ESP32"

Functionmidi_signal_rESP32
digitalP2 → 5-pin DIN MIDI output socket PIN5EXT

13. Connections between "midi_vcc_r" and "ESP32"

Functionmidi_vcc_rESP32
powerP1 → USB 5 V MIDI loop supply5V
digitalP2 → 5-pin DIN MIDI output socket PIN4EXT

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <driver/uart.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

constexpr int OLED_SDA_PIN = 21;
constexpr int OLED_SCL_PIN = 22;
constexpr int ENCODER_CLK_PIN = 32;
constexpr int ENCODER_DT_PIN = 33;
constexpr int ENCODER_SW_PIN = 4;
constexpr int CHORD_C_PIN = 13;
constexpr int CHORD_D_PIN = 14;
constexpr int CHORD_E_PIN = 16;
constexpr int CHORD_F_PIN = 17;
constexpr int CHORD_G_PIN = 25;
constexpr int CHORD_A_PIN = 26;
constexpr int CHORD_B_PIN = 27;
constexpr int MIDI_TX_PIN = 18;

constexpr uint8_t MIDI_CHANNEL = 0; // MIDI channel 1
constexpr uint8_t VELOCITY = 100;
constexpr uint8_t BASE_NOTE = 60;   // C4
constexpr uint32_t DEBOUNCE_MS = 18;

Adafruit_SSD1306 display(128, 64, &Wire, -1);
HardwareSerial MidiSerial(2);

const uint8_t chordPins[7] = {
  CHORD_C_PIN, CHORD_D_PIN, CHORD_E_PIN, CHORD_F_PIN,
  CHORD_G_PIN, CHORD_A_PIN, CHORD_B_PIN
};
const int8_t degreeSemitones[7] = {0, 2, 4, 5, 7, 9, 11};
const char* degreeNames[7] = {"I", "ii", "iii", "IV", "V", "vi", "vii"};
const char* keyNames[12] = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"};
const char* qualityNames[3] = {"Major", "Minor", "7th"};

bool stablePressed[7] = {false, false, false, false, false, false, false};
bool rawPressed[7] = {false, false, false, false, false, false, false};
uint32_t lastButtonChange[7] = {0, 0, 0, 0, 0, 0, 0};
int keyOffset = 0;
uint8_t quality = 0;
int lastEncoderClk = HIGH;
bool lastEncoderButton = HIGH;
uint32_t lastEncoderButtonChange = 0;
bool screenDirty = true;

void sendMidi(uint8_t status, uint8_t data1, uint8_t data2) {
  MidiSerial.write(status);
  MidiSerial.write(data1);
  MidiSerial.write(data2);
}

uint8_t chordRoot(uint8_t degree) {
  return BASE_NOTE + keyOffset + degreeSemitones[degree];
}

void sendChord(uint8_t degree, bool noteOn) {
  const uint8_t root = chordRoot(degree);
  int8_t intervals[4] = {0, 4, 7, -1};
  uint8_t count = 3;

  if (quality == 1) {
    intervals[1] = 3;
  } else if (quality == 2) {
    intervals[3] = 10;
    count = 4;
  }

  const uint8_t status = (noteOn ? 0x90 : 0x80) | MIDI_CHANNEL;
  const uint8_t velocity = noteOn ? VELOCITY : 0;
  for (uint8_t i = 0; i < count; ++i) {
    sendMidi(status, root + intervals[i], velocity);
  }
}

void stopAllNotes() {
  sendMidi(0xB0 | MIDI_CHANNEL, 123, 0);
}

void drawScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.println(F("HiChord Lite"));
  display.drawFastHLine(0, 11, 128, SSD1306_WHITE);

  display.setTextSize(2);
  display.setCursor(0, 18);
  display.print(keyNames[keyOffset]);
  display.setTextSize(1);
  display.setCursor(42, 24);
  display.print(qualityNames[quality]);

  display.setCursor(0, 47);
  display.print(F("Hold a chord button"));
  display.setCursor(0, 56);
  display.print(F("Knob: key  Press: type"));
  display.display();
  screenDirty = false;
}

void readEncoder() {
  const int encoderClk = digitalRead(ENCODER_CLK_PIN);
  if (encoderClk != lastEncoderClk && encoderClk == LOW) {
    if (digitalRead(ENCODER_DT_PIN) != encoderClk) {
      keyOffset = (keyOffset + 1) % 12;
    } else {
      keyOffset = (keyOffset + 11) % 12;
    }
    stopAllNotes();
    screenDirty = true;
  }
  lastEncoderClk = encoderClk;

  const bool encoderButton = digitalRead(ENCODER_SW_PIN);
  if (encoderButton != lastEncoderButton) {
    lastEncoderButtonChange = millis();
    lastEncoderButton = encoderButton;
  }
  if (!encoderButton && (millis() - lastEncoderButtonChange) == DEBOUNCE_MS) {
    quality = (quality + 1) % 3;
    stopAllNotes();
    screenDirty = true;
  }
}

void readChordButtons() {
  const uint32_t now = millis();
  for (uint8_t i = 0; i < 7; ++i) {
    const bool sample = digitalRead(chordPins[i]) == LOW;
    if (sample != rawPressed[i]) {
      rawPressed[i] = sample;
      lastButtonChange[i] = now;
    }
    if (sample != stablePressed[i] && now - lastButtonChange[i] >= DEBOUNCE_MS) {
      stablePressed[i] = sample;
      sendChord(i, sample);
      screenDirty = true;
    }
  }
}

void setup() {
  for (uint8_t i = 0; i < 7; ++i) {
    pinMode(chordPins[i], INPUT_PULLUP);
  }
  pinMode(ENCODER_CLK_PIN, INPUT_PULLUP);
  pinMode(ENCODER_DT_PIN, INPUT_PULLUP);
  pinMode(ENCODER_SW_PIN, INPUT_PULLUP);
  lastEncoderClk = digitalRead(ENCODER_CLK_PIN);

  // The NPN MIDI circuit inverts the electrical signal, so invert UART TX too.
  MidiSerial.begin(31250, SERIAL_8N1, -1, MIDI_TX_PIN);
  uart_set_line_inverse(UART_NUM_2, UART_SIGNAL_TXD_INV);

  Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    while (true) {
      delay(1000);
    }
  }
  drawScreen();
}

void loop() {
  readEncoder();
  readChordButtons();
  if (screenDirty) {
    drawScreen();
  }
  delay(2);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik