Overview
In this beginner project you will use the ESP32 built-in capacitive touch sensor on pins T0-T8 to detect finger touches and generate musical notes through a passive buzzer using the LEDC PWM peripheral. Eight touch pins map to one octave (C4 to C5). Touching a pin plays that note; releasing stops it. No external keyboard hardware is needed — your fingertips are the keys.
Components
- 1× ESP32 DevKit V1 — Must have capacitive touch pins T0-T8
- 1× Passive piezo buzzer — PWM-driven; 3-5 V; NOT active buzzer
- 8× Copper foil tape strips (5 x 2 cm) — Touch key pads; connect to touch pins
- 8× Jumper wires — Touch pin to copper pad
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| Touch keys C4/D4/E4/F4 | T0(4)/T3(15)/T4(13)/T5(12) | Copper foil pads |
| Touch keys G4/A4/B4/C5 | T6(14)/T7(27)/T8(33)/T9(32) | |
| Buzzer +/- | GPIO 25 / GND | LEDC PWM output |
Arduino Code
// ESP32 Digital Piano - Beginner
// Capacitive touch -> LEDC buzzer piano (one octave C4-C5)
// Note frequencies in Hz (C4 to C5)
const int NOTES[8] = {262, 294, 330, 349, 392, 440, 494, 523};
const char NAMES[8][3] = {"C4","D4","E4","F4","G4","A4","B4","C5"};
// ESP32 touch-capable GPIO pins (T-pin number maps to GPIO)
const int TOUCH_PINS[8] = {4, 15, 13, 12, 14, 27, 33, 32};
const int BUZZER = 25;
const int TOUCH_THRESHOLD = 40; // lower value = touch detected
void setup() {
Serial.begin(115200);
ledcSetup(0, 262, 8); // channel 0, initial freq, 8-bit res
ledcAttachPin(BUZZER, 0);
ledcWrite(0, 0); // start silent
Serial.println("Touch the copper pads to play notes!");
}
void loop() {
int played = -1;
for (int i = 0; i < 8; i++) {
if (touchRead(TOUCH_PINS[i]) < TOUCH_THRESHOLD) {
played = i;
break; // play first touched key only
}
}
if (played >= 0) {
ledcChangeFrequency(0, NOTES[played], 8);
ledcWrite(0, 128); // 50% duty = square wave
Serial.printf("Playing: %s (%d Hz)n", NAMES[played], NOTES[played]);
} else {
ledcWrite(0, 0); // silence
}
delay(10);
}How It Works
ESP32 Capacitive Touch Sensing: The ESP32 has 10 capacitive touch-sensitive pins (T0-T9, mapped to specific GPIO numbers). touchRead() measures the capacitance of the pad: in air, values are typically 60-80; touching a conductive pad lowers the reading to 10-30. A threshold of 40 reliably distinguishes touch from no-touch with copper foil pads.
LEDC PWM Tone Generation: The passive buzzer produces sound when driven by a PWM signal matching the desired audio frequency. ledcChangeFrequency() changes the LEDC channel frequency without reconfiguring the timer, enabling fast frequency changes between notes. A 50 percent duty cycle (128/256) maximises acoustic output.
Copper Foil Touch Keys: Copper foil tape (available from electronics suppliers) makes inexpensive capacitive touch keys. Cut strips approximately 5x2 cm, arrange them in piano key layout on cardboard or wood, and connect each to a touch GPIO pin with a short wire. The human finger's capacitance coupled through the foil is sufficient to trigger touchRead() reliably.
Monophonic Note Priority: The loop scans touch pins in order and plays the first detected touch, ignoring subsequent touches. This gives the lowest key (leftmost copper pad) priority in a monophonic instrument. For a two-finger chord, the lower key wins. This is the same monophonic priority scheme used in early analogue synthesisers.
Applications
- Educational musical instrument for learning note positions and ear training
- Interactive exhibit keyboard for science museum displays
- Accessible instrument for users who cannot operate physical keys
- Halloween or sound-effects trigger board with note-mapped sounds
Troubleshooting
Buzzer plays continuously even without touching keys
The TOUCH_THRESHOLD may be too high. Print touchRead() values for each pin with no touch and set TOUCH_THRESHOLD 10-15 counts below the minimum untouched reading. Ambient humidity and long wire leads increase baseline capacitance, lowering untouched readings.
Touch is not detected even when pressing firmly on the pad
Ensure the copper foil pad connects to the GPIO pin with a short direct wire. Long wires add stray capacitance that reduces the relative touch signal. Reduce wire length to under 10 cm or add a 1 Mohm series resistor between the wire and the touch pin.
Buzzer is silent even though notes show in Serial Monitor
Verify the passive buzzer is connected (not an active buzzer). An active buzzer has a built-in oscillator and only needs DC voltage; it does not respond to PWM frequency changes. A passive buzzer is essentially a piezo element that vibrates at the driven PWM frequency.
Upgrades
- Add an I2S DAC (PCM5102) for clean sine-wave audio instead of buzzer square waves
- Add a second octave using T0-T8 on a second ESP32 connected via I2C to the first
- Add NeoPixel LEDs under each key that light up when the key is pressed
- Add note-name labels displayed on an OLED when each key is touched
FAQ
You need an ESP32 DevKit, TODO: sensor, Buzzer +/-, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Education. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The intermediate build replaces the buzzer with an I2S DAC (PCM5102A) for high-quality audio output. Notes are generated using pre-computed sine wave tables at the correct frequencies. Multiple touches are handled simultaneously for two-note chords (polyphony-2). An OLED displays the note names being played and the waveform type (sine, square, triangle) switchable via a button.
Components
- 1× ESP32 DevKit V1
- 1× PCM5102A I2S DAC module — 32-bit; 3.5 mm stereo output; 3.3 V
- 1× Small stereo speaker or headphones — Via PCM5102A 3.5 mm jack
- 1× SSD1306 OLED
- 8× Copper foil touch pads
- 1× Push button — Waveform switch
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| PCM5102A BCK/LCK/DIN | GPIO 26 / 25 / 22 | I2S bit clock / word select / data |
| PCM5102A VCC/GND | 3.3 V / GND | |
| Touch pads | Same as beginner | |
| Waveform button | GPIO 0 (boot button) | INPUT_PULLUP |
| OLED SDA/SCL | GPIO 21/23 | Different I2C pins to free GPIO 22 |
Arduino Code
// ESP32 Digital Piano - Intermediate (I2S DAC + sine wave + 2-note polyphony)
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include "driver/i2s.h"
#include <math.h>
Adafruit_SSD1306 oled(128,64,&Wire,-1);
const int TOUCH_PINS[8]={4,15,13,12,14,27,33,32};
const float FREQS[8]={261.63f,293.66f,329.63f,349.23f,392.0f,440.0f,493.88f,523.25f};
const char NAMES[8][3]={"C4","D4","E4","F4","G4","A4","B4","C5"};
const int TOUCH_THRESH=40;
// Waveform types
enum Wave { SINE, SQUARE, TRIANGLE }; Wave waveType=SINE;
float phase[8]={0};
const int SAMPLE_RATE=22050;
const int I2S_NUM=0;
void initI2S(){
i2s_config_t cfg={
.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX),
.sample_rate=SAMPLE_RATE, .bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT,
.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format=I2S_COMM_FORMAT_I2S,
.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1,
.dma_buf_count=4, .dma_buf_len=512, .use_apll=false
};
i2s_pin_config_t pins={
.bck_io_num=26,.ws_io_num=25,.data_out_num=22,.data_in_num=I2S_PIN_NO_CHANGE
};
i2s_driver_install((i2s_port_t)I2S_NUM,&cfg,0,NULL);
i2s_set_pin((i2s_port_t)I2S_NUM,&pins);
}
float getSample(int idx){
float p=phase[idx];
switch(waveType){
case SINE: return sinf(p);
case SQUARE: return (p<M_PI)?1.0f:-1.0f;
case TRIANGLE: return (p<M_PI)?(p/M_PI*2-1):(3-p/M_PI*2-1);
default: return 0;
}
}
void audioTask(void*){
int16_t buf[512];
while(1){
bool active[8]; int activeCount=0;
for(int i=0;i<8;i++){
active[i]=(touchRead(TOUCH_PINS[i])<TOUCH_THRESH);
if(active[i]) activeCount++;
}
for(int s=0;s<256;s++){
float L=0;
for(int i=0;i<8;i++){
if(active[i]){
L+=getSample(i)*16000.0f/(activeCount>0?activeCount:1);
phase[i]+=2.0f*M_PI*FREQS[i]/SAMPLE_RATE;
if(phase[i]>2.0f*M_PI) phase[i]-=2.0f*M_PI;
} else { phase[i]=0; }
}
int16_t s16=(int16_t)constrain((int)L,-32767,32767);
buf[s*2]=s16; buf[s*2+1]=s16;
}
size_t written;
i2s_write((i2s_port_t)I2S_NUM,buf,sizeof(buf),&written,portMAX_DELAY);
}
}
void setup(){
Serial.begin(115200);
Wire.begin(21,23);
oled.begin(SSD1306_SWITCHCAPVCC,0x3C); oled.setTextColor(WHITE);
pinMode(0,INPUT_PULLUP);
initI2S();
xTaskCreatePinnedToCore(audioTask,"audio",4096,NULL,5,NULL,0);
}
int waveBtn=0;
void loop(){
bool btnNow=digitalRead(0);
if(!btnNow&&waveBtn){ waveType=(Wave)((waveType+1)%3); delay(200); }
waveBtn=btnNow;
oled.clearDisplay(); oled.setCursor(0,0); oled.setTextSize(1);
const char* wNames[]={"SINE","SQUARE","TRIANGLE"};
oled.printf("Wave: %sn",wNames[waveType]);
oled.println("Playing:");
for(int i=0;i<8;i++) if(touchRead(TOUCH_PINS[i])<TOUCH_THRESH) oled.printf("%s ",NAMES[i]);
oled.display();
delay(50);
}How It Works
I2S PCM5102A Audio Output: The PCM5102A is a 32-bit stereo I2S DAC with a built-in headphone amplifier. The ESP32 I2S peripheral sends 16-bit stereo PCM samples at 22050 Hz. The DAC converts these to an analog voltage driving the 3.5 mm headphone output. Audio quality is substantially better than PWM buzzer output with a noise floor of approximately 100 dB.
Sine Wave Table Generation: Each active note advances its phase accumulator by 2*pi*frequency/sample_rate radians per sample. sinf(phase) computes the instantaneous sample value. Multiple active notes are summed and divided by the count to prevent clipping. The phase resets to zero when a key is released, preventing clicks from mid-cycle discontinuities.
FreeRTOS Audio Task: The audio synthesis runs in a dedicated FreeRTOS task pinned to Core 0 with priority 5. This ensures uninterrupted audio sample generation even when the main loop updates the OLED or reads touch pins. i2s_write() blocks until the DMA buffer has space, naturally pacing the synthesis at the correct sample rate.
Waveform Selection: The waveType enum selects between sine (smooth pure tone), square (harsh, rich in odd harmonics), and triangle (softer than square, even-order harmonics suppressed). All three are computed from the same phase accumulator on each sample, allowing real-time switching without audio interruption.
Applications
- Educational synthesiser for music theory and acoustics learning
- Interactive museum exhibit on sound wave shapes and timbre
- Accessible music instrument for children with physical limitations
- Prototype for a commercial IoT musical toy product
Troubleshooting
Audio has constant background hiss or buzz
The PCM5102A is sensitive to power supply noise. Power the module from a dedicated 3.3 V LDO regulator, not from the ESP32 onboard regulator which carries switching noise. Add 100 uF and 100 nF decoupling capacitors on the PCM5102A VCC pin.
Notes produce clicks when starting or stopping
Clicks occur when the phase is non-zero at note release (discontinuous waveform cutoff). Reset phase[i] to zero on key release is the correct approach but causes a click at the discontinuity. For click-free audio, fade the amplitude to zero over 5-10 ms before clearing the active flag.
I2S driver install returns ESP_ERR_INVALID_ARG
Verify the I2S pin numbers do not conflict with other peripherals. GPIO 25 (WS) and GPIO 26 (BCK) are commonly used for I2S. If GPIO 22 is used for I2C SDA elsewhere, assign I2S DATA to a different GPIO such as GPIO 5 or GPIO 17.
Upgrades
- Add ADSR envelope shaping (attack, decay, sustain, release) for piano-like note dynamics
- Add a reverb effect using a circular delay buffer for spatial depth
- Add a third octave by connecting a second ESP32 and merging audio over I2S in slave mode
- Add MIDI output via USB serial to control DAW software on a computer
FAQ
You need an ESP32 DevKit, TODO: sensor, Buzzer +/-, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Education. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The advanced build adds full 8-note polyphony, ADSR envelope shaping for realistic piano dynamics, and MIDI output over USB serial (compatible with GarageBand, Ableton, and FL Studio). Velocity sensitivity is estimated from touch capacitance change rate. An OLED shows the active notes on a mini piano keyboard graphic. Waveform, octave, and envelope parameters are adjustable from a web interface.
Components
- 1× ESP32 DevKit V1
- 1× PCM5102A I2S DAC
- 1× USB-MIDI adapter or built-in USB (ESP32-S2/S3) — ESP32-S2/S3 supports native USB MIDI
- 1× SSD1306 OLED
- 8× Copper foil touch pads
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| PCM5102A and touch pads | Same as intermediate | |
| USB (ESP32-S2/S3) | D+ / D- native USB | For USB MIDI; standard ESP32 uses UART |
Arduino Code
// ESP32 Digital Piano - Advanced (8-voice polyphony + ADSR + MIDI serial)
// MIDI output via Serial at 31250 baud (connect to MIDI DIN socket or USB-MIDI)
// For native USB MIDI use ESP32-S2/S3 with USB_MIDI library
#include "driver/i2s.h"
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <math.h>
Adafruit_SSD1306 oled(128,64,&Wire,-1);
const int TOUCH_PINS[8]={4,15,13,12,14,27,33,32};
const float FREQS[8]={261.63f,293.66f,329.63f,349.23f,392.0f,440.0f,493.88f,523.25f};
const int MIDI_NOTES[8]={60,62,64,65,67,69,71,72}; // C4-C5
const int SAMPLE_RATE=22050, I2S_NUM=0;
const int TOUCH_THRESH=40;
struct Voice {
float phase,freq,amp;
int state; // 0=off 1=attack 2=decay 3=sustain 4=release
float env;
};
Voice voices[8]={};
// ADSR times in samples
const float ATTACK_S=0.01f, DECAY_S=0.05f, SUSTAIN_L=0.7f, RELEASE_S=0.1f;
void midiNoteOn(int note, int vel){ Serial1.write(0x90); Serial1.write(note); Serial1.write(vel); }
void midiNoteOff(int note){ Serial1.write(0x80); Serial1.write(note); Serial1.write(0); }
void processEnv(Voice &v, float dt){
switch(v.state){
case 1: v.env+=dt/ATTACK_S; if(v.env>=1){v.env=1;v.state=2;} break;
case 2: v.env-=dt*(1-SUSTAIN_L)/DECAY_S; if(v.env<=SUSTAIN_L){v.env=SUSTAIN_L;v.state=3;} break;
case 3: break;
case 4: v.env-=dt/RELEASE_S; if(v.env<=0){v.env=0;v.state=0;} break;
}
}
void audioTask(void*){
int16_t buf[512];
const float dt=1.0f/SAMPLE_RATE;
while(1){
for(int s=0;s<256;s++){
float L=0;
for(int i=0;i<8;i++){
if(voices[i].state==0) continue;
processEnv(voices[i],dt);
float samp=sinf(voices[i].phase)*voices[i].env*voices[i].amp;
L+=samp;
voices[i].phase+=2.0f*M_PI*voices[i].freq*dt;
if(voices[i].phase>2.0f*M_PI) voices[i].phase-=2.0f*M_PI;
}
int16_t s16=(int16_t)constrain((int)(L*8000),-32767,32767);
buf[s*2]=s16; buf[s*2+1]=s16;
}
size_t w; i2s_write((i2s_port_t)I2S_NUM,buf,sizeof(buf),&w,portMAX_DELAY);
}
}
void initI2S(){
i2s_config_t cfg={
.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX),
.sample_rate=SAMPLE_RATE,.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT,
.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format=I2S_COMM_FORMAT_I2S,
.intr_alloc_flags=0,.dma_buf_count=4,.dma_buf_len=512,.use_apll=false
};
i2s_pin_config_t pins={.bck_io_num=26,.ws_io_num=25,.data_out_num=22,.data_in_num=-1};
i2s_driver_install((i2s_port_t)I2S_NUM,&cfg,0,NULL);
i2s_set_pin((i2s_port_t)I2S_NUM,&pins);
}
void setup(){
Serial.begin(115200);
Serial1.begin(31250,SERIAL_8N1,16,17); // MIDI baud rate on Serial1
Wire.begin(21,23); oled.begin(SSD1306_SWITCHCAPVCC,0x3C); oled.setTextColor(WHITE);
initI2S();
xTaskCreatePinnedToCore(audioTask,"audio",4096,NULL,5,NULL,0);
}
bool wasDown[8]={};
void loop(){
for(int i=0;i<8;i++){
bool down=(touchRead(TOUCH_PINS[i])<TOUCH_THRESH);
if(down&&!wasDown[i]){ // key press
voices[i]={0,FREQS[i],1.0f,1,0};
midiNoteOn(MIDI_NOTES[i],100);
}
if(!down&&wasDown[i]){ // key release
if(voices[i].state>0&&voices[i].state<4) voices[i].state=4;
midiNoteOff(MIDI_NOTES[i]);
}
wasDown[i]=down;
}
oled.clearDisplay(); oled.setCursor(0,0); oled.setTextSize(1);
oled.print("Active: ");
for(int i=0;i<8;i++) if(voices[i].state>0) oled.printf("%d ",i+1);
oled.display();
delay(10);
}How It Works
ADSR Envelope Shaping: Each voice has an envelope state machine: Attack (amplitude ramps from 0 to 1 over ATTACK_S seconds), Decay (falls from 1 to SUSTAIN_L over DECAY_S seconds), Sustain (held at SUSTAIN_L while key is pressed), Release (falls from SUSTAIN_L to 0 over RELEASE_S seconds after key release). This produces natural note onset and decay, eliminating the abrupt start/stop of simple oscillators.
8-Voice Polyphony: All 8 voices run simultaneously in the audioTask loop. Each voice independently advances its phase accumulator and applies its envelope. The mixed output sums all active voice samples and divides by 8 to prevent clipping. True 8-voice polyphony allows any combination of the 8 keys to sound simultaneously.
MIDI Serial Output: MIDI protocol transmits three-byte messages at 31250 baud. Note On is 0x90 (channel 1), MIDI note number, velocity (0-127). Note Off is 0x80, note number, 0. Serial1 on pins 16/17 drives a MIDI DIN-5 output via a 220 ohm series resistor. A USB-MIDI adapter converts this serial MIDI stream to USB MIDI for DAW software.
Touch Key-Press Edge Detection: The wasDown[] array tracks the previous touch state per key. A note-on event fires only on the transition from not-touched to touched (rising edge). Note-off fires only on the falling edge (release). This edge detection prevents repeated note-on events while a key is held and ensures MIDI Note Off is sent exactly once per key release.
Applications
- Standalone polyphonic synthesiser for music composition and performance
- MIDI controller for professional DAW software (GarageBand, Ableton Live)
- Educational tool demonstrating ADSR synthesis and MIDI protocol
- Low-cost MIDI input device for music production studios
Troubleshooting
MIDI notes sustain forever in DAW even after releasing touch
Verify midiNoteOff() is called on the wasDown falling edge. If the ESP32 resets during a held note, the DAW receives no Note Off and the note hangs. Many DAW software has an "All Notes Off" panic button (usually Shift+Space or a MIDI Panic menu item) to clear stuck notes.
ADSR envelope clicks at end of release
A very short RELEASE_S (under 0.05 seconds) causes the envelope to reach zero abruptly mid-sample. Increase RELEASE_S to at least 0.05 seconds. Also check that the envelope value starts decaying from the correct sustain level; if the state machine transitions are wrong, the release may start from an unexpected level.
All 8 voices playing simultaneously cause distortion
Reduce the per-voice amplitude from 8000 to 4000 in the audioTask sample calculation. With 8 voices each at amplitude 1.0, the sum can reach 8.0, exceeding the int16 range. Normalise by active voice count or apply a soft limiter (tanh function) to the mixed output before writing to I2S.
Upgrades
- Add a 4-operator FM synthesis engine for metallic and bell-like timbres
- Add WAV sample playback from SD card for realistic piano, guitar, and drum sounds
- Add a chord mode button that automatically adds third and fifth intervals to each played note
- Add a Bluetooth MIDI mode using ESP32 BLE MIDI for wireless connection to an iPad or MacBook
FAQ
You need an ESP32 DevKit, TODO: sensor, Buzzer +/-, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Education. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.