MIDI Breadboard Instrument — Desk Rave & 16-Track Symphony
A standalone, dual-core ESP32 MIDI synthesizer, drum sequencer, and 30 FPS SPI OLED visualizer written in ESP-IDF v5.2.2 (C99 / FreeRTOS), powered by a custom Run-Length Encoding (RLE) Python MIDI-to-C compiler.
What started as making a passive piezo buzzer squeak evolved into a full 16-track embedded music box with live kick/hi-hat drum transients, hardware PCNT quadrature rotary controls for real-time tempo and volume scaling, and dual-core FreeRTOS task isolation.
YouTube Proof-of-Concept Demonstration
Upcoming YouTube video showcasing live playback across the 16-track flash playlist (Careless Whisper, Rondo Alla Turca, Seven Nation Army +2 octave transpose), real-time rotary speed/volume tweaking, and synchronized kick/hi-hat buzzers + LEDs.
midi_to_c.py) generating flash-resident SequenceStep struct arrays for the dual-core ESP32 breadboard synthesizer.
Breadboard Prototype Photos (Click to Zoom)
WHISP), rotary mode (SPD / VOL), kick/hi-hat drum boxes, and dynamic frequency bars.
Build, Flash & MIDI Conversion Commands
Clone the repository, convert any `.mid` file into an RLE-compressed C struct array using `scripts/midi_to_c.py`, and flash the 16-track firmware to an ESP32:
# 1. Clone the repository
git clone https://github.com/ertunc-ozbay/midi-breadboard-instrument.git
cd midi-breadboard-instrument
# 2. (Optional) Convert a custom .mid file into an RLE-compressed C struct array
pip install mido
python3 scripts/midi_to_c.py scripts/midillis/Alla_Turca/Alla_Turca.mid > my_track.h
# 3. Build and flash the 16-track firmware to ESP32
source ~/esp/esp-idf/export.sh
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
Python RLE MIDI-to-C Compiler (scripts/midi_to_c.py)
To prevent fragmented MIDI delta-times from strobing the LEDs/OLED at 30+ Hz and exhausting flash memory, midi_to_c.py merges contiguous identical states via Run-Length Encoding and routes Channel 10 + bass notes (`< MIDI 50`) to hardware percussion buzzers:
#!/usr/bin/env python3
import sys, mido
def convert_midi_to_c(midi_filename):
mid = mido.MidiFile(midi_filename)
safe_name = midi_filename.split('/')[-1].replace('.mid', '').replace('-', '_')
print(f"const SequenceStep midi_{safe_name}[] = {{")
active_melody_notes = set()
time_accum = 0.0
trigger_kick = trigger_hat = False
rle_freq, rle_dur, rle_kick, rle_hat = -1, 0, False, False
for msg in mid:
time_accum += msg.time
if msg.type in ['note_on', 'note_off'] and time_accum > 0.01:
duration_ms = int(time_accum * 1000)
freq = int(440.0 * (2.0 ** ((max(active_melody_notes) - 69) / 12.0))) if active_melody_notes else 0
# RLE Engine: coalesce contiguous identical frequency & drum states
if freq == rle_freq and trigger_kick == rle_kick and trigger_hat == rle_hat:
rle_dur += duration_ms
else:
if rle_dur > 0:
print(f" {{{rle_freq}, {rle_dur}, {str(rle_kick).lower()}, {str(rle_hat).lower()}}},")
rle_freq, rle_dur, rle_kick, rle_hat = freq, duration_ms, trigger_kick, trigger_hat
time_accum, trigger_kick, trigger_hat = 0.0, False, False
Dual-Core FreeRTOS Music & Visualizer Architecture (main/mmcontroller.c)
typedef struct {
uint16_t frequency; // Melody pitch in Hz (driven by 13-bit LEDC PWM on GPIO 25)
uint16_t duration; // Step duration in ms (scaled live by PCNT rotary speed)
bool kick_drum; // Triggers Active Buzzer 1 (GPIO 26) + Kick LED (GPIO 21)
bool hi_hat; // Triggers Active Buzzer 2 (GPIO 27) + Hat LED (GPIO 22)
} SequenceStep;
void app_main(void) {
printf("Desk Rave: 16-Track Symphony Edition Initializing...\n");
init_hardware();
// Core 0: 30 FPS SPI OLED Framebuffer Visualizer & 50ms Button Polling
xTaskCreatePinnedToCore(oled_visualizer_task, "oled_task", 4096, NULL, 4, NULL, 0);
xTaskCreatePinnedToCore(button_task, "btn_task", 4096, NULL, 5, NULL, 0);
// Core 1: Dedicated Real-Time Audio Synthesizer & PCNT Rotary Scaling
xTaskCreatePinnedToCore(buzzer_sequence_task, "music_task", 4096, NULL, 5, NULL, 1);
}
ESP32 Pinout & Architecture Specifications
| Component / Peripheral | ESP32 Pin / Interface | Function |
|---|---|---|
| Microcontroller | ESP32 (Dual-Core Xtensa @ 160 MHz) | ESP-IDF v5.2.2 + FreeRTOS dual-core execution |
| Rotary Encoder CLK / DT | GPIO 32 / GPIO 33 |
Hardware PCNT quadrature tracking (1000 ns glitch filter) |
| Rotary Encoder SW | GPIO 16 (Active Low) |
Toggles knob & OLED HUD between Speed (0.2x–3.0x) and Volume (0%–100%) |
| Pause / Next Buttons | GPIO 13 / GPIO 14 (Active Low) |
Play/Pause toggle and 16-track playlist cycling |
| Passive Buzzer (Melody) | GPIO 25 (LEDC 13-bit PWM) |
Main melody synthesizer driven by hardware LEDC Timer 0 |
| Active Buzzer 1 & 2 (Drums) | GPIO 26 (Kick) / GPIO 27 (Hi-Hat) |
Physical percussion transients on drum steps |
| Kick & Hi-Hat LEDs | GPIO 21 (Kick) / GPIO 22 (Hi-Hat) |
Visual beat indicators synchronized to drum hits |
| 1.3" 7-Pin SPI OLED | CS: 5, DC: 2, RES: 4, MOSI: 23, SCK: 18 |
10 MHz SPI2 display running custom 30 FPS visualizer on Core 0 |
The 16-Track Symphony
- Careless Whisper (Extended Club Edit)
- Take On Me (EDM Build-up Edition)
- Never Gonna Give You Up / Rickroll (Double Chorus)
- Can't Help Falling In Love (Wedding Mix)
- Gimme! Gimme! Gimme! (Midnight Rave)
- Für Elise (Beethoven Trance)
- Symphony No. 40 (Mozart Intense Strings)
- Super Mario Bros. (Main Theme)
- Pirates of the Caribbean 1 (He's a Pirate)
- Pirates of the Caribbean 2 (The Black Pearl)
- Rondo Alla Turca (Mozart)
- Tetris (Korobeiniki)
- Sonic the Hedgehog (Green Hill Zone)
- Castlevania (Vampire Killer)
- Seven Nation Army (Transposed +2 Octaves for piezo resonance)
- Mega Man 2 (Dr. Wily Stage)