Intermediate3 hours12+7 parts needed

Parent info

Cost: ~$23
Time: 3 hours
Age: 12+
Difficulty: ●●●
Soldering: No soldering needed
What they'll learn: Microcontroller programming, Display programming

Parts you need

Affiliate links — we may earn a small commission

ESP32-S3 Dev Board
8x Tactile Push Buttons (6×6mm)
Rotary Encoder with Push Button
0.96" OLED Display (SSD1306 I2C)
3.5mm Audio Jack Breakout (TRS)
100Ω Resistor
Breadboard + Jumper Wires
🎮

Try this circuit in your browser!

Run the code, press the buttons and watch what happens — before you buy any parts. No account needed.

Open in Simulator →

You press a button. Your desk makes music.

Imagine this: eight buttons on a breadboard. You press one — a warm sine tone fills your headphones. You twist the knob — the sound changes from smooth to buzzy to bright. You tap a pattern into a 16-step sequencer and hit play. Your homemade gadget loops a beat you just invented.

That’s a synthesizer. The Teenage Engineering Pocket Operator sells for $89. You’re building the core of it for $20.

Wiring diagram for Pocket Synth: esp32 s3 devkitc 1 connected to C4, D4, E4, G4, A4


What you’ll need

Part What it does Price
ESP32-S3 Dev Board Brain. Runs the audio engine at 44,100 samples/sec. ~$12
8× Tactile buttons (6×6mm) One octave of notes — one button per note. ~$1
Rotary encoder with push Turn to change waveform. Push to start/stop sequencer. ~$1
0.96” OLED (SSD1306 I2C) Shows waveform shape, frequency, and sequencer pattern live. ~$2
3.5mm audio jack (TRS) Headphone or line output. ~$1
100Ω resistor Protects the audio pin from short circuits. cents
Breadboard + jumper wires Connects everything without soldering. ~$5

Total: ~$20 | Time: ~3 hours | Difficulty: ●●●○○


How it works (60 seconds)

Think of it like a digital musician trapped inside a chip.

The ESP32 turns numbers into a voltage on its audio pin — like a DAC (digital-to-analog converter). Your code writes a number to it 44,100 times every second. Those numbers follow a pattern (a waveform), and that pattern makes the speaker cone vibrate at a specific frequency. 440 vibrations per second = A4 = concert pitch.

Different patterns = different sounds. Smooth wave = flute. Square wave = old video game. Sharp ramp = bright brass. You’ll switch between five of them with a single knob turn.


Step 0: Understand the layout

Time: ~5 minutes

Before wiring anything, picture the final layout on your breadboard:

  • 8 buttons in a row across the middle — these are your notes
  • Rotary encoder at one end — waveform selector + sequencer toggle
  • OLED at the top — your display
  • Audio jack at the side — where headphones plug in

Each button connects: one leg to a GPIO pin, other leg to GND. The encoder has 5 pins: CLK, DT (rotation), SW (push), plus + and GND.

Before you start: The sound comes out of GPIO 46 (C6: GPIO 10). The ESP32-S3 and ESP32-C6 have no hardware DAC, so the code uses fast PWM instead (ledcWrite): it switches the pin on and off 25,000 times per second, and the 100Ω resistor plus your headphones smooth that into sound.


Step 1: Wire it up

Time: ~15 minutes

Buttons (8 wires):

  1. Button 0 → GPIO 4 (C6: GPIO 0) | other leg → GND
  2. Button 1 → GPIO 5 (C6: GPIO 19) | other leg → GND
  3. Button 2 → GPIO 6 (C6: GPIO 20) | other leg → GND
  4. Button 3 → GPIO 7 (C6: GPIO 21) | other leg → GND
  5. Button 4 → GPIO 15 (C6: GPIO 3) | other leg → GND
  6. Button 5 → GPIO 16 (C6: GPIO 4) | other leg → GND
  7. Button 6 → GPIO 17 (C6: GPIO 22) | other leg → GND
  8. Button 7 → GPIO 18 (C6: GPIO 23) | other leg → GND

