mbot is a small music-to-light project built around an ESP32.
The idea is straightforward: the computer plays the music, keeps the clock, and decides which light lanes should be active. The ESP32 stays simple and acts as a four-output light renderer over USB serial.
This repo contains both halves of that setup:
- the ESP32 firmware that listens for light commands and flips GPIO outputs
- the Python harness that inspects MIDI, reduces it into four lanes, and streams those lane changes to the board
If you just want to get the board doing something quickly, use this path.
The default serial port is /dev/cu.usbserial-0001. If your board shows up
somewhere else, pass --port /path/to/device to the CLI commands below.
# install the CLI from the repo root
bash scripts/install.sh
# if `mbot` is not found in a fresh shell, add uv's tool bin directory
uv tool update-shell
# flash the firmware to the ESP32
mbot flash
# confirm the board is reachable
mbot board-brightness
# list the bundled pieces
mbot pieces
# play one
mbot run cavalleria_rusticana
# change brightness live if needed
mbot board-brightness 10
# stop playback
bash scripts/stop_playback.shThat is the shortest end-to-end path: install, flash, verify, play, adjust, stop.
The install script is just a thin wrapper around uv tool install --editable .
so the repo has one documented install path.
If you prefer a small menu instead of individual commands, use:
mbot interactive| Filename | Name | Composer |
|---|---|---|
cavalleria_rusticana_intermezzo.mid |
Cavalleria rusticana (Intermezzo) | Pietro Mascagni |
chopin_prelude_op28_no4_e_minor.mid |
Chopin Prelude in E minor | Frederic Chopin |
clair_de_lune.mid |
Clair de lune | Claude Debussy |
e_lucevan_le_stelle.mid |
E lucevan le stelle | Giacomo Puccini |
gymnopedie_no_1.mid |
Gymnopedie No. 1 | Erik Satie |
nessun_dorma.mid |
Nessun dorma | Giacomo Puccini |
o_mio_babbino_caro_violin.mid |
O mio babbino caro | Giacomo Puccini |
pavane_pour_une_infante_defunte.mid |
Pavane pour une infante defunte | Maurice Ravel |
vissi_darte.mid |
Vissi d'arte | Giacomo Puccini |
| Filename | Name | Composer |
|---|---|---|
dirty_work.mid |
Dirty Work | Walter Becker, Donald Fagen |
ventura_highway.mid |
Ventura Highway | Dewey Bunnell |
sailing.mid |
Sailing | Christopher Cross |
brandy_youre_a_fine_girl.mid |
Brandy (You're a Fine Girl) | Elliot Lurie |
escape_the_pina_colada_song.mid |
Escape (The Pina Colada Song) | Rupert Holmes |
summer_breeze.mid |
Summer Breeze | Jim Seals, Dash Crofts |
how_deep_is_your_love.mid |
How Deep Is Your Love | Barry Gibb, Robin Gibb, Maurice Gibb |
year_of_the_cat.mid |
Year of the Cat | Al Stewart, Peter Wood |
| Filename | Name | Composer |
|---|---|---|
aint_no_mountain_high_enough.mid |
Ain't No Mountain High Enough | Nickolas Ashford, Valerie Simpson |
stand_by_me.mid |
Stand By Me | Ben E. King, Jerry Leiber, Mike Stoller |
dock_of_the_bay.mid |
(Sittin' On) The Dock of the Bay | Otis Redding, Steve Cropper |
what_a_difference_a_day_makes.mid |
What a Difference a Day Makes | Maria Grever, Stanley Adams |
Use:
mbot pieces
mbot run <piece_slug>
mbot piece-play <piece_slug> --dry-runrun is the main happy-path command. It uses the preset's default player and
timing offsets, starts TiMidity++, and then starts the light stream.
This project does not try to synthesize a violin on the ESP32. MIDI is the source score, the computer handles playback, and the board reflects the music as light.
The two halves have different jobs.
The firmware is responsible for:
- configuring the ESP32 GPIO outputs
- accepting serial commands such as
PING,OFF, andMASK 5 - applying a 4-bit light mask to the board outputs
The harness is responsible for:
- loading Standard MIDI files without external Python MIDI packages
- summarizing tracks so you can see what is in a file
- choosing or preconfiguring the musical line to follow
- turning note activity into four light lanes
- streaming those lane changes to the board in time with playback
In other words: the computer conducts, the board reflects.
At runtime, the whole loop works like this:
- A command such as
midi-play,piece-play, orrunloads a MIDI file. - The host parser scans the file, collects note events, and summarizes each track.
- The command decides which track or tracks to follow.
- The selected note activity is converted from MIDI ticks into millisecond timestamps.
- The full pitch range in use is divided into four bands.
- For each timestamp, the host computes a 4-bit mask that says which lanes should be on.
- The host opens the ESP32 serial port and sends masks such as
M0,M3, orM15in time. - The ESP32 sets four GPIO outputs high or low and the attached LEDs reflect the current mask.
The board does not know anything about songs, tempo maps, tracks, or phrase structure. It only knows how to receive commands and set outputs.
firmware/esp32/light_renderer/ESP-IDF firmware for the four-lane serial renderermbot/host-side MIDI tools and board streaming codemidi/bundled MIDI assetspyproject.tomlpackaging for the host-side harness
The repo is currently tuned for the ELEGOO USB-C ESP-WROOM-32 board with a CP2102 USB-serial bridge.
The current firmware lane order is:
- lane 1 ->
GPIO26 - lane 2 ->
GPIO25 - lane 3 ->
GPIO33 - lane 4 ->
GPIO32
On the current breadboard setup, that is wired left-to-right as low-to-high note bands.
This is a board-specific choice, not a universal ESP32 rule. On the classic ESP32 family:
GPIO20does not existGPIO34throughGPIO39are input-onlyGPIO6throughGPIO11should be avoided because they are tied to flash
If the wiring changes, update the pin order in
firmware/esp32/light_renderer/main/main.c.
The firmware drives each lane with PWM rather than plain binary GPIO. The
default global brightness is set to 75%, which is a 25% reduction from full
output. That brightness can be changed at runtime over serial or from the host
CLI.
mbot midi-inspect path/to/file.midThis shows track names, note counts, channels, programs, and pitch ranges so you can decide what to follow.
mbot midi-play path/to/file.mid
mbot midi-play path/to/file.mid --track 2
mbot midi-play path/to/file.mid --dry-runUseful flags:
mbot midi-play path/to/file.mid --list-tracks
mbot midi-play path/to/file.mid --speed 1.25
mbot midi-revoice path/to/file.mid path/to/violin.mid --program violinIf --track is omitted, midi-play picks the densest note-bearing track.
The host-side commands are intentionally small and composable:
midi-inspectreads a MIDI file and prints track summaries without touching the boardmidi-playchooses one track, builds a four-lane light score, and streams itpiece-playdoes the same thing, but uses a named preset frommbot/pieces.pyrunuses a named preset and also starts local audio playback through TiMidity++midi-revoicerewrites MIDI program changes so playback uses a different General MIDI patchboard-brightnessqueries or sets the board-wide PWM brightness percentageflashruns the repo's ESP-IDF flash helper through the CLIinteractiveopens a small menu for piece playback, brightness changes, status, replay, and stop
The practical difference between piece-play and run is that piece-play
only handles the board stream, while run tries to coordinate the board stream
with local audio playback.
mbot flash
mbot flash fullclean flash
mbot flash monitormbot flash is a thin wrapper around scripts/flash_firmware.sh, which
loads the ESP-IDF environment and then runs idf.py inside
firmware/esp32/light_renderer.
If your ESP32 is on a different device path, use mbot flash --port /path/to/device ....
After flashing, the board reports its lane order with PING or INFO, for
example:
OK PINS 26 25 33 32 BRIGHTNESS 75
You usually do not need to send commands by hand, but the firmware understands:
PINGINFOOFFRESETMASK 5M5BRIGHTNESSBRIGHTNESS 60
PING and INFO return the board identity line. OFF and RESET clear all
lanes. MASK n and Mn set the active 4-bit lane mask directly, where bit 0
is lane 1 and bit 3 is lane 4. BRIGHTNESS reports the current global PWM
brightness percentage. BRIGHTNESS n sets it, where n must be between 0
and 100.
Query the current brightness:
mbot board-brightnessSet it explicitly:
mbot board-brightness 75
mbot board-brightness 60
mbot board-brightness 100This is a board-wide setting. It changes the duty cycle used when a lane is on, but it does not change the note-to-lane reduction logic. You can change it while a piece is already playing.
There are two clocks involved during a normal run:
- audio playback on the host computer
- light-mask streaming to the ESP32
The Python harness is the source of truth for timing. It starts the player, waits for the configured launch and light delays, then sends the light masks at the required millisecond offsets.
That means the quality of sync depends on:
- how quickly the local player starts
- whether the configured
launch_delayandlight_delaysuit that piece - whether the machine is busy enough to delay scheduling
For repeatable setups, keep the same player, port, and delay values for a given piece preset.
If a run is still active and you want to stop it quickly, use:
bash scripts/stop_playback.shBy default that helper targets /dev/cu.usbserial-0001. You can pass a
different serial port as the first argument.
The helper tries to:
- send
OFFto the board - stop active
mbotplayback commands - stop the associated
timidityprocess - send
OFFagain so the lanes are left dark
- Find or export a MIDI file.
- Run
mbot midi-inspect .... - Decide whether one track is enough or whether the piece wants a preset.
- Revoice the file if needed with
midi-revoice. - Dry-run the light score.
- Stream it to the board.
For quick experimentation, midi-play is enough. For repeatable setups, add a
piece preset in mbot/pieces.py.
There are two ways to bring in a new piece.
If you just want to try a file with the board, you do not need to edit the repo at all:
mbot midi-inspect path/to/file.mid
mbot midi-play path/to/file.midIf the auto-selected track is not the musical line you want, choose a track explicitly:
mbot midi-play path/to/file.mid --track NIf you want the piece to show up under pieces, piece-play, and run:
- Copy the MIDI file into
midi/. - Optionally revoice it with
midi-revoice. - Add a
PiecePresetentry inmbot/pieces.py.
Example:
"new_piece": PiecePreset(
slug="new_piece",
title="New Piece",
midi_path=REPO_ROOT / "midi" / "new_piece.mid",
preferred_tracks=(2,),
default_player="timidity",
launch_delay=0.0,
light_delay=0.1,
note="Short note about the arrangement and chosen track(s).",
),Then you can use:
mbot pieces
mbot piece-play new_piece --dry-run
mbot run new_pieceUsually yes, mechanically.
If the file is a Standard MIDI file with note events, midi-play will parse it
and turn the selected notes into four light lanes automatically.
What is not automatic is musical taste. The current reducer is still simple:
midi-playfollows one selected track at a time- bundled presets can follow more than one note-bearing track
- notes are reduced into four pitch bands
So a new file will often work immediately, but it may need:
- a better track choice
- a revoiced copy for nicer audio playback
- a preset if you want repeatable
runbehavior
- supported MIDI formats:
0and1 - SMPTE time division is not supported
- best results come from melody-forward arrangements or carefully chosen track presets
The repo uses Python's built-in unittest runner, so there is no extra test
dependency to install.
Run the regular suite with:
uv run python -m unittest discover -s tests -vRun the longer soak pass with:
uv run python -m unittest tests.soak -vYou can scale the soak loop count if you want a longer run:
MBOT_SOAK_ITERS=500 uv run python -m unittest tests.soak -vThe project is still intentionally narrow:
- four output lanes
- one board profile hard-coded in the firmware
- one global brightness value for all lanes rather than per-lane brightness
- pitch-band mapping rather than richer phrase or dynamics mapping
- preset-based multi-track support instead of a fully general arrangement layer