# Piano LED Visualizer — firmware Bare-metal firmware that lights a WS2812B strip above an 88-key keybed in response to MIDI. Two platforms, one shared implementation: | Platform | Board | USB MIDI | Strip | |---|---|---|---| | **Pico** | RP2040 / RP2350 | TinyUSB device | PIO | | **Circle** | Pi Zero / Zero 2 | `CUSBMIDIGadget` | SPI | All the visualizer logic lives in `src/` and is shared verbatim. Each platform supplies two small things: an `ILEDStrip` implementation, and a main loop that feeds MIDI packets to `CPianoLEDs::OnMIDIPacket()`. The design rationale, bill of materials, electrical notes and project phases live in [PIANO-LED-CIRCLE-PLAN.md](PIANO-LED-CIRCLE-PLAN.md). This file covers only how to build and run the firmware (plan Phase 1). ## What this is The board is a **USB MIDI device**. The PC is the host and owns everything else — the piano connection, the learning software, the song library. From the PC's side this firmware is just another ALSA MIDI output port: ``` Casio PX-S7000 --USB-B--> PC --USB--> board (this firmware) --> WS2812B strip ``` There is no network stack, no shell, and nothing writable at runtime. The board boots into this firmware in about a second and does one job. On the Circle build the piano **cannot** be plugged into the board directly: Circle has no OTG support, so its USB controller is gadget-only and all MIDI must arrive from the PC. RP2040/RP2350 can do USB host, so that restriction is a Circle property rather than an architectural one — but nothing here uses it. ## Layout | Path | | |---|---| | `src/config.h` | Every tunable. Start here. | | `src/pianoleds.cpp` | Note-to-LED mapping, colour, brightness clamps. Platform-independent. | | `src/ledstrip.h` | `ILEDStrip` — the entire hardware surface the logic depends on. | | `pico/` | RP2040 / RP2350 backend: TinyUSB MIDI + PIO WS2812. | | `firmware/` | Circle backend: `CUSBMIDIGadget` + `CWS28XXStripe`. | | `tests/` | Host-side tests. No toolchain or SDK needed. | | `circle/` | Circle as a submodule, pinned to `Step51`. | ## Building — Pico (RP2040 / RP2350) ```sh sudo apt-get install gcc-arm-none-eabi cmake git clone --recursive https://github.com/raspberrypi/pico-sdk # if you lack one PICO_SDK_PATH=/path/to/pico-sdk ./pico/build.sh ``` Produces `pico/build/pianoled.uf2`. Hold BOOTSEL while plugging the board in and copy the `.uf2` onto the drive that appears. `BOARD` selects the target (`pico`, `pico2`, `pico_w`, `pico2_w`; default `pico2`). All four run this firmware unchanged — the radio variants simply leave their radio unused. The strip data pin is `WS2812_PIN` in `src/config.h`, default GPIO2. PIO can drive it from any GPIO, so this is a free choice. ## Building — Circle (Raspberry Pi Zero) Circle is a submodule pinned to `Step51`, so clone recursively: ```sh git clone --recursive # or, in an existing clone: git submodule update --init ``` Then: ```sh sudo apt-get install gcc-arm-none-eabi # Debian/Ubuntu ./build.sh ``` The pin is deliberate. A pinned Circle tree still builds in five years; nothing here tracks a moving upstream. This produces two images in `boot/`, which coexist on one card: | Image | `RASPPI` | Model | |---|---|---| | `kernel.img` | 1 | Pi Zero / Zero W (ARM1176) | | `kernel7.img` | 2 | Pi Zero 2 / Zero 2 W (Cortex-A7) | The Pi picks the right one at boot, so the same SD card runs on either model. ## SD card (Circle only) The Pico boots from internal flash and needs none of this. FAT32, single partition. Copy in: - `boot/kernel.img` and `boot/kernel7.img` - From the [Raspberry Pi firmware repo](https://github.com/raspberrypi/firmware) `boot/` directory: `bootcode.bin`, `start.elf`, `fixup.dat` - `cmdline.txt` (optional). Circle reads kernel options from it; useful for raising the log level while bringing the board up. Nothing is ever written to the card at runtime, so pulling the power mid-note cannot corrupt it. ## Wiring **On Pico**, the WS2812B waveform comes from a PIO state machine, so the data line is any GPIO you like — `WS2812_PIN` in `src/config.h`, default GPIO2. Both hardware SPI blocks stay free. **On Circle**, verified against `circle/addon/WS28XX`: `CWS28XXStripe` clocks the waveform out over SPI at a fixed 6.4 MHz, encoding one LED bit per SPI byte. On SPI master device 0 that fixes the data line at **MOSI = GPIO10 (BCM) = physical pin 19**, and it occupies the only SPI master a Pi Zero exposes. Three things from plan section 7 that are not optional: - **Level shifter.** WS2812B wants logic high at 0.7 × VDD = 3.5V on a 5V rail; the Pi's GPIO is 3.3V. Put a **74AHCT125** on the data line. Skipping this is the single most common cause of "the strip flickers intermittently". - **Common ground.** The Pi's ground and the LED supply's ground must be tied. - **Power injection.** Feed 5V at both ends of the strip. On a Pi Zero, connect the PC to the **USB** port, not **PWR**, with a data cable. On a Pico, the single USB connector is the one. ## Configuring Everything is in `firmware/config.h`, and every value there is a Phase 0 question. Two are load-bearing: - **`STRIP_REVERSED`** — whether pixel 0 sits at the bass or treble end. Decide this *after* the strip is physically mounted; it is one flag to flip. - **`GLOBAL_BRIGHTNESS` and `MAX_LIT_KEYS`** — the power clamps. 176 LEDs at full white would draw ~10.5A against a 6A supply. Real playing never comes close, but these two make a whited-out strip *unreachable* rather than merely unlikely. Do not raise them without redoing the arithmetic in plan section 7. Any of them can also be overridden at build time without editing the file: ```sh make -C firmware EXTRADEFINE=-DSTRIP_REVERSED=1 # Circle cmake -B pico/build -S pico -DCMAKE_CXX_FLAGS=-DSTRIP_REVERSED=1 # Pico ``` ### Note-to-LED mapping `NOTE_MAP_GEOMETRIC` (default 1) derives each key's position from white-key geometry: 52 white keys span the strip, so a white key is `LED_COUNT / 52` pixels — **~3.38 at 176 LEDs, not 2** — with black keys on the boundaries. Setting it to 0 restores the plan's original `(note - 21) * 2`. That map is linear in semitone index, but a keybed is not: it drifts within each octave, worst at F, by up to ~0.87 LEDs (~6mm) even after an optimal offset and scale. Keep it only to reproduce the original behaviour. One consequence of the geometric map: adjacent key spans **overlap**, because the semitone pitch (~1.7 LEDs) is narrower than `LEDS_PER_KEY`. That is expected, and the renderer paints only lit keys so a neighbour cannot erase them. ## Calibration This is a headless appliance, so calibration runs over MIDI — the one channel that already exists. `tools/calibrate.sh` drives it from the PC: ```sh tools/calibrate.sh list # find the port tools/calibrate.sh ends # pixel 0 (red), last pixel (green) tools/calibrate.sh octaves # every C, middle C in red tools/calibrate.sh keys # every key: white green, black blue tools/calibrate.sh walk 37 # one pixel only tools/calibrate.sh sweep # walk every pixel in turn tools/calibrate.sh all # every pixel — voltage droop test tools/calibrate.sh off # back to normal ``` A pattern replaces the note display entirely while it is active; `off` restores it. ### Procedure Work in this order — each step depends on the one before. 1. **`ends`** — one pixel lights at each end of the strip. If red is at the treble end, set `STRIP_REVERSED 1` and rebuild. If either end is dark, the strip is not the length `LED_COUNT` assumes. 2. **`all`** — every pixel white. Watch the far end: if it drifts dim or warm, the strip needs 5V injected at that end too. This draws roughly `LED_COUNT × 3 × GLOBAL_BRIGHTNESS/255 × 20mA` — about 4A at the defaults, inside a 6A supply but well beyond normal play, which caps at `MAX_LIT_KEYS`. 3. **`keys`** — every key lit, whites and blacks in different colours. Check the colours line up with the actual keys across the whole span. This is the fastest way to see a mapping or length error. 4. **`octaves`** — every C, middle C in red. Drift shows up as the marks walking off the keys as you move up the keyboard. With the geometric map they should stay put. 5. **`sweep`** or **`walk `** — step one pixel at a time until you find the pixel sitting over A0. If that is not the pixel the firmware expects, the difference is your `LED_OFFSET`. Set it and rebuild. 6. **`chromatic`** — plays every key in turn. Watch for the lit span leading or lagging the key as it climbs. Then decide the product questions the firmware cannot: colours *through the diffuser* (not bare), brightness, and whether velocity should modulate anything. All of them live in `src/config.h`. ## MIDI behaviour - Notes 21–108 (A0–C8) map to the strip; anything outside is dropped. - Note On with velocity 0 is treated as Note Off. - CC 120 (All Sound Off) and CC 123 (All Notes Off) clear the strip. - Notes on `HINT_MIDI_CHANNEL` (default channel 16) light in a separate colour, for the plan's Phase 3 "light the next key to play". A key actually being played takes precedence over a hint on the same key. - Notes held when the USB host suspends are cleared, so nothing stays lit. - CC 20 selects a calibration pattern; CC 21/22 set the pixel for the walk pattern. See **Calibration** above. ## Tests ```sh ./tests/run.sh ``` Compiles the real `src/pianoleds.cpp` against a capture backend that records pixels in memory, and exercises the mapping, the note-off paths, the range clamping and both power clamps across nine configuration variants. Because the logic depends only on `ILEDStrip`, this needs no ARM toolchain, no Circle, and no pico-sdk — just `g++`. It checks the arithmetic, not the wiring, and does not replace bench-testing on real hardware.