Rotary Encoder (5 wires): 9. Encoder CLK → GPIO 2 (C6: GPIO 11) 10. Encoder DT → GPIO 3 (C6: GPIO 1) 11. Encoder SW → GPIO 0 (C6: GPIO 15) — on the S3 this is also the BOOT pin, so don’t hold the knob down while plugging in USB 12. Encoder + → 3.3V 13. Encoder GND → GND

OLED (4 wires): 14. OLED SDA → GPIO 8 (C6: GPIO 6) 15. OLED SCL → GPIO 9 (C6: GPIO 7) 16. OLED VCC → 3.3V 17. OLED GND → GND

Audio Jack (2 wires): 18. GPIO 46 (C6: GPIO 10) → 100Ω resistor → jack TIP 19. GND → jack SLEEVE

Check: Count connections. You should have 8 button wires + 5 encoder + 4 OLED + 2 audio = 19 connections total. Board is NOT plugged in yet.

Common mistake: Wiring the encoder GND to 3.3V or vice versa. CLK and DT need to go to GPIO pins, not power rails.


Step 2: Install libraries and flash

Time: ~10 minutes

  1. Open Arduino IDE. Go to File → Preferences → Additional Board Manager URLs and add:
    https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  2. Install esp32 by Espressif Systems from the Board Manager.
  3. In Library Manager, install: Adafruit SSD1306 and Adafruit GFX Library
  4. Select your board: ESP32S3 Dev Module (C6: ESP32C6 Dev Module)
  5. Copy the complete code below into a new sketch
  6. Plug in USB and click Upload

Check: After upload, your OLED should show “WAVE: SINE” at the top and “—” for frequency (no button pressed = silence).


Step 3: Upload the complete code

The big picture first. A synthesizer makes sound by turning numbers into voltages many thousands of times per second — the job of a DAC (Digital-to-Analog Converter). The ESP32-S3 and C6 have no DAC chip, so the code fakes one with fast PWM on the audio pin: each number becomes a tiny average voltage. The speaker follows those voltages and vibrates at exactly the frequency you want. The ESP32 has a hardware timer that fires 44,100 times every second — like an extremely fast metronome. Each tick it runs audioISR, which calculates the next audio sample and writes it to the audio pin. The shape of those numbers over time is the waveform: a smooth hill and valley = sine wave = flute sound. Sharp on/off = square wave = old video game sound. Steadily rising then instant drop = sawtooth = bright synthesizer. The audioPhase variable is the heartbeat: it counts from 0.0 to 1.0 over exactly one wave cycle, then wraps back to 0. How fast it counts determines the frequency — count faster, the pitch rises.

// ========== CHOOSE YOUR BOARD ==========
// Uncomment the line for YOUR board:
#define BOARD_S3    // ESP32-S3-DevKitC-1
//#define BOARD_C6  // ESP32-C6-DevKitC-1
// ========================================

#ifdef BOARD_S3
  #define PIN_BTN0             4
  #define PIN_BTN1             5
  #define PIN_BTN2             6
  #define PIN_BTN3             7
  #define PIN_BTN4             15
  #define PIN_BTN5             16
  #define PIN_BTN6             17
  #define PIN_BTN7             18
  #define PIN_ENC_CLK          2
  #define PIN_ENC_DT           3
  #define PIN_ENC_SW           0
  #define PIN_AUDIO            46
  #define PIN_SDA              8
  #define PIN_SCL              9
#endif
#ifdef BOARD_C6
  #define PIN_BTN0             0
  #define PIN_BTN1             19
  #define PIN_BTN2             20
  #define PIN_BTN3             21
  #define PIN_BTN4             3
  #define PIN_BTN5             4
  #define PIN_BTN6             22
  #define PIN_BTN7             23
  #define PIN_ENC_CLK          11
  #define PIN_ENC_DT           1
  #define PIN_ENC_SW           15
  #define PIN_AUDIO            10
  #define PIN_SDA              6
  #define PIN_SCL              7
#endif

#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <math.h>

const int BTN_PINS[8] = {PIN_BTN0, PIN_BTN1, PIN_BTN2, PIN_BTN3, PIN_BTN4, PIN_BTN5, PIN_BTN6, PIN_BTN7};
const int ENC_CLK = PIN_ENC_CLK;
const int ENC_DT  = PIN_ENC_DT;
const int ENC_SW  = PIN_ENC_SW;
const int AUDIO_PIN = PIN_AUDIO;

