Files
PianoLED-Circle-Edition/src/pianoleds.cpp
T
prosolis 59bd3b4cea Add boot self-test and idle indicator; target the Waveshare RP2350-Plus
The board arriving is a Waveshare RP2350-Plus (4MB, USB-C), which is now the
default BOARD. Its pico-sdk header defines neither PICO_DEFAULT_LED_PIN nor
PICO_DEFAULT_WS2812_PIN - it has no onboard indicator at all - so a bare board
gives no sign of life. The status-LED support added for boards that do have one
(zero, one, tiny, usb_a on GPIO16; eth on GPIO25) is kept and compiles out here.

That makes feedback on the strip itself the useful path, and it turns out to be
the better one anyway:

- Boot self-test sweeps one pixel from index 0 to the far end once at startup.
  It answers in a single glance whether the firmware runs, PIO drives the line,
  the strip is the length LED_COUNT claims, the far end holds voltage, and -
  because you see which end it starts from - whether STRIP_REVERSED is right.
  It runs before USB, so the first test needs nothing but 5V.
- Idle indicator holds one dim pixel lit while no host is connected, separating
  "powered and waiting" from "no power" and from "crashed".

Both are platform-independent, so the Circle build gets them too.

Verified: tests pass across seventeen configurations, now including the
self-test and idle paths on and off. Both platforms build clean with no
warnings from project sources.

Note on the previous commit's verification: a filtered build log hid a real
compile error in the Pico target (sleep_ms takes uint32_t, which is unsigned
long here, and did not match the portable void(*)(unsigned) delay callback).
The build script now gets an explicit success check rather than a grep.

Claude-Session: https://claude.ai/code/session_01TVCB25LBsmeteWvaSMz4Ne
2026-08-27 23:22:56 -07:00

458 lines
10 KiB
C++

