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
+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