const float NOTE_FREQ[8] = {
  261.63f, 293.66f, 329.63f, 392.00f,
  440.00f, 523.25f, 587.33f, 659.25f
};

enum Waveform { SINE, SQUARE, SAW, TRIANGLE, NOISE };
const char* WAVE_NAMES[] = {"SINE", "SQR", "SAW", "TRI", "NSE"};
volatile Waveform currentWave = SINE;

bool seqPattern[16] = {false};
int  seqStep       = 0;
bool seqPlaying    = false;
unsigned long lastStepTime = 0;
int bpm = 120;

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

volatile float  audioFreq  = 0.0f;
volatile float  audioPhase = 0.0f;
float           waveBuffer[64];
int             waveBufIdx = 0;

volatile int encLastCLK;
void IRAM_ATTR encoderISR() {
  int clk = digitalRead(ENC_CLK);
  int dt  = digitalRead(ENC_DT);
  if (clk != encLastCLK) {
    if (dt != clk) {
      currentWave = (Waveform)((currentWave + 1) % 5);
    } else {
      currentWave = (Waveform)((currentWave + 4) % 5);
    }
    encLastCLK = clk;
  }
}

hw_timer_t* audioTimer = NULL;
void IRAM_ATTR audioISR() {
  if (audioFreq == 0.0f) {
    ledcWrite(AUDIO_PIN, 128);
    return;
  }

  float phaseInc = audioFreq / 44100.0f;
  audioPhase += phaseInc;
  if (audioPhase >= 1.0f) audioPhase -= 1.0f;

  float sample = 0.0f;
  switch (currentWave) {
    case SINE:
      sample = sinf(audioPhase * 2.0f * M_PI);
      break;
    case SQUARE:
      sample = (audioPhase < 0.5f) ? 1.0f : -1.0f;
      break;
    case SAW:
      sample = (audioPhase * 2.0f) - 1.0f;
      break;
    case TRIANGLE:
      sample = (audioPhase < 0.5f)
        ? (audioPhase * 4.0f - 1.0f)
        : (3.0f - audioPhase * 4.0f);
      break;
    case NOISE:
      sample = ((float)esp_random() / UINT32_MAX) * 2.0f - 1.0f;
      break;
  }

  int dacVal = (int)(sample * 100.0f) + 128;
  dacVal = constrain(dacVal, 0, 255);
  ledcWrite(AUDIO_PIN, dacVal);

  waveBuffer[waveBufIdx % 64] = sample;
  waveBufIdx++;
}

void setup() {
  for (int i = 0; i < 8; i++) {
    pinMode(BTN_PINS[i], INPUT_PULLUP);
  }
  pinMode(ENC_CLK, INPUT_PULLUP);
  pinMode(ENC_DT,  INPUT_PULLUP);
  pinMode(ENC_SW,  INPUT_PULLUP);
  encLastCLK = digitalRead(ENC_CLK);
  attachInterrupt(digitalPinToInterrupt(ENC_CLK), encoderISR, CHANGE);

  ledcAttach(AUDIO_PIN, 25000, 8);

  Wire.begin(PIN_SDA, PIN_SCL);
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.clearDisplay();
  display.setTextColor(WHITE);

  audioTimer = timerBegin(44100);
  timerAttachInterrupt(audioTimer, &audioISR);
  timerAlarm(audioTimer, 1, true, 0);
}

