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
458 lines
10 KiB
C++
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 ();
|
|
}
|