Port to RP2040/RP2350, extract the shared logic

Raspberry Pi supply is unreliable, and Circle is Broadcom-only - there is no
Allwinner or Rockchip support anywhere in its tree, so an Orange Pi is not a
board swap but a restart on an unproven base. RP2040/RP2350 is the better
answer: available, ~$4, and a better fit for this job than the Zero ever was.

Structure. All the visualizer logic moves to src/ and is now
platform-independent, depending only on ILEDStrip (four methods) with MIDI
pushed in via OnMIDIPacket(). Each platform supplies a backend and a main
loop. The Circle build is unchanged in behaviour and still produces both
kernel images.

Pico backend:
- WS2812B from a PIO state machine, which clocks the 1.25us bit cell directly
  rather than faking it with 8 SPI bytes per data bit as the Circle build must.
- TinyUSB MIDI 1.0 device. Enumerates as an ordinary ALSA port, as the Circle
  gadget does. Packet framing comes from the USB MIDI Code Index Number rather
  than being re-derived.
- Mount, unmount, suspend and resume all clear held notes, so a chord held when
  the host goes away cannot stay lit.
- Latch spacing is enforced against a timestamp, so a caller cannot start a
  frame inside the WS2812B reset window.

Verified: builds clean for both pico (RP2040, 30052 bytes) and pico2 (RP2350,
28284 bytes), no warnings from project sources, and the Circle build still
produces kernel.img and kernel7.img. Tests pass across nine configurations.

Incidental findings. PIO frees both hardware SPI blocks; on a Pi Zero Circle
exposes only one SPI master (DEVICES=1 for RASPPI<4) and the WS2812B driver
monopolises it, so a display and the strip could not coexist there. RP2040/
RP2350 also support USB host and, on the W variants, BLE via btstack - both
of which section 3a records as impossible on Circle.

Also documents a known limitation found while looking at calibration: the
note-to-LED map is linear in semitone index, but a keybed is not. 52 white
keys span the same 1222mm, making one white key ~3.38 LEDs rather than 2. The
error drifts within each octave, worst at F, by up to ~0.87 LEDs (~6mm) even
after an optimal offset and scale. A geometric map would remove it. Not yet
implemented.

Claude-Session: https://claude.ai/code/session_01TVCB25LBsmeteWvaSMz4Ne
This commit is contained in:
prosolis
2026-08-27 22:53:00 -07:00
parent 138efc28ca
commit 469b321a40
27 changed files with 1046 additions and 267 deletions
+4
View File
@@ -6,6 +6,10 @@ CIRCLEHOME = ../circle
OBJS = main.o kernel.o pianoleds.o
# Portable visualizer logic, shared with the Pico build.
VPATH = ../src
EXTRAINCLUDE += -I ../src
LIBS = $(CIRCLEHOME)/addon/WS28XX/libws28xx.a \
$(CIRCLEHOME)/lib/usb/gadget/libusbgadget.a \
$(CIRCLEHOME)/lib/usb/libusb.a \
+38
View File
@@ -0,0 +1,38 @@
//
// circlestrip.h
//
// ILEDStrip backed by Circle's CWS28XXStripe (WS2812B over SPI).
//
#ifndef _circlestrip_h
#define _circlestrip_h
#include <WS28XX/ws28xxstripe.h>
#include "ledstrip.h"
#include "config.h"
class CCircleLEDStrip : public ILEDStrip
{
public:
CCircleLEDStrip (void)
: m_Stripe (WS2812B, LED_COUNT, 4000000, SPI_MASTER_DEVICE)
{
}
bool Initialize (void) override { return m_Stripe.Initialize (); }
unsigned GetLEDCount (void) const override { return m_Stripe.GetLEDCount (); }
void SetLED (unsigned nIndex, uint8_t nRed, uint8_t nGreen, uint8_t nBlue) override
{
m_Stripe.SetLED (nIndex, nRed, nGreen, nBlue);
}
bool Update (void) override { return m_Stripe.Update (); }
bool Blackout (void) override { return m_Stripe.Blackout (); }
private:
CWS28XXStripe m_Stripe;
};
#endif
-146
View File
@@ -1,146 +0,0 @@
//
// 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
+16 -2
View File
@@ -16,11 +16,21 @@
static const char FromKernel[] = "kernel";
void CKernel::MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam)
{
CKernel *pThis = static_cast<CKernel *> (pParam);
assert (pThis != 0);
pThis->m_PianoLEDs.OnMIDIPacket (pPacket, nLength);
}
CKernel::CKernel (void)
: m_Timer (&m_Interrupt),
m_Logger (m_Options.GetLogLevel (), &m_Timer),
m_pUSB (new CUSBMIDIGadget (&m_Interrupt)),
m_pMIDIDevice (0)
m_pMIDIDevice (0),
m_PianoLEDs (m_LEDStrip)
{
m_ActLED.Blink (5); // show we are alive
}
@@ -95,7 +105,11 @@ void CKernel::UpdateMIDIDevice (void)
if (m_pMIDIDevice != 0)
{
m_PianoLEDs.AttachMIDIDevice (m_pMIDIDevice);
// The gadget builds a new device object on each enumeration, so
// clear anything held at the moment the host went away.
m_PianoLEDs.AllOff ();
m_pMIDIDevice->RegisterPacketHandler (MIDIPacketHandler, this);
m_Logger.Write (FromKernel, LogNotice, "USB MIDI gadget connected");
}
+5
View File
@@ -16,6 +16,7 @@
#include <circle/usb/usbcontroller.h>
#include <circle/usb/usbmidi.h>
#include "pianoleds.h"
#include "circlestrip.h"
enum TShutdownMode
{
@@ -39,6 +40,9 @@ private:
// after every re-enumeration.
void UpdateMIDIDevice (void);
static void MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam);
private:
// do not change this order
CActLED m_ActLED;
@@ -53,6 +57,7 @@ private:
CUSBController *m_pUSB;
CUSBMIDIDevice *m_pMIDIDevice;
CCircleLEDStrip m_LEDStrip;
CPianoLEDs m_PianoLEDs;
};
-230
View File
@@ -1,230 +0,0 @@
//
// 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
@@ -1,60 +0,0 @@
//
// 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