Add Phase 1 Circle firmware

Greenfield bare-metal firmware for a Pi Zero that lights a WS2812B strip
above an 88-key keybed. The Pi is a USB MIDI gadget; the PC is the host and
owns the piano connection and everything else. No network stack, no
filesystem, no shell.

Circle is a submodule pinned to Step51. Builds kernel.img (RASPPI=1, Zero /
Zero W) and kernel7.img (RASPPI=2, Zero 2 / Zero 2 W); both coexist on one
card, so either model runs from the same SD.

Resolves two open assumptions from the plan against the Circle sources
rather than by guessing:

- CWS28XXStripe clocks the waveform out over SPI at a fixed 6.4MHz, one SPI
  byte per LED bit. On device 0 that puts data on MOSI = GPIO10 = physical
  pin 19. The implied 5.28ms frame time matches the plan's arithmetic.
- The USB gadget lifecycle follows sample/29-miniorgan, which already
  carries a USB_GADGET_MODE path.

Three details the hardware forces:

- The gadget destroys and recreates its CUSBMIDIDevice across a USB
  suspend, so the kernel re-fetches it and clears notes held at that
  moment. Otherwise a chord would stay lit forever when the PC sleeps.
- Rendering blocks for ~5.3ms of SPI traffic, so the MIDI packet handler
  only records state and the main loop draws.
- MAX_LIT_KEYS complements the global brightness ceiling. 176 LEDs at full
  white would draw ~10.5A against a 6A supply; together the two clamps make
  that unreachable rather than merely unlikely.

Every Phase 0 product decision has a named slot in firmware/config.h, all
overridable at build time via EXTRADEFINE.

tests/run.sh compiles the real pianoleds.cpp against stubbed Circle headers
and checks the mapping, note-off paths, range clamping and both power
clamps across nine configuration variants. It verifies arithmetic, not
wiring, and does not replace bench-testing on real hardware.

Claude-Session: https://claude.ai/code/session_01TVCB25LBsmeteWvaSMz4Ne
This commit is contained in:
prosolis
2026-08-27 22:09:47 -07:00
parent e5b92de3e6
commit 3904703de1
18 changed files with 1176 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Build output
boot/
*.o
*.d
*.a
*.elf
*.lst
*.map
*.img
# Circle build configuration, generated by ./build.sh
circle/Config.mk
+3
View File
@@ -0,0 +1,3 @@
[submodule "circle"]
path = circle
url = https://github.com/rsta2/circle.git
+135
View File
@@ -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 <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
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 21108 (A0C8) 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.
Executable
+63
View File
@@ -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."
Submodule
+1
Submodule circle added at 6177984e30
+24
View File
@@ -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)
+146
View File
@@ -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
+135
View File
@@ -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 <circle/usb/gadget/usbmidigadget.h>
#include <circle/devicenameservice.h>
#include <assert.h>
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;
}
+59
View File
@@ -0,0 +1,59 @@
//
// kernel.h
//
#ifndef _kernel_h
#define _kernel_h
#include <circle/actled.h>
#include <circle/koptions.h>
#include <circle/devicenameservice.h>
#include <circle/serial.h>
#include <circle/exceptionhandler.h>
#include <circle/interrupt.h>
#include <circle/timer.h>
#include <circle/logger.h>
#include <circle/types.h>
#include <circle/usb/usbcontroller.h>
#include <circle/usb/usbmidi.h>
#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
+31
View File
@@ -0,0 +1,31 @@
//
// main.cpp
//
#include "kernel.h"
#include <circle/startup.h>
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;
}
}
+230
View File
@@ -0,0 +1,230 @@
//
// pianoleds.cpp
//
#include "pianoleds.h"
#include <circle/util.h>
#include <assert.h>
// 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<CPianoLEDs *> (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 ();
}
+60
View File
@@ -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 <circle/usb/usbmidi.h>
#include <circle/types.h>
#include <WS28XX/ws28xxstripe.h>
#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
Executable
+33
View File
@@ -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"
+25
View File
@@ -0,0 +1,25 @@
#ifndef _stub_ws28xx_h
#define _stub_ws28xx_h
#include <circle/types.h>
#include <vector>
#include <array>
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<std::array<u8,3>> 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
+10
View File
@@ -0,0 +1,10 @@
#ifndef _stub_types_h
#define _stub_types_h
#include <cstdint>
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef int boolean;
#define TRUE 1
#define FALSE 0
#endif
+22
View File
@@ -0,0 +1,22 @@
#ifndef _stub_usbmidi_h
#define _stub_usbmidi_h
#include <circle/types.h>
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
+4
View File
@@ -0,0 +1,4 @@
#ifndef _stub_util_h
#define _stub_util_h
#include <cstring>
#endif
+183
View File
@@ -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 <cstdio>
#include <cstring>
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;
}