The board arriving is a Waveshare RP2350-Plus (4MB, USB-C), which is now the default BOARD. Its pico-sdk header defines neither PICO_DEFAULT_LED_PIN nor PICO_DEFAULT_WS2812_PIN - it has no onboard indicator at all - so a bare board gives no sign of life. The status-LED support added for boards that do have one (zero, one, tiny, usb_a on GPIO16; eth on GPIO25) is kept and compiles out here. That makes feedback on the strip itself the useful path, and it turns out to be the better one anyway: - Boot self-test sweeps one pixel from index 0 to the far end once at startup. It answers in a single glance whether the firmware runs, PIO drives the line, the strip is the length LED_COUNT claims, the far end holds voltage, and - because you see which end it starts from - whether STRIP_REVERSED is right. It runs before USB, so the first test needs nothing but 5V. - Idle indicator holds one dim pixel lit while no host is connected, separating "powered and waiting" from "no power" and from "crashed". Both are platform-independent, so the Circle build gets them too. Verified: tests pass across seventeen configurations, now including the self-test and idle paths on and off. Both platforms build clean with no warnings from project sources. Note on the previous commit's verification: a filtered build log hid a real compile error in the Pico target (sleep_ms takes uint32_t, which is unsigned long here, and did not match the portable void(*)(unsigned) delay callback). The build script now gets an explicit success check rather than a grep. Claude-Session: https://claude.ai/code/session_01TVCB25LBsmeteWvaSMz4Ne
270 lines
11 KiB
Markdown
270 lines
11 KiB
Markdown
# 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; the default is `waveshare_rp2350_plus_4mb`
|
||
(Waveshare RP2350-Plus, 4MB, USB-C). pico-sdk 2.3.0 also ships definitions for
|
||
eleven other Waveshare RP2350 boards and the stock `pico`/`pico2`:
|
||
|
||
```sh
|
||
ls "$PICO_SDK_PATH"/src/boards/include/boards/ | grep rp2350
|
||
BOARD=waveshare_rp2350_zero ./pico/build.sh
|
||
```
|
||
|
||
Note the RP2350-Plus has **no onboard LED** — its board header defines neither
|
||
`PICO_DEFAULT_LED_PIN` nor `PICO_DEFAULT_WS2812_PIN`, so the status-LED code
|
||
compiles out. Boards that do have one (`zero`, `one`, `tiny`, `usb_a` on
|
||
GPIO16; `eth` on GPIO25) get it automatically. On boards without, the boot
|
||
self-test and idle indicator on the strip itself are the feedback.
|
||
|
||
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 <this repo>
|
||
# 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.
|
||
|
||
## First power-up
|
||
|
||
Two things run before any PC is involved, so a bare board on a bench still
|
||
tells you something:
|
||
|
||
- **Boot self-test** — one pixel sweeps from index 0 to the far end, once, then
|
||
clears. Watching it answers, in a single glance: the firmware runs, PIO
|
||
drives the data line, the strip is the length `LED_COUNT` claims, the far end
|
||
still has voltage, and — because you see which end it starts from — whether
|
||
`STRIP_REVERSED` is the right way round.
|
||
- **Idle indicator** — while no USB host is connected, one dim pixel stays lit
|
||
at the strip's start. This distinguishes "powered and waiting for the PC"
|
||
from "no power" and from "crashed".
|
||
|
||
Both are on by default (`BOOT_SELF_TEST`, `IDLE_INDICATOR` in `src/config.h`).
|
||
|
||
So the very first test needs nothing but 5V: power the strip and the board, and
|
||
watch for the sweep.
|
||
|
||
## 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 <n>`** — 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.
|