Chiptune player on an ESP32-C3
A four-channel chiptune on an ESP32-C3: a lead, a harmony, a bass and a noise drum on four piezos, with mute buttons. Four timers means four pitches at once. The whole build, an ESP32-C3 and 8 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Advanced About 30 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $15. On Mokxi they're free.
What you'll learn
- Sound
- Controllers
- Reading a button
- Using the Piezo speaker
- Difficulty: Advanced
- Time: About 30 minutes
- Board: ESP32-C3-DevKitM-1
- Parts: 10
How it works
A four-channel chiptune on an ESP32-C3: a lead, a harmony, a bass and a noise drum on four piezos, with mute buttons. Four timers means four pitches at once.
ESP32-C3-DevKitM-1 runs the sketch below. The circuit around it: Pushbutton, Piezo speaker. pinMode sets MUTE_PIN[c] as an input with its pull-up on. digitalRead reads whether MUTE_PIN[c] is HIGH or LOW. Serial.begin opens the serial port at 115200 baud. millis reads the time since power-up, so the sketch can time things without stopping.
Parts list
10 parts, plus the jumper wires. They retail for about $15; on Mokxi they're free.
- 1 × ESP32-C3-DevKitM-1In Handheld Console Kit, Desk Buddy Kit.
- 1 × Full-size breadboard
- 4 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 4 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| Piezo speaker (3) pin 1 | ESP32-C3-DevKitM-1 pin 2 |
| Piezo speaker (4) pin 1 | ESP32-C3-DevKitM-1 pin 3 |
| Pushbutton (1) pin 2a | ESP32-C3-DevKitM-1 pin GND |
| Pushbutton (2) pin 2a | ESP32-C3-DevKitM-1 pin GND |
| Pushbutton (3) pin 2a | ESP32-C3-DevKitM-1 pin GND |
| Pushbutton (4) pin 2a | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker (1) pin 2 | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker (2) pin 2 | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker (3) pin 2 | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker (4) pin 2 | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker (1) pin 1 | ESP32-C3-DevKitM-1 pin 0 |
| Piezo speaker (2) pin 1 | ESP32-C3-DevKitM-1 pin 1 |
| Pushbutton (1) pin 1a | ESP32-C3-DevKitM-1 pin 4 |
| Pushbutton (2) pin 1a | ESP32-C3-DevKitM-1 pin 5 |
| Pushbutton (3) pin 1a | ESP32-C3-DevKitM-1 pin 6 |
| Pushbutton (4) pin 1a | ESP32-C3-DevKitM-1 pin 7 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Chiptune player: four channels, four piezos, one small tune.
//
// Wiring (ESP32-C3):
// lead GPIO 0 -> piezo -> GND
// harmony GPIO 1 -> piezo -> GND
// bass GPIO 2 -> piezo -> GND
// noise GPIO 3 -> piezo -> GND (the drums)
// mutes GPIO 4, 5, 6, 7 to GND: each silences or brings back one channel
//
// Old game consoles made music with a few simple sound channels playing at once:
// two or three square waves for the tune and the chords, a bass, and a channel of
// noise for drums. Here each channel is a piezo with its own pin, and the ESP32-C3's
// LEDC peripheral keeps each one going on its own, so the sketch only has to say what
// each channel should play at each step and then get out of the way.
//
// There is a limit, and it is a real one. LEDC has four timers, and every channel
// that plays a different pitch needs a timer of its own to count at that pitch. So
// the chip can play four different pitches at once and no more. A fifth voice would
// have to share a timer, which means sharing a pitch. That is what "polyphony" means,
// how many notes can sound at once, and why a chip with a small number of voices
// needs a tune written for it.
//
// The tune is 32 steps of sixteenth notes at 140 BPM, written as MIDI note numbers
// (60 is middle C, 61 is the next key up, and so on; 0 is a rest). The sketch turns
// a note number into a frequency by looking up the 12 notes of one octave and doubling
// for each octave above it, the same way every note on a piano relates to the one an
// octave down.
//
// The drums are made from noise: a hi-hat is a very short burst of random high pitches,
// and a kick is a low pitch that falls in steps.
//
// Things to try. Mute the lead and listen to the bass and drums. Change the notes in
// LEAD. Change BPM.
const uint8_t PIEZO_PIN[4] = {0, 1, 2, 3};
const uint8_t MUTE_PIN[4] = {4, 5, 6, 7};
const char *const CHANNEL_NAME[4] = {"lead", "harmony", "bass", "noise"};
const uint16_t BPM = 140;
const uint8_t STEPS = 32;
const uint32_t STEP_MS = 60000UL / BPM / 4; // a sixteenth note
// MIDI notes: C5 = 72, Eb5 = 75, G5 = 79, Bb4 = 70, D5 = 74, F5 = 77, Ab4 = 68,
// G4 = 67, B4 = 71, C3 = 48, Bb2 = 46, Ab2 = 44, G2 = 43
const uint8_t LEAD[STEPS] = {72, 0, 75, 0, 79, 0, 75, 0, 70, 0, 74, 0, 77, 0, 74, 0,
68, 0, 72, 0, 75, 0, 72, 0, 67, 0, 71, 0, 74, 0, 79, 71};
const uint8_t HARMONY[STEPS] = {67, 0, 72, 0, 75, 0, 72, 0, 65, 0, 70, 0, 74, 0, 70, 0,
63, 0, 68, 0, 72, 0, 68, 0, 62, 0, 67, 0, 71, 0, 74, 67};
const uint8_t BASS[STEPS] = {48, 0, 48, 0, 48, 0, 48, 0, 46, 0, 46, 0, 46, 0, 46, 0,
44, 0, 44, 0, 44, 0, 44, 0, 43, 0, 43, 0, 43, 0, 43, 0};
// the drums: 1 is a kick, 2 is a hi-hat
const uint8_t DRUMS[STEPS] = {1, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 2, 0,
1, 0, 0, 0, 2, 0, 0, 0, 1, 0, 1, 0, 2, 0, 2, 2};
// the twelve notes C to B of octave 4, in tenths of a hertz
const uint16_t OCTAVE4[12] = {2616, 2772, 2937, 3111, 3296, 3492, 3700, 3920, 4153, 4400, 4662, 4939};
bool muted[4] = {false, false, false, false};
bool muteWas[4];
uint32_t muteAt[4];
uint8_t step = 0;
uint32_t nextStep = 0;
uint32_t gateOffAt = 0;
uint32_t drumOffAt = 0;
int8_t kickStage = -1;
uint32_t lastKick = 0;
static uint16_t midiToHz(uint8_t midi)
{
uint32_t tenths = OCTAVE4[midi % 12];
int octave = midi / 12 - 1;
while (octave > 4) {
tenths *= 2;
octave--;
}
while (octave < 4) {
tenths /= 2;
octave++;
}
return (uint16_t)((tenths + 5) / 10);
}
static void silence(uint8_t channel)
{
ledcWrite(PIEZO_PIN[channel], 0);
}
static void play(uint8_t channel, uint8_t midi)
{
if (muted[channel]) {
silence(channel);
return;
}
ledcWriteTone(PIEZO_PIN[channel], midiToHz(midi));
}
void setup()
{
Serial.begin(115200);
for (uint8_t c = 0; c < 4; c++) {
pinMode(MUTE_PIN[c], INPUT_PULLUP);
// each channel on its own timer: four different starting pitches
ledcAttach(PIEZO_PIN[c], 1000 + 100 * c, 8);
silence(c);
}
Serial.println("Chiptune ready: 4 channels, 32 steps at 140 BPM");
nextStep = millis() + 300;
}
void loop()
{
uint32_t now = millis();
// mute buttons
for (uint8_t c = 0; c < 4; c++) {
bool down = digitalRead(MUTE_PIN[c]) == LOW;
if (down != muteWas[c] && now - muteAt[c] > 20) {
muteWas[c] = down;
muteAt[c] = now;
if (down) {
muted[c] = !muted[c];
if (muted[c]) {
silence(c);
}
Serial.print(CHANNEL_NAME[c]);
Serial.println(muted[c] ? " muted" : " on");
}
}
}
if (now >= nextStep) {
nextStep += STEP_MS;
if (nextStep < now) {
nextStep = now + STEP_MS;
}
// melodic channels: each step is a new note, with a gap before the next
if (LEAD[step]) {
play(0, LEAD[step]);
}
if (HARMONY[step]) {
play(1, HARMONY[step]);
}
if (BASS[step]) {
play(2, BASS[step]);
}
gateOffAt = now + STEP_MS * 7 / 10;
// drums
if (DRUMS[step] == 1 && !muted[3]) {
ledcWriteTone(PIEZO_PIN[3], 160);
kickStage = 0;
lastKick = now;
drumOffAt = now + 90;
} else if (DRUMS[step] == 2 && !muted[3]) {
ledcWriteTone(PIEZO_PIN[3], 4000 + random(0, 3000));
kickStage = -1;
drumOffAt = now + 25;
}
Serial.print("Step ");
Serial.print(step + 1);
Serial.print(" lead ");
Serial.print(LEAD[step] && !muted[0] ? midiToHz(LEAD[step]) : 0);
Serial.print(" harmony ");
Serial.print(HARMONY[step] && !muted[1] ? midiToHz(HARMONY[step]) : 0);
Serial.print(" bass ");
Serial.print(BASS[step] && !muted[2] ? midiToHz(BASS[step]) : 0);
Serial.print(" drum ");
Serial.println(muted[3] ? 0 : DRUMS[step]);
step = (step + 1) % STEPS;
}
if (gateOffAt != 0 && now >= gateOffAt) {
gateOffAt = 0;
silence(0);
silence(1);
silence(2);
}
if (drumOffAt != 0) {
if (kickStage >= 0 && now - lastKick >= 30 && kickStage < 2) {
// the kick falls in three steps: 160 Hz, then 110 Hz, then 70 Hz. Each change
// of pitch makes a small glitch in the sound, so it changes only twice.
lastKick = now;
kickStage++;
ledcWriteTone(PIEZO_PIN[3], kickStage == 1 ? 110 : 70);
}
if (now >= drumOffAt) {
drumOffAt = 0;
silence(3);
}
}
}
Code walkthrough
The key functions of the sketch, and what each one does.
setup sketch.ino
Runs once when the board starts. It gets the pins and libraries ready.
void setup()
{
Serial.begin(115200);
for (uint8_t c = 0; c < 4; c++) {
pinMode(MUTE_PIN[c], INPUT_PULLUP);
// each channel on its own timer: four different starting pitches
ledcAttach(PIEZO_PIN[c], 1000 + 100 * c, 8);
silence(c);
}
Serial.println("Chiptune ready: 4 channels, 32 steps at 140 BPM");
nextStep = millis() + 300;
}loop sketch.ino
Runs over and over for as long as the board has power. This is where the project does its work.
void loop()
{
uint32_t now = millis();
// mute buttons
for (uint8_t c = 0; c < 4; c++) {
bool down = digitalRead(MUTE_PIN[c]) == LOW;
if (down != muteWas[c] && now - muteAt[c] > 20) {
muteWas[c] = down;
muteAt[c] = now;
if (down) {
muted[c] = !muted[c];
if (muted[c]) {
silence(c);
}
Serial.print(CHANNEL_NAME[c]);
Serial.println(muted[c] ? " muted" : " on");
}
}
}
if (now >= nextStep) {
nextStep += STEP_MS;
if (nextStep < now) {
nextStep = now + STEP_MS;
}
// melodic channels: each step is a new note, with a gap before the next
if (LEAD[step]) {
// ... 53 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The input reads random HIGH and LOW when the button is not pressed. Pushbutton
A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW. - The button seems always pressed, or never changes. Pushbutton
A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected. - A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead. - Sound is very quiet or the pin seems overloaded. Piezo speaker
Drive the piezo from a pin directly, or through a 100 ohm resistor. For a bigger buzzer, switch it with a transistor. Check that you wired the right two legs and joined grounds. - Upload fails or the board is not found by the computer. ESP32-C3-DevKitM-1
Pick the ESP32-C3 board in your tools and the right port. If it still fails, hold BOOT, tap RESET, then release BOOT to enter download mode and try again. - GPIO pins act strangely or the board resets. ESP32-C3-DevKitM-1
GPIO is 3.3 V only, so never feed it 5 V. GPIO 18 and 19 are USB, and GPIO 2, 8 and 9 are boot strapping pins. Avoid loading them at reset.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.