diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2abac5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Build output +boot/ +*.o +*.d +*.a +*.elf +*.lst +*.map +*.img + +# Circle build configuration, generated by ./build.sh +circle/Config.mk diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c0e1ee0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "circle"] + path = circle + url = https://github.com/rsta2/circle.git diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0270df --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# Piano LED Visualizer — Circle firmware + +Bare-metal firmware for a Raspberry Pi Zero that lights a WS2812B strip above +an 88-key keybed in response to MIDI. + +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 Pi is a **USB MIDI gadget**. 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--> Pi Zero (this firmware) --> WS2812B strip +``` + +There is no network stack, no filesystem, no shell. The Pi boots into this +firmware in about a second and does one job. + +Circle has no OTG support, so the USB controller is gadget-only here. The +piano **cannot** be plugged into the Pi directly; all MIDI arrives from the PC. + +## Layout + +| Path | | +|---|---| +| `firmware/config.h` | Every tunable. Start here. | +| `firmware/pianoleds.cpp` | Note-to-LED mapping, colour, brightness clamps. | +| `firmware/kernel.cpp` | USB gadget lifecycle and the main loop. | +| `tests/` | Host-side tests for the mapping and clamps. | +| `circle/` | Circle as a submodule, pinned to `Step51`. | +| `build.sh` | Builds both kernel images. | + +## Building + +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 + +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 + +Verified against `circle/addon/WS28XX`: `CWS28XXStripe` clocks the WS2812B +waveform out over SPI at a fixed 6.4 MHz, encoding one LED bit per SPI byte. +On SPI master device 0 that puts the data line on: + +**MOSI = GPIO10 (BCM) = physical pin 19.** + +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. + +Connect the PC to the Zero's **USB** port, not **PWR**, with a data cable. + +## 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 +``` + +## 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. + +## Tests + +```sh +./tests/run.sh +``` + +Compiles the real `firmware/pianoleds.cpp` against stubbed Circle headers and +exercises the mapping, the note-off paths, the range clamping and both power +clamps across nine configuration variants. This does not need the ARM +toolchain and does not replace bench-testing on real hardware — it checks the +arithmetic, not the wiring. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d7f93c2 --- /dev/null +++ b/build.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# +# Builds the Piano LED Visualizer firmware for the Raspberry Pi Zero family. +# +# Produces both kernel images, which coexist on one SD card: +# +# kernel.img RASPPI=1 Pi Zero / Zero W (ARM1176) +# kernel7.img RASPPI=2 Pi Zero 2 / Zero 2 W (Cortex-A7) +# +# The Pi picks the right one at boot, so the same card runs on either model. +# +set -e + +cd "$(dirname "$0")" +ROOT=$PWD +PREFIX=${PREFIX:-arm-none-eabi-} + +if ! command -v "${PREFIX}gcc" >/dev/null 2>&1; then + echo "error: ${PREFIX}gcc not found." >&2 + echo " Debian/Ubuntu: sudo apt-get install gcc-arm-none-eabi" >&2 + exit 1 +fi + +if [ ! -f circle/Rules.mk ]; then + echo "error: circle/ is empty. Run: git submodule update --init" >&2 + exit 1 +fi + +mkdir -p boot + +for SPEC in "1:kernel.img" "2:kernel7.img"; do + RASPPI=${SPEC%%:*} + IMAGE=${SPEC##*:} + + echo + echo "=============== RASPPI=$RASPPI -> $IMAGE ===============" + + cd "$ROOT/circle" + ./configure -r "$RASPPI" -p "$PREFIX" -f + ./makeall clean >/dev/null + ./makeall -j "$(nproc)" + + # The WS28XX driver is an addon and is not built by makeall. + make -C addon/WS28XX clean >/dev/null 2>&1 || true + make -C addon/WS28XX -j "$(nproc)" + + cd "$ROOT/firmware" + make clean >/dev/null 2>&1 || true + make -j "$(nproc)" + + cp "$ROOT/firmware/$IMAGE" "$ROOT/boot/$IMAGE" + echo "wrote boot/$IMAGE" +done + +cd "$ROOT/firmware" && make clean >/dev/null 2>&1 || true + +echo +echo "Built:" +ls -l "$ROOT/boot" +echo +echo "Copy boot/*.img plus the Raspberry Pi firmware files (bootcode.bin," +echo "start.elf, fixup.dat) and cmdline.txt onto a FAT32 SD card." +echo "See README.md for the full card layout." diff --git a/circle b/circle new file mode 160000 index 0000000..6177984 --- /dev/null +++ b/circle @@ -0,0 +1 @@ +Subproject commit 6177984e30fac5e65582d171d43f1563368a94ac diff --git a/firmware/Makefile b/firmware/Makefile new file mode 100644 index 0000000..14cbf41 --- /dev/null +++ b/firmware/Makefile @@ -0,0 +1,24 @@ +# +# Makefile +# + +CIRCLEHOME = ../circle + +OBJS = main.o kernel.o pianoleds.o + +LIBS = $(CIRCLEHOME)/addon/WS28XX/libws28xx.a \ + $(CIRCLEHOME)/lib/usb/gadget/libusbgadget.a \ + $(CIRCLEHOME)/lib/usb/libusb.a \ + $(CIRCLEHOME)/lib/input/libinput.a \ + $(CIRCLEHOME)/lib/fs/libfs.a \ + $(CIRCLEHOME)/lib/sched/libsched.a \ + $(CIRCLEHOME)/lib/libcircle.a + +# Build-time overrides for config.h, e.g. +# make EXTRADEFINE=-DSTRIP_REVERSED=1 +# Appended, so Circle's own defines (-DRASPPI=... etc.) survive. +DEFINE += $(EXTRADEFINE) + +include $(CIRCLEHOME)/sample/Rules.mk + +-include $(DEPS) diff --git a/firmware/config.h b/firmware/config.h new file mode 100644 index 0000000..3aeaedb --- /dev/null +++ b/firmware/config.h @@ -0,0 +1,146 @@ +// +// config.h +// +// Piano LED Visualizer on Circle - all tunable parameters. +// +// Every value in this file is a product decision that Phase 0 of +// PIANO-LED-CIRCLE-PLAN.md exists to answer. Bench-test on Raspberry Pi OS +// first, then transcribe the answers here and build once. +// +#ifndef _config_h +#define _config_h + +// -------------------------------------------------------------------------- +// Keybed and strip geometry (plan section 6) +// -------------------------------------------------------------------------- + +// An 88-key keybed spans MIDI notes 21 (A0) through 108 (C8). +#define MIDI_NOTE_MIN 21 +#define MIDI_NOTE_MAX 108 + +#define KEY_COUNT (MIDI_NOTE_MAX - MIDI_NOTE_MIN + 1) // 88 + +// LEDs per key. At 144 LEDs/m, 2 per key spans 1.222m, which lines up with a +// standard 88-key keybed almost exactly. +#ifndef LEDS_PER_KEY +#define LEDS_PER_KEY 2 +#endif + +#define LED_COUNT (KEY_COUNT * LEDS_PER_KEY) // 176 + +// Strip orientation. Pixel 0 of a WS2812B strip is at the end the data line +// enters. Decide this AFTER the strip is physically mounted, then flip this +// one flag. +// +// 0 = pixel 0 is at the bass end -> led = (note - 21) * 2 +// 1 = pixel 0 is at the treble end -> led = (108 - note) * 2 +#ifndef STRIP_REVERSED +#define STRIP_REVERSED 0 +#endif + +// -------------------------------------------------------------------------- +// Power safety (plan section 7) - NOT optional +// -------------------------------------------------------------------------- +// +// 176 LEDs at full white draw ~60mA each = 10.56A theoretical maximum, against +// a 6A supply. Real playing never approaches that (a ten-finger chord lights 20 +// LEDs, ~1.2A), but a firmware bug that whites out the strip would brown out +// the rail. These two clamps make that unreachable rather than unlikely. + +// Global brightness ceiling, applied to every channel of every pixel. +// 0-255. At 96 a full-strip white would draw roughly 4A, still inside 6A. +#ifndef GLOBAL_BRIGHTNESS +#define GLOBAL_BRIGHTNESS 96 +#endif + +// Hard cap on simultaneously lit keys. Beyond this, further held notes are +// tracked but not lit, so current draw stays bounded no matter what arrives +// on the wire. 20 keys is a ten-finger chord; 30 leaves room for pedal-held +// passages without ever approaching the supply limit. +#ifndef MAX_LIT_KEYS +#define MAX_LIT_KEYS 30 +#endif + +// -------------------------------------------------------------------------- +// Colour (Phase 0 decides these against the actual diffuser) +// -------------------------------------------------------------------------- +// +// Colours look substantially different through a diffuser than on bare strip. +// Do not finalise these from a photo. + +// Colour for a played key, before brightness scaling. +#ifndef NOTE_COLOR_R +#define NOTE_COLOR_R 0 +#endif +#ifndef NOTE_COLOR_G +#define NOTE_COLOR_G 140 +#endif +#ifndef NOTE_COLOR_B +#define NOTE_COLOR_B 255 +#endif + +// Distinct colour for a "next note to play" hint driven by learning software +// on the PC (plan Phase 3). Reached over MIDI channel HINT_MIDI_CHANNEL. +#ifndef HINT_COLOR_R +#define HINT_COLOR_R 255 +#endif +#ifndef HINT_COLOR_G +#define HINT_COLOR_G 80 +#endif +#ifndef HINT_COLOR_B +#define HINT_COLOR_B 0 +#endif + +// -------------------------------------------------------------------------- +// Velocity response +// -------------------------------------------------------------------------- + +// 1 = velocity scales pixel brightness, 0 = every key lights at full +// GLOBAL_BRIGHTNESS regardless of how hard it was struck. +#ifndef VELOCITY_SENSITIVE +#define VELOCITY_SENSITIVE 1 +#endif + +// Floor for velocity scaling, as a percentage. A pianissimo note should still +// be clearly visible, so velocity maps onto [VELOCITY_FLOOR_PCT, 100] rather +// than onto [0, 100]. +#ifndef VELOCITY_FLOOR_PCT +#define VELOCITY_FLOOR_PCT 35 +#endif + +// -------------------------------------------------------------------------- +// MIDI routing +// -------------------------------------------------------------------------- + +// Channel carrying notes actually played on the piano. 0-15 on the wire +// (channel 1 in a DAW), or MIDI_CHANNEL_ANY to accept every channel. +#define MIDI_CHANNEL_ANY 0xFF +#ifndef NOTE_MIDI_CHANNEL +#define NOTE_MIDI_CHANNEL MIDI_CHANNEL_ANY +#endif + +// Channel reserved for Phase 3 "light the next key" hints from the PC. Kept +// separate from played notes so the two never overwrite each other. Set to +// MIDI_CHANNEL_NONE to ignore hints entirely. +#define MIDI_CHANNEL_NONE 0xFE +#ifndef HINT_MIDI_CHANNEL +#define HINT_MIDI_CHANNEL 15 // channel 16 in a DAW +#endif + +// -------------------------------------------------------------------------- +// Hardware wiring (VERIFIED against circle/addon/WS28XX, do not guess) +// -------------------------------------------------------------------------- +// +// CWS28XXStripe clocks the WS2812B waveform out over SPI at a fixed 6.4MHz, +// encoding each LED bit as one SPI byte. On SPI master device 0 that puts the +// data line on: +// +// MOSI = GPIO10 (BCM) = physical pin 19 +// +// Feed that through a 74AHCT125 to get a 5V logic level at the strip, and tie +// the Pi's ground to the LED supply ground. See plan section 7. +#ifndef SPI_MASTER_DEVICE +#define SPI_MASTER_DEVICE 0 +#endif + +#endif diff --git a/firmware/kernel.cpp b/firmware/kernel.cpp new file mode 100644 index 0000000..88fea81 --- /dev/null +++ b/firmware/kernel.cpp @@ -0,0 +1,135 @@ +// +// kernel.cpp +// +// Piano LED Visualizer for Circle. +// +// The Pi is a USB MIDI *gadget*: the PC is the host, and this firmware appears +// on the PC as an ordinary ALSA MIDI output port. Circle has no OTG support, so +// the USB controller is gadget-only in this build and the piano can never be +// plugged in here directly - all MIDI arrives from the PC. See section 2 of +// PIANO-LED-CIRCLE-PLAN.md. +// +#include "kernel.h" +#include +#include +#include + +static const char FromKernel[] = "kernel"; + +CKernel::CKernel (void) +: m_Timer (&m_Interrupt), + m_Logger (m_Options.GetLogLevel (), &m_Timer), + m_pUSB (new CUSBMIDIGadget (&m_Interrupt)), + m_pMIDIDevice (0) +{ + m_ActLED.Blink (5); // show we are alive +} + +CKernel::~CKernel (void) +{ +} + +boolean CKernel::Initialize (void) +{ + boolean bOK = TRUE; + + if (bOK) + { + bOK = m_Serial.Initialize (115200); + } + + if (bOK) + { + // Headless appliance: there is no screen, so the log goes to the + // serial port and nowhere else. + bOK = m_Logger.Initialize (&m_Serial); + } + + if (bOK) + { + bOK = m_Interrupt.Initialize (); + } + + if (bOK) + { + bOK = m_Timer.Initialize (); + } + + if (bOK) + { + // Bring the strip up before USB, so the LEDs are known-dark by the + // time the host can start sending us notes. + bOK = m_PianoLEDs.Initialize (); + } + + if (bOK) + { + assert (m_pUSB != 0); + bOK = m_pUSB->Initialize (); + } + + return bOK; +} + +void CKernel::UpdateMIDIDevice (void) +{ + assert (m_pUSB != 0); + + if (!m_pUSB->UpdatePlugAndPlay ()) + { + return; + } + + // The gadget deletes its CUSBMIDIDevice when the host suspends the bus + // and builds a new one on the next enumeration, so the pointer we hold + // is only valid until the next status change. Re-fetch it every time. + CUSBMIDIDevice *pMIDIDevice = + (CUSBMIDIDevice *) m_DeviceNameService.GetDevice ("umidi1", FALSE); + + if (pMIDIDevice == m_pMIDIDevice) + { + return; + } + + m_pMIDIDevice = pMIDIDevice; + + if (m_pMIDIDevice != 0) + { + m_PianoLEDs.AttachMIDIDevice (m_pMIDIDevice); + + m_Logger.Write (FromKernel, LogNotice, "USB MIDI gadget connected"); + } + else + { + // Host went away mid-chord. Do not leave keys lit. + m_PianoLEDs.AllOff (); + + m_Logger.Write (FromKernel, LogNotice, "USB MIDI gadget disconnected"); + } +} + +TShutdownMode CKernel::Run (void) +{ + m_Logger.Write (FromKernel, LogNotice, "Compile time: " __DATE__ " " __TIME__); + m_Logger.Write (FromKernel, LogNotice, + "%u LEDs, %u keys, notes %u-%u, %s orientation", + (unsigned) LED_COUNT, (unsigned) KEY_COUNT, + (unsigned) MIDI_NOTE_MIN, (unsigned) MIDI_NOTE_MAX, + STRIP_REVERSED ? "reversed" : "normal"); + m_Logger.Write (FromKernel, LogNotice, + "Brightness ceiling %u/255, at most %u keys lit at once", + (unsigned) GLOBAL_BRIGHTNESS, (unsigned) MAX_LIT_KEYS); + m_Logger.Write (FromKernel, LogNotice, "Waiting for USB host"); + + for (;;) + { + UpdateMIDIDevice (); + + // Rendering blocks for ~5.3ms of SPI traffic, which is why it runs + // here and not in the MIDI packet handler's IRQ context. Update() + // returns immediately when nothing has changed. + m_PianoLEDs.Update (); + } + + return ShutdownHalt; +} diff --git a/firmware/kernel.h b/firmware/kernel.h new file mode 100644 index 0000000..6a4bef4 --- /dev/null +++ b/firmware/kernel.h @@ -0,0 +1,59 @@ +// +// kernel.h +// +#ifndef _kernel_h +#define _kernel_h + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "pianoleds.h" + +enum TShutdownMode +{ + ShutdownNone, + ShutdownHalt, + ShutdownReboot +}; + +class CKernel +{ +public: + CKernel (void); + ~CKernel (void); + + boolean Initialize (void); + + TShutdownMode Run (void); + +private: + // Pick up the MIDI device after the host has enumerated us, and again + // after every re-enumeration. + void UpdateMIDIDevice (void); + +private: + // do not change this order + CActLED m_ActLED; + CKernelOptions m_Options; + CDeviceNameService m_DeviceNameService; + CSerialDevice m_Serial; + CExceptionHandler m_ExceptionHandler; + CInterruptSystem m_Interrupt; + CTimer m_Timer; + CLogger m_Logger; + + CUSBController *m_pUSB; + CUSBMIDIDevice *m_pMIDIDevice; + + CPianoLEDs m_PianoLEDs; +}; + +#endif diff --git a/firmware/main.cpp b/firmware/main.cpp new file mode 100644 index 0000000..6709d04 --- /dev/null +++ b/firmware/main.cpp @@ -0,0 +1,31 @@ +// +// main.cpp +// +#include "kernel.h" +#include + +int main (void) +{ + // cannot return here because some destructors used in CKernel are not implemented + + CKernel Kernel; + if (!Kernel.Initialize ()) + { + halt (); + return EXIT_HALT; + } + + TShutdownMode ShutdownMode = Kernel.Run (); + + switch (ShutdownMode) + { + case ShutdownReboot: + reboot (); + return EXIT_REBOOT; + + case ShutdownHalt: + default: + halt (); + return EXIT_HALT; + } +} diff --git a/firmware/pianoleds.cpp b/firmware/pianoleds.cpp new file mode 100644 index 0000000..263231b --- /dev/null +++ b/firmware/pianoleds.cpp @@ -0,0 +1,230 @@ +// +// pianoleds.cpp +// +#include "pianoleds.h" +#include +#include + +// MIDI status nibbles +#define MIDI_NOTE_OFF 0x80 +#define MIDI_NOTE_ON 0x90 +#define MIDI_CONTROL_CHANGE 0xB0 + +// Control numbers that mean "stop everything" +#define MIDI_CC_ALL_SOUND_OFF 120 +#define MIDI_CC_ALL_NOTES_OFF 123 + +CPianoLEDs::CPianoLEDs (void) +: m_Stripe (WS2812B, LED_COUNT, 4000000, SPI_MASTER_DEVICE), + m_bDirty (TRUE) +{ + memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity); + memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity); +} + +CPianoLEDs::~CPianoLEDs (void) +{ +} + +boolean CPianoLEDs::Initialize (void) +{ + if (!m_Stripe.Initialize ()) + { + return FALSE; + } + + // Start from a known-dark strip rather than whatever the pixels held + // when power came up. + return m_Stripe.Blackout (); +} + +void CPianoLEDs::AttachMIDIDevice (CUSBMIDIDevice *pMIDIDevice) +{ + assert (pMIDIDevice != 0); + + // The gadget destroys and recreates its CUSBMIDIDevice across a suspend, + // so any notes held at that moment would otherwise stay lit forever. + AllOff (); + + pMIDIDevice->RegisterPacketHandler (MIDIPacketHandler, this); +} + +void CPianoLEDs::MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength, + unsigned nDevice, void *pParam) +{ + CPianoLEDs *pThis = static_cast (pParam); + assert (pThis != 0); + + pThis->OnMIDIPacket (pPacket, nLength); +} + +void CPianoLEDs::OnMIDIPacket (const u8 *pPacket, unsigned nLength) +{ + // Circle hands us one already-framed MIDI message of 1-3 bytes. Anything + // shorter than a channel message cannot be a note event. + if (nLength < 3) + { + return; + } + + u8 ucStatus = pPacket[0] & 0xF0; + u8 ucChannel = pPacket[0] & 0x0F; + + switch (ucStatus) + { + case MIDI_NOTE_ON: + // Note On with velocity 0 is the conventional Note Off. + SetKey (pPacket[1], pPacket[2], ChannelMatches (ucChannel, HINT_MIDI_CHANNEL)); + break; + + case MIDI_NOTE_OFF: + SetKey (pPacket[1], 0, ChannelMatches (ucChannel, HINT_MIDI_CHANNEL)); + break; + + case MIDI_CONTROL_CHANGE: + if ( pPacket[1] == MIDI_CC_ALL_SOUND_OFF + || pPacket[1] == MIDI_CC_ALL_NOTES_OFF) + { + AllOff (); + } + break; + + default: + break; + } +} + +void CPianoLEDs::SetKey (u8 ucNote, u8 ucVelocity, boolean bHint) +{ + // Drop anything off the ends of the keybed rather than trusting the + // input; an out-of-range note would index past the strip. + if ( ucNote < MIDI_NOTE_MIN + || ucNote > MIDI_NOTE_MAX) + { + return; + } + + unsigned nKey = ucNote - MIDI_NOTE_MIN; + + if (bHint) + { + m_HintVelocity[nKey] = ucVelocity; + } + else + { + m_KeyVelocity[nKey] = ucVelocity; + } + + m_bDirty = TRUE; +} + +void CPianoLEDs::AllOff (void) +{ + memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity); + memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity); + + m_bDirty = TRUE; +} + +boolean CPianoLEDs::ChannelMatches (u8 ucChannel, u8 ucWanted) +{ + if (ucWanted == MIDI_CHANNEL_NONE) + { + return FALSE; + } + + if (ucWanted == MIDI_CHANNEL_ANY) + { + return TRUE; + } + + return ucChannel == ucWanted; +} + +u8 CPianoLEDs::Scale (u8 ucChannel, u8 ucVelocity) +{ + unsigned nValue = ucChannel; + + // Global brightness ceiling. This is the clamp that keeps a whited-out + // strip inside the supply's current budget; see config.h. + nValue = nValue * GLOBAL_BRIGHTNESS / 255; + +#if VELOCITY_SENSITIVE + // Map velocity 1-127 onto [VELOCITY_FLOOR_PCT, 100] percent, so even the + // softest note stays visible. + unsigned nPercent = VELOCITY_FLOOR_PCT + + (100 - VELOCITY_FLOOR_PCT) * ucVelocity / 127; + nValue = nValue * nPercent / 100; +#endif + + return (u8) nValue; +} + +void CPianoLEDs::Update (void) +{ + if (!m_bDirty) + { + return; + } + + // Clear the flag before reading state, not after. An event arriving + // mid-render then leaves the flag set and we render again next pass, + // rather than being dropped. + m_bDirty = FALSE; + + unsigned nLit = 0; + + for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++) + { + u8 ucVelocity = m_KeyVelocity[nKey]; + boolean bHint = FALSE; + + if (ucVelocity == 0) + { + // A key being played wins over a "next note" hint on it. + ucVelocity = m_HintVelocity[nKey]; + bHint = TRUE; + } + + u8 ucRed = 0; + u8 ucGreen = 0; + u8 ucBlue = 0; + + // Bound the number of simultaneously lit keys, so no sequence of + // MIDI events can drive the strip past the supply's budget. + if ( ucVelocity != 0 + && nLit < MAX_LIT_KEYS) + { + nLit++; + + if (bHint) + { + ucRed = Scale (HINT_COLOR_R, ucVelocity); + ucGreen = Scale (HINT_COLOR_G, ucVelocity); + ucBlue = Scale (HINT_COLOR_B, ucVelocity); + } + else + { + ucRed = Scale (NOTE_COLOR_R, ucVelocity); + ucGreen = Scale (NOTE_COLOR_G, ucVelocity); + ucBlue = Scale (NOTE_COLOR_B, ucVelocity); + } + } + +#if STRIP_REVERSED + unsigned nBase = (KEY_COUNT - 1 - nKey) * LEDS_PER_KEY; +#else + unsigned nBase = nKey * LEDS_PER_KEY; +#endif + + for (unsigned i = 0; i < LEDS_PER_KEY; i++) + { + unsigned nLED = nBase + i; + assert (nLED < LED_COUNT); + + m_Stripe.SetLED (nLED, ucRed, ucGreen, ucBlue); + } + } + + m_Stripe.Update (); +} diff --git a/firmware/pianoleds.h b/firmware/pianoleds.h new file mode 100644 index 0000000..d060812 --- /dev/null +++ b/firmware/pianoleds.h @@ -0,0 +1,60 @@ +// +// pianoleds.h +// +// Maps incoming MIDI note events onto a WS2812B strip mounted above an +// 88-key keybed. +// +#ifndef _pianoleds_h +#define _pianoleds_h + +#include +#include +#include +#include "config.h" + +class CPianoLEDs +{ +public: + CPianoLEDs (void); + ~CPianoLEDs (void); + + boolean Initialize (void); + + // Attach to a USB MIDI device. Safe to call again after the gadget has + // been re-enumerated, which destroys and recreates the device object. + void AttachMIDIDevice (CUSBMIDIDevice *pMIDIDevice); + + // Push pending state to the strip. Call from the main loop only; this + // blocks for ~5.3ms of SPI traffic and must never run in IRQ context. + // Does nothing when no state has changed since the last call. + void Update (void); + + // Extinguish every pixel and forget all held notes. + void AllOff (void); + +private: + // Called in IRQ context by the USB MIDI driver. + static void MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength, + unsigned nDevice, void *pParam); + + void OnMIDIPacket (const u8 *pPacket, unsigned nLength); + + void SetKey (u8 ucNote, u8 ucVelocity, boolean bHint); + + // Scale a colour channel by velocity and the global brightness ceiling. + static u8 Scale (u8 ucChannel, u8 ucVelocity); + + static boolean ChannelMatches (u8 ucChannel, u8 ucWanted); + +private: + CWS28XXStripe m_Stripe; + + // Written in IRQ context, read by Update(). Index is + // note - MIDI_NOTE_MIN. Zero means the key is not lit. + volatile u8 m_KeyVelocity[KEY_COUNT]; + volatile u8 m_HintVelocity[KEY_COUNT]; + + volatile boolean m_bDirty; +}; + +#endif diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..b604ffe --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Host-side tests for the mapping and power-clamp logic. +# Compiles the real firmware sources against stubbed Circle headers, across +# every configuration variant that changes the rendering path. +set -e +cd "$(dirname "$0")/.." +OUT=$(mktemp -d) +trap 'rm -rf "$OUT"' EXIT + +FAIL=0 +for CFG in \ + "default:" \ + "reversed strip:-DSTRIP_REVERSED=1" \ + "velocity insensitive:-DVELOCITY_SENSITIVE=0" \ + "reversed + velocity insensitive:-DSTRIP_REVERSED=1 -DVELOCITY_SENSITIVE=0" \ + "full brightness:-DGLOBAL_BRIGHTNESS=255" \ + "tight key cap:-DMAX_LIT_KEYS=5" \ + "single LED per key:-DLEDS_PER_KEY=1" \ + "three LEDs per key:-DLEDS_PER_KEY=3" \ + "hints disabled:-DHINT_MIDI_CHANNEL=MIDI_CHANNEL_NONE" \ +; do + NAME=${CFG%%:*} + FLAGS=${CFG#*:} + printf '\n=== %s ===\n' "$NAME" + g++ -std=c++17 -Wall -Wextra -Wno-unused-parameter $FLAGS \ + -o "$OUT/t" -Itests/stubs -Ifirmware \ + tests/test_pianoleds.cpp firmware/pianoleds.cpp + "$OUT/t" || FAIL=1 +done + +printf '\n' +if [ $FAIL -ne 0 ]; then echo "SOME CONFIGURATIONS FAILED"; exit 1; fi +echo "all configurations passed" diff --git a/tests/stubs/WS28XX/ws28xxstripe.h b/tests/stubs/WS28XX/ws28xxstripe.h new file mode 100644 index 0000000..198ce7b --- /dev/null +++ b/tests/stubs/WS28XX/ws28xxstripe.h @@ -0,0 +1,25 @@ +#ifndef _stub_ws28xx_h +#define _stub_ws28xx_h +#include +#include +#include +enum TWS28XXType { WS2801, WS2812, WS2812B, SK6812 = WS2812B }; + +// Captures what the firmware pushed to the strip, so tests can inspect pixels. +class CWS28XXStripe +{ +public: + std::vector> m_Pixels; + unsigned m_nUpdates = 0; + CWS28XXStripe (TWS28XXType, unsigned nLEDCount, unsigned = 4000000, unsigned = 0) + : m_Pixels (nLEDCount, {0,0,0}) {} + boolean Initialize (void) { return TRUE; } + void SetLED (unsigned n, u8 r, u8 g, u8 b) { m_Pixels.at (n) = {r,g,b}; } + boolean Update (void) { m_nUpdates++; return TRUE; } + boolean Blackout (void) + { + for (auto &p : m_Pixels) p = {0,0,0}; + return TRUE; + } +}; +#endif diff --git a/tests/stubs/circle/types.h b/tests/stubs/circle/types.h new file mode 100644 index 0000000..7361611 --- /dev/null +++ b/tests/stubs/circle/types.h @@ -0,0 +1,10 @@ +#ifndef _stub_types_h +#define _stub_types_h +#include +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef int boolean; +#define TRUE 1 +#define FALSE 0 +#endif diff --git a/tests/stubs/circle/usb/usbmidi.h b/tests/stubs/circle/usb/usbmidi.h new file mode 100644 index 0000000..a49323d --- /dev/null +++ b/tests/stubs/circle/usb/usbmidi.h @@ -0,0 +1,22 @@ +#ifndef _stub_usbmidi_h +#define _stub_usbmidi_h +#include +typedef void TMIDIPacketHandlerEx (unsigned nCable, u8 *pPacket, unsigned nLength, + unsigned nDevice, void *pParam); +class CUSBMIDIDevice +{ +public: + TMIDIPacketHandlerEx *m_pHandler = nullptr; + void *m_pParam = nullptr; + void RegisterPacketHandler (TMIDIPacketHandlerEx *p, void *pParam) + { + m_pHandler = p; m_pParam = pParam; + } + // Deliver a plain MIDI message the way Circle's driver would. + void Inject (u8 a, u8 b, u8 c) + { + u8 packet[3] = {a, b, c}; + if (m_pHandler) m_pHandler (0, packet, 3, 1, m_pParam); + } +}; +#endif diff --git a/tests/stubs/circle/util.h b/tests/stubs/circle/util.h new file mode 100644 index 0000000..920f5ec --- /dev/null +++ b/tests/stubs/circle/util.h @@ -0,0 +1,4 @@ +#ifndef _stub_util_h +#define _stub_util_h +#include +#endif diff --git a/tests/test_pianoleds.cpp b/tests/test_pianoleds.cpp new file mode 100644 index 0000000..ee0d93b --- /dev/null +++ b/tests/test_pianoleds.cpp @@ -0,0 +1,183 @@ +// +// Host-side tests for the note-to-LED mapping and the power clamps. +// Compiles the real firmware/pianoleds.cpp against stubbed Circle headers. +// +#define private public // inspect the captured strip state +#include "pianoleds.h" +#undef private + +#include +#include + +static int g_nFail = 0; + +static void Check (const char *pName, bool bCond) +{ + printf ("%-58s %s\n", pName, bCond ? "ok" : "FAIL"); + if (!bCond) g_nFail++; +} + +static unsigned CountLit (CPianoLEDs &L) +{ + unsigned n = 0; + for (auto &p : L.m_Stripe.m_Pixels) + if (p[0] || p[1] || p[2]) n++; + return n; +} + +static bool Dark (CPianoLEDs &L, unsigned i) +{ + auto &p = L.m_Stripe.m_Pixels.at (i); + return !p[0] && !p[1] && !p[2]; +} + +// every pixel of one key's span is lit +static bool Span (CPianoLEDs &L, unsigned nBase) +{ + for (unsigned i = 0; i < LEDS_PER_KEY; i++) + if (Dark (L, nBase + i)) return false; + return true; +} + +static unsigned LedFor (u8 ucNote) +{ + unsigned nKey = ucNote - MIDI_NOTE_MIN; +#if STRIP_REVERSED + return (KEY_COUNT - 1 - nKey) * LEDS_PER_KEY; +#else + return nKey * LEDS_PER_KEY; +#endif +} + +int main (void) +{ + printf ("STRIP_REVERSED=%d LED_COUNT=%d MAX_LIT_KEYS=%d GLOBAL_BRIGHTNESS=%d\n\n", + STRIP_REVERSED, LED_COUNT, MAX_LIT_KEYS, GLOBAL_BRIGHTNESS); + + CUSBMIDIDevice MIDI; + CPianoLEDs LEDs; + LEDs.Initialize (); + LEDs.AttachMIDIDevice (&MIDI); + + // --- lowest key, A0 = note 21 ------------------------------------- + MIDI.Inject (0x90, 21, 127); + LEDs.Update (); +#if STRIP_REVERSED + unsigned nLow = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174 +#else + unsigned nLow = 0; +#endif + Check ("note 21 lights its whole key span", Span (LEDs, nLow)); + Check ("note 21 lights exactly LEDS_PER_KEY LEDs", CountLit (LEDs) == LEDS_PER_KEY); + + // --- highest key, C8 = note 108 ----------------------------------- + MIDI.Inject (0x80, 21, 0); + MIDI.Inject (0x90, 108, 127); + LEDs.Update (); +#if STRIP_REVERSED + unsigned nHigh = 0; +#else + unsigned nHigh = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174 +#endif + Check ("note 108 lights its whole key span", Span (LEDs, nHigh)); + Check ("note 108 lights exactly LEDS_PER_KEY LEDs", CountLit (LEDs) == LEDS_PER_KEY); + Check ("the two extremes are at opposite ends", nLow != nHigh); + + // --- note off ------------------------------------------------------ + MIDI.Inject (0x80, 108, 0); + LEDs.Update (); + Check ("note off extinguishes the key", CountLit (LEDs) == 0); + + // --- note on with velocity 0 is a note off ------------------------- + MIDI.Inject (0x90, 60, 100); + LEDs.Update (); + Check ("note on lights middle C", CountLit (LEDs) == LEDS_PER_KEY); + MIDI.Inject (0x90, 60, 0); + LEDs.Update (); + Check ("note on velocity 0 acts as note off", CountLit (LEDs) == 0); + + // --- out-of-range notes are dropped, not clamped into the strip ---- + MIDI.Inject (0x90, 20, 127); + MIDI.Inject (0x90, 109, 127); + MIDI.Inject (0x90, 0, 127); + MIDI.Inject (0x90, 127, 127); + LEDs.Update (); + Check ("notes outside 21-108 are ignored", CountLit (LEDs) == 0); + + // --- brightness ceiling -------------------------------------------- + for (u8 n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) + MIDI.Inject (0x90, n, 127); + LEDs.Update (); + bool bWithinCeiling = true; +#if GLOBAL_BRIGHTNESS < 255 // at 255 a u8 channel cannot exceed the ceiling by construction + for (auto &p : LEDs.m_Stripe.m_Pixels) + for (int c = 0; c < 3; c++) + if (p[c] > GLOBAL_BRIGHTNESS) bWithinCeiling = false; +#endif + Check ("no channel ever exceeds GLOBAL_BRIGHTNESS", bWithinCeiling); + + // --- simultaneous-key cap ------------------------------------------ + Check ("all 88 keys held stays within MAX_LIT_KEYS", + CountLit (LEDs) <= MAX_LIT_KEYS * LEDS_PER_KEY); + + // --- all notes off -------------------------------------------------- + MIDI.Inject (0xB0, 123, 0); + LEDs.Update (); + Check ("CC 123 (all notes off) clears the strip", CountLit (LEDs) == 0); + + for (u8 n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) + MIDI.Inject (0x90, n, 127); + MIDI.Inject (0xB0, 120, 0); + LEDs.Update (); + Check ("CC 120 (all sound off) clears the strip", CountLit (LEDs) == 0); + + // --- velocity sensitivity ------------------------------------------- + MIDI.Inject (0x90, 60, 127); + LEDs.Update (); + auto Loud = LEDs.m_Stripe.m_Pixels.at (LedFor (60)); + MIDI.Inject (0x90, 60, 1); + LEDs.Update (); + auto Soft = LEDs.m_Stripe.m_Pixels.at (LedFor (60)); +#if VELOCITY_SENSITIVE + Check ("a soft note is dimmer than a loud one", Soft[2] < Loud[2]); + Check ("a soft note is still visible", Soft[2] > 0); +#else + Check ("velocity does not change brightness", Soft[2] == Loud[2]); +#endif + MIDI.Inject (0x80, 60, 0); + + // --- hint channel ---------------------------------------------------- +#if HINT_MIDI_CHANNEL != MIDI_CHANNEL_NONE + MIDI.Inject (0x90 | HINT_MIDI_CHANNEL, 64, 127); + LEDs.Update (); + auto Hint = LEDs.m_Stripe.m_Pixels.at (LedFor (64)); + Check ("a hint note lights in the hint colour", Hint != Loud && (Hint[0] || Hint[1] || Hint[2])); + + // a key actually played wins over a hint on the same key + MIDI.Inject (0x90, 64, 127); + LEDs.Update (); + auto Both = LEDs.m_Stripe.m_Pixels.at (LedFor (64)); + Check ("a played note overrides a hint on the same key", Both == Loud); + + // releasing the played note falls back to the still-pending hint + MIDI.Inject (0x80, 64, 0); + LEDs.Update (); + auto Back = LEDs.m_Stripe.m_Pixels.at (LedFor (64)); + Check ("releasing a played note reveals the hint again", Back == Hint); +#endif + + // --- reconnect clears held notes ------------------------------------- + MIDI.Inject (0x90, 60, 127); + LEDs.AttachMIDIDevice (&MIDI); + LEDs.Update (); + Check ("re-enumeration clears notes held at suspend", CountLit (LEDs) == 0); + + // --- short packets are not parsed as notes ---------------------------- + u8 Short[1] = {0xF8}; // clock, 1 byte + MIDI.m_pHandler (0, Short, 1, 1, MIDI.m_pParam); + LEDs.Update (); + Check ("a 1-byte realtime message lights nothing", CountLit (LEDs) == 0); + + printf ("\n%s\n", g_nFail ? "FAILURES" : "all tests passed"); + return g_nFail != 0; +}