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