void loop() {
  audioFreq = 0.0f;
  for (int i = 0; i < 8; i++) {
    if (digitalRead(BTN_PINS[i]) == LOW) {
      audioFreq = NOTE_FREQ[i];
      break;
    }
  }

  static bool lastEncSW = HIGH;
  bool encSW = digitalRead(ENC_SW);
  if (encSW == LOW && lastEncSW == HIGH) {
    seqPlaying = !seqPlaying;
    seqStep = 0;
  }
  lastEncSW = encSW;

  if (seqPlaying) {
    unsigned long stepInterval = 60000UL / (bpm * 4);
    if (millis() - lastStepTime >= stepInterval) {
      lastStepTime = millis();
      if (seqPattern[seqStep]) {
        audioFreq = NOTE_FREQ[seqStep % 8];
      }
      seqStep = (seqStep + 1) % 16;
    }
  }

  static unsigned long lastDisplayUpdate = 0;
  if (millis() - lastDisplayUpdate > 50) {
    lastDisplayUpdate = millis();
    display.clearDisplay();

    display.setTextSize(1);
    display.setCursor(0, 0);
    display.print("WAVE: ");
    display.print(WAVE_NAMES[currentWave]);

    display.setCursor(70, 0);
    if (audioFreq > 0) {
      display.print((int)audioFreq);
      display.print("Hz");
    } else {
      display.print("---");
    }

    for (int x = 0; x < 64; x++) {
      int idx = (waveBufIdx - 64 + x + 128) % 64;
      int y = 32 - (int)(waveBuffer[idx] * 14.0f);
      y = constrain(y, 18, 46);
      display.drawPixel(x + 32, y, WHITE);
    }

    for (int i = 0; i < 16; i++) {
      int sx = (i % 8) * 8;
      int sy = (i < 8) ? 54 : 62;
      if (seqPattern[i]) {
        display.fillRect(sx, sy, 6, 6, WHITE);
      } else {
        display.drawRect(sx, sy, 6, 6, WHITE);
      }
      if (seqPlaying && i == seqStep) {
        display.drawRect(sx - 1, sy - 1, 8, 8, WHITE);
      }
    }

    display.display();
  }

  delay(1);
}

Line-by-line: what every line does and why

Note frequencies and the pentatonic scale

const float NOTE_FREQ[8] = {
  261.63f, 293.66f, 329.63f, 392.00f,
  440.00f, 523.25f, 587.33f, 659.25f
};

These are the exact frequencies (in Hz = vibrations per second) of musical notes. 261.63 Hz is middle C on any piano. 440.00 Hz is concert A — the note every orchestra tunes to. The suffix f means “this is a floating-point number” — a number with a decimal point. The notes chosen (C, D, E, G, A) form a pentatonic scale: the five notes that sound good in any combination. Pressing multiple buttons simultaneously always sounds musical — no clashing.


enum Waveform — naming the five sound shapes

enum Waveform { SINE, SQUARE, SAW, TRIANGLE, NOISE };
volatile Waveform currentWave = SINE;

enum creates a named list. Instead of remembering that waveform 0 = sine, 1 = square, etc., you use the names directly: SINE, SQUARE. The compiler stores them as numbers internally (SINE=0, SQUARE=1, SAW=2, TRIANGLE=3, NOISE=4), but you read code that says SINE instead of 0. volatile means “this can change at any moment from the encoder interrupt — always read fresh from memory.”


The hardware timer — the audio engine’s heartbeat

  audioTimer = timerBegin(44100);
  timerAttachInterrupt(audioTimer, &audioISR);
  timerAlarm(audioTimer, 1, true, 0);

timerBegin(44100) — create a hardware timer running at 44,100 Hz (44,100 fires per second) — CD-quality audio rate. timerAttachInterrupt links the timer to the audioISR function — every tick, run audioISR. timerAlarm(audioTimer, 1, true, 0) — fire the alarm every 1 tick, auto-reload (true), on timer channel 0.


audioISR — writing one audio sample 44,100 times per second