//
// 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),
m_bHostConnected (false),
m_nCalibPattern (CALIB_PATTERN_OFF),
m_nCalibIndex (0),
m_nCalibIndexHi (0)
{
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);
BuildKeyMap ();
// 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:
switch (pPacket[1])
{
case MIDI_CC_ALL_SOUND_OFF:
case MIDI_CC_ALL_NOTES_OFF:
AllOff ();
break;
case CALIB_CC_PATTERN:
// Leaving calibration must not strand a lit pattern.
m_nCalibPattern = pPacket[2];
m_bDirty = true;
break;
case CALIB_CC_INDEX_HI:
m_nCalibIndexHi = pPacket[2];
break;
case CALIB_CC_INDEX_LO:
// Low byte last, so the 14-bit value updates atomically
// from the renderer's point of view.
m_nCalibIndex = (m_nCalibIndexHi << 7) | pPacket[2];
m_bDirty = true;
break;
default:
break;
}
break;
default:
break;
}
}
// Pitch classes of the white keys, C through B.
static bool IsWhiteKey (uint8_t ucNote)
{
switch (ucNote % 12)
{
case 0: case 2: case 4: case 5: case 7: case 9: case 11:
return true;
default:
return false;
}
}
void CPianoLEDs::BuildKeyMap (void)
{
#if NOTE_MAP_GEOMETRIC
// A white key is LED_COUNT / WHITE_KEY_COUNT pixels wide - 3.38 at the
// nominal 176 LEDs, not 2. Held as a 1/256 fixed-point value so the
// mapping needs no floating point.
const unsigned nWhitePitch = (LED_COUNT * 256u) / WHITE_KEY_COUNT;
unsigned nWhitesBelow = 0;
#endif
for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++)
{
uint8_t ucNote = (uint8_t) (MIDI_NOTE_MIN + nKey);
#if NOTE_MAP_GEOMETRIC
// A white key's centre sits half a key past the whites below it;
// a black key sits on the boundary between its neighbours.
unsigned nCentre = nWhitesBelow * nWhitePitch;
if (IsWhiteKey (ucNote))
{
nCentre += nWhitePitch / 2;
nWhitesBelow++;
}
// Round to the nearest pixel, then centre the lit span on it.
int nCentreLED = (int) ((nCentre + 128) / 256);
int nStart = nCentreLED - (int) (LEDS_PER_KEY / 2);
#else
(void) ucNote;
int nStart = (int) (nKey * LEDS_PER_KEY);
#endif
nStart += LED_OFFSET;
#if STRIP_REVERSED
// Mirror the whole strip, keeping the span left-to-right.
nStart = (int) LED_COUNT - nStart - (int) LEDS_PER_KEY;
#endif
m_KeyLED[nKey] = nStart;
}
}
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::SetHostConnected (bool bConnected)
{
if (m_bHostConnected != bConnected)
{
m_bHostConnected = bConnected;
m_bDirty = true;
}
}
void CPianoLEDs::RunSelfTest (TDelayMs *pDelay)
{
#if BOOT_SELF_TEST
assert (pDelay != nullptr);
// Sweep in strip order, not key order, so what you watch is the strip's
// own geometry: it starts at pixel 0 wherever that physically is.
for (unsigned i = 0; i < LED_COUNT; i++)
{
if (i > 0)
{
m_Strip.SetLED (i - 1, 0, 0, 0);
}
m_Strip.SetLED (i, GLOBAL_BRIGHTNESS, GLOBAL_BRIGHTNESS,
GLOBAL_BRIGHTNESS);
m_Strip.Update ();
pDelay (BOOT_SELF_TEST_MS);
}
m_Strip.Blackout ();
// The sweep left the strip in a state the renderer does not know about.
m_bDirty = true;
#else
(void) pDelay;
#endif
}
void CPianoLEDs::RenderIdle (void)
{
#if IDLE_INDICATOR
// One dim pixel at the strip's start: powered and running, no host yet.
const uint8_t B = GLOBAL_BRIGHTNESS / 8 ? GLOBAL_BRIGHTNESS / 8 : 1;
PaintPixel (0, B, B, B);
#endif
}
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::PaintPixel (int nLED, uint8_t nRed, uint8_t nGreen, uint8_t nBlue)
{
// A non-zero LED_OFFSET can push a key's span off either end. Drop
// those pixels rather than wrapping them to the wrong end of the strip.
if ( nLED < 0
|| nLED >= (int) LED_COUNT)
{
return;
}
m_Strip.SetLED ((unsigned) nLED, nRed, nGreen, nBlue);
}
void CPianoLEDs::PaintKey (unsigned nKey, uint8_t nRed, uint8_t nGreen, uint8_t nBlue)
{
assert (nKey < KEY_COUNT);
for (unsigned i = 0; i < LEDS_PER_KEY; i++)
{
PaintPixel (m_KeyLED[nKey] + (int) i, nRed, nGreen, nBlue);
}
}
void CPianoLEDs::RenderNotes (void)
{
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;
}
// 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)
{
continue;
}
nLit++;
// Only lit keys are painted. Under the geometric map adjacent
// keys' spans overlap, so painting unlit keys black here would
// erase a lit neighbour's pixels.
if (bHint)
{
PaintKey (nKey, Scale (HINT_COLOR_R, ucVelocity),
Scale (HINT_COLOR_G, ucVelocity),
Scale (HINT_COLOR_B, ucVelocity));
}
else
{
PaintKey (nKey, Scale (NOTE_COLOR_R, ucVelocity),
Scale (NOTE_COLOR_G, ucVelocity),
Scale (NOTE_COLOR_B, ucVelocity));
}
}
}
void CPianoLEDs::RenderCalibration (void)
{
// Patterns run at the same ceiling as normal operation, so nothing here
// can draw more current than the design already allows.
const uint8_t W = GLOBAL_BRIGHTNESS;
switch (m_nCalibPattern)
{
case CALIB_PATTERN_ENDS:
// Confirms orientation and that LED_COUNT matches the strip you
// actually cut. Red is pixel 0, green is the last pixel.
PaintPixel (0, W, 0, 0);
PaintPixel ((int) LED_COUNT - 1, 0, W, 0);
break;
case CALIB_PATTERN_OCTAVES:
// Every C. Mapping drift shows up immediately as the marks
// walking off the keys; middle C is picked out in red.
for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++)
{
uint8_t ucNote = (uint8_t) (MIDI_NOTE_MIN + nKey);
if (ucNote % 12 != 0)
{
continue;
}
if (ucNote == 60)
{
PaintKey (nKey, W, 0, 0);
}
else
{
PaintKey (nKey, 0, 0, W);
}
}
break;
case CALIB_PATTERN_KEYS:
// Every key, white keys and black keys in different colours, so
// the whole mapping can be checked against the keybed at once.
for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++)
{
if (IsWhiteKey ((uint8_t) (MIDI_NOTE_MIN + nKey)))
{
PaintKey (nKey, 0, W, 0);
}
else
{
PaintKey (nKey, 0, 0, W);
}
}
break;
case CALIB_PATTERN_WALK:
// One pixel at a time, stepped from the PC. This is how
// LED_OFFSET gets its value: walk to the pixel sitting over A0.
PaintPixel ((int) m_nCalibIndex, W, W, W);
break;
case CALIB_PATTERN_ALL:
// Voltage droop test. Every pixel lit is well beyond normal
// operation, which caps at MAX_LIT_KEYS, so watch the far end
// for the colour shifting warm - that is the injection point
// telling you it is needed.
for (unsigned i = 0; i < LED_COUNT; i++)
{
PaintPixel ((int) i, W, W, W);
}
break;
default:
break;
}
}
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;
// Start from black, then paint only what should be lit. Key spans can
// overlap under the geometric map, so nothing may paint black over a
// region a neighbour has already claimed.
for (unsigned i = 0; i < LED_COUNT; i++)
{
m_Strip.SetLED (i, 0, 0, 0);
}
if (m_nCalibPattern != CALIB_PATTERN_OFF)
{
RenderCalibration ();
}
else if (!m_bHostConnected)
{
// No host means no notes can arrive, so show a heartbeat rather
// than a strip that looks identical to an unpowered one.
RenderIdle ();
}
else
{
RenderNotes ();
}
m_Strip.Update ();
}