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
+213
View File
@@ -0,0 +1,213 @@
//
// pianoleds.cpp
//
#include "pianoleds.h"
#include <string.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 (ILEDStrip &Strip)
: m_Strip (Strip),
m_bDirty (true)
{
memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity);
memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity);
}
CPianoLEDs::~CPianoLEDs (void)
{
}
bool CPianoLEDs::Initialize (void)
{
if (!m_Strip.Initialize ())
{
return false;
}
assert (m_Strip.GetLEDCount () >= LED_COUNT);
// Start from a known-dark strip rather than whatever the pixels held
// when power came up.
return m_Strip.Blackout ();
}
void CPianoLEDs::OnMIDIPacket (const uint8_t *pPacket, unsigned nLength)
{
// The platform 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;
}
uint8_t ucStatus = pPacket[0] & 0xF0;
uint8_t 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 (uint8_t ucNote, uint8_t ucVelocity, bool 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;
}
bool CPianoLEDs::ChannelMatches (uint8_t ucChannel, uint8_t ucWanted)
{
if (ucWanted == MIDI_CHANNEL_NONE)
{
return false;
}
if (ucWanted == MIDI_CHANNEL_ANY)
{
return true;
}
return ucChannel == ucWanted;
}
uint8_t CPianoLEDs::Scale (uint8_t ucChannel, uint8_t 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 (uint8_t) 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++)
{
uint8_t ucVelocity = m_KeyVelocity[nKey];
bool bHint = false;
if (ucVelocity == 0)
{
// A key being played wins over a "next note" hint on it.
ucVelocity = m_HintVelocity[nKey];
bHint = true;
}
uint8_t ucRed = 0;
uint8_t ucGreen = 0;
uint8_t 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_Strip.SetLED (nLED, ucRed, ucGreen, ucBlue);
}
}
m_Strip.Update ();
}