void IRAM_ATTR audioISR() {
  if (audioFreq == 0.0f) {
    ledcWrite(AUDIO_PIN, 128);
    return;
  }

IRAM_ATTR stores this function in fast RAM so it runs in nanoseconds — it is called 44,100 times per second and cannot afford to wait for slow flash memory. If no button is pressed (audioFreq == 0), write 128 to the audio pin. The PWM range is 0–255; 128 is the midpoint — a constant neutral level with no sound. return exits the function immediately without doing anything else.

  float phaseInc = audioFreq / 44100.0f;
  audioPhase += phaseInc;
  if (audioPhase >= 1.0f) audioPhase -= 1.0f;

This is the phase accumulator — the core of all digital audio synthesis. audioPhase counts from 0.0 to 1.0, representing one complete wave cycle. phaseInc is how much to advance per sample: at 440 Hz, 440 / 44100 = 0.00997 — advance by about 1% of the cycle each sample. After 100 samples that is one complete cycle — the speaker vibrates at 440 Hz. Higher frequency = larger increment = faster counting = higher pitch.

  switch (currentWave) {
    case SINE:
      sample = sinf(audioPhase * 2.0f * M_PI);
      break;
    case SQUARE:
      sample = (audioPhase < 0.5f) ? 1.0f : -1.0f;
      break;
    case SAW:
      sample = (audioPhase * 2.0f) - 1.0f;
      break;
    case TRIANGLE:
      sample = (audioPhase < 0.5f)
        ? (audioPhase * 4.0f - 1.0f)
        : (3.0f - audioPhase * 4.0f);
      break;
    case NOISE:
      sample = ((float)esp_random() / UINT32_MAX) * 2.0f - 1.0f;
      break;
  }

switch chooses one branch based on the current waveform. case SINE: sinf() calculates the sine of the phase — a smooth hill and valley. audioPhase * 2 * π converts 0.0–1.0 into 0–360 degrees in radians. case SQUARE: if we are in the first half of the cycle (phase < 0.5), output +1. Otherwise output -1. The sharp on/off transition is what gives it that buzzy video game sound. case SAW: ramps linearly from -1 to +1, then instantly resets. case NOISE: esp_random() generates a random number from the ESP32’s hardware random number generator. Dividing by UINT32_MAX normalizes it to 0.0–1.0, then scaling to -1.0–+1.0 makes it symmetric noise, good for percussion.

  int dacVal = (int)(sample * 100.0f) + 128;
  dacVal = constrain(dacVal, 0, 255);
  ledcWrite(AUDIO_PIN, dacVal);

sample is between -1.0 and +1.0. Multiplying by 100 gives -100 to +100. Adding 128 centers it at 128 — the midpoint. The result is 28 to 228, which fits comfortably in the 0–255 range. constrain is a safety net in case any math goes slightly outside bounds. ledcWrite sends the number to the PWM output, which produces a proportional voltage after filtering.


setup — starting the sequencer and encoder

  audioTimer = timerBegin(44100);

Create a timer running at 44,100 Hz. In ESP32 Arduino 3.x, you just pass the desired frequency directly — the library handles the prescaler math for you. This is like telling a metronome “tick 44,100 times per second” instead of calculating gear ratios yourself.


loop — the sequencer timing

    unsigned long stepInterval = 60000UL / (bpm * 4);

60000 is milliseconds per minute. bpm * 4 is how many 16th notes fit in one minute (4 per beat × bpm beats per minute). At 120 BPM: 60000 / (120 × 4) = 60000 / 480 = 125 milliseconds per step. This means each of the 16 sequencer steps lasts exactly 125ms — the whole pattern repeats every 2 seconds (125ms × 16 = 2000ms). UL means “unsigned long” — tells the compiler this is a large number, not a small integer.


loop — the OLED oscilloscope

    for (int x = 0; x < 64; x++) {
      int idx = (waveBufIdx - 64 + x + 128) % 64;
      int y = 32 - (int)(waveBuffer[idx] * 14.0f);
      y = constrain(y, 18, 46);
      display.drawPixel(x + 32, y, WHITE);
    }

waveBuffer is a circular buffer of the last 64 audio samples. waveBufIdx is the write position — it keeps incrementing. The index math (waveBufIdx - 64 + x + 128) % 64 reads them in the correct order without going negative. For each of the 64 samples, y = 32 - (waveBuffer[idx] * 14) converts the -1 to +1 sample value into a vertical pixel position (center at y=32, range of ±14 pixels). A sine wave draws a smooth curve; a square wave draws two flat horizontal lines with vertical jumps between them.


The whole thing in one sentence

A hardware timer fires 44,100 times per second, and each time it reads the current note frequency, advances a phase counter by a tiny amount, calculates what value the chosen waveform should have at that phase position, and writes it to the audio pin — turning pure math into real sound through your headphones.

First thing to try: Upload, plug in headphones, press and hold button 0 — you should hear a clear tone. While holding it, turn the encoder knob to hear the same note change from smooth (sine) to buzzy (square) to bright (saw) to soft (triangle) to static (noise).


Step 4: Play it!

Time: ~2 minutes to start, infinite from there

Plug in headphones. Hold any button — you should hear a tone immediately.

Try these:

Explore waveforms: Hold button 0 (C4) and slowly turn the encoder. Listen to how the tone changes: SINE → smooth flute, SQR → buzzy video game, SAW → bright brass, TRI → soft, NSE → pure noise (great for percussion!).

Build a beat with the sequencer: While stopped, press any button — that step toggles on/off. Try pressing buttons 0, 4, 8, 12 (every 4th step). Push the encoder to start — you’ll hear a four-on-the-floor beat pattern.

Play a melody: Hold multiple buttons in sequence while the sequencer plays — the real-time notes override the sequence. You’re live-performing.

Note: This synth is monophonic — only one note at a time. The first button pressed wins. That’s how most vintage synthesizers work too.


What just happened (what you learned)

You built a working digital audio synthesizer. Here’s the science behind it:

  • Phase Accumulator — how all digital synthesizers generate audio. Instead of a lookup table, you track a 0.0–1.0 number that increments each sample. Higher frequency = bigger increment. When it reaches 1.0, wrap back to 0.0. The shape of the function applied to that number is the waveform.

  • DAC (Digital-to-Analog Converter) — turns a number (0–255) into a voltage (0–3.3V). Here fast PWM does the DAC’s job. You write numbers 44,100 times per second. The speaker turns those voltage wiggles into sound — exactly like how movies are 24 still frames per second and look like motion.

  • Hardware Timer Interrupt — runs your audio code at exactly 44,100 times per second no matter what else is happening. It’s like a reliable alarm clock that wakes up the CPU, runs the audio function, then goes back to sleep. This guarantees timing regardless of OLED drawing or button scanning.

  • Waveform character — Sine = pure tone like a tuning fork. Square = buzzy, like old NES games (rich in odd harmonics). Sawtooth = bright, classic synthesizer sound (all harmonics). Triangle = soft, halfway between sine and square. Noise = all frequencies simultaneously, useful for percussion.

  • Pentatonic scale — removes the two “problem” notes from a major scale. What’s left sounds good in any combination. That’s why mashing these buttons always sounds musical.


Level Up

Add portamento (pitch glide). Right now frequency jumps instantly. Real synths glide smoothly between notes. Track a currentFreq variable and move it toward targetFreq by a small amount each audio sample: currentFreq += (targetFreq - currentFreq) * 0.001f. The 0.001 controls glide speed.

Change the scale. Replace NOTE_FREQ[] with a blues scale (C, Eb, F, F#, G, Bb) or a chromatic scale. Different scales feel completely different emotionally. Can you feel why?

Add a reversed sawtooth. Normal sawtooth ramps from -1 to +1 then drops. Reversed: start at +1, ramp to -1. Change SAW case to: sample = 1.0f - (audioPhase * 2.0f). Does it sound different from regular saw? Why or why not?

3D-print an enclosure. Slim rectangle: 120mm × 60mm × 15mm, matte black PLA. 8 buttons in a 2×4 grid, OLED top-left, encoder top-right. No visible screws on the top face.

★★ You completed: Pocket Synth!


Troubleshooting

Problem Fix
No sound in headphones Check GPIO 46 (C6: GPIO 10) → 100Ω → jack TIP. Check GND → jack SLEEVE. Try ledcWrite(AUDIO_PIN, 200) in setup to force a high PWM level — if nothing happens, the audio pin is wrong.
OLED stays black Check SDA/SCL wiring (GPIO 8/9; C6: GPIO 6/7). Check OLED VCC is 3.3V (not 5V). Try I2C address 0x3D if 0x3C doesn’t work.
Encoder doesn’t change waveform Check CLK on GPIO 2, DT on GPIO 3 (C6: CLK on GPIO 11, DT on GPIO 1). Try turning slowly — the interrupt fires on each detent.
All buttons sound the same note Check that each button goes to its own GPIO. Multiple buttons on the same pin = same note.
Clicking/buzzing between notes Normal when jumping between very different frequencies. Add portamento (see Level Up) to smooth transitions.
OLED shows garbage pixels Call display.clearDisplay() before drawing and display.display() after — both are required every frame.

Affiliate disclosure: Some links on this page are affiliate links. If you buy through them, we may earn a small commission at no extra cost to you.