Add calibration patterns and a geometric note-to-LED map

Hardware arrives tomorrow, which makes this the blocking work: the appliance
has no console, so without it there is no way to answer "is pixel 0 at the end
I think it is" except by guessing.

Geometric mapping. NOTE_MAP_GEOMETRIC (default on) derives each key from
white-key geometry rather than semitone index: 52 white keys span the strip,
so a white key is LED_COUNT/52 pixels - about 3.38 at 176 LEDs, not 2 - with
black keys on the boundaries. The old linear map drifts within each octave,
worst at F, by up to ~0.87 LEDs (~6mm) even after an optimal offset and scale.
Set NOTE_MAP_GEOMETRIC=0 to restore it.

This exposed a real bug. Under the geometric map adjacent key spans overlap,
because the semitone pitch (~1.7 LEDs) is narrower than LEDS_PER_KEY. The
renderer painted unlit keys black, so a key erased its lit neighbour's pixels.
It now clears once and paints only lit keys. The linear map never overlapped,
so this could not have been found without the geometry change.

LED_OFFSET shifts every key, absorbing where the strip was actually cut and
where the profile ended up. Off-strip pixels are clipped, never wrapped.

Calibration patterns, selected by CC 20, with CC 21/22 setting the pixel for
the walk: ends (orientation and length), octaves (mapping drift), keys (whole
mapping at once), walk (finding LED_OFFSET), all (voltage droop at the far
end). Patterns run at the same brightness ceiling as normal operation, so none
can exceed the current budget the design already allows.

tools/calibrate.sh drives all of it from the PC over ALSA MIDI, and README
carries the six-step procedure in dependency order.

Verified: tests pass across fourteen configurations, now including both
mapping modes and positive, negative and reversed offsets. Both platforms
build clean - pianoled.uf2 for RP2350 and both Circle kernel images - with no
warnings from project sources.

Claude-Session: https://claude.ai/code/session_01TVCB25LBsmeteWvaSMz4Ne
This commit is contained in:
prosolis
2026-08-27 23:06:11 -07:00
parent 469b321a40
commit 202adacf7a
7 changed files with 676 additions and 70 deletions
+222 -34
View File
@@ -16,7 +16,10 @@
CPianoLEDs::CPianoLEDs (ILEDStrip &Strip)
: m_Strip (Strip),
m_bDirty (true)
m_bDirty (true),
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);
@@ -35,6 +38,8 @@ bool CPianoLEDs::Initialize (void)
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 ();
@@ -65,10 +70,32 @@ void CPianoLEDs::OnMIDIPacket (const uint8_t *pPacket, unsigned nLength)
break;
case MIDI_CONTROL_CHANGE:
if ( pPacket[1] == MIDI_CC_ALL_SOUND_OFF
|| pPacket[1] == MIDI_CC_ALL_NOTES_OFF)
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;
@@ -77,6 +104,62 @@ void CPianoLEDs::OnMIDIPacket (const uint8_t *pPacket, unsigned nLength)
}
}
// 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
@@ -143,18 +226,31 @@ uint8_t CPianoLEDs::Scale (uint8_t ucChannel, uint8_t ucVelocity)
return (uint8_t) nValue;
}
void CPianoLEDs::Update (void)
void CPianoLEDs::PaintPixel (int nLED, uint8_t nRed, uint8_t nGreen, uint8_t nBlue)
{
if (!m_bDirty)
// 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;
}
// 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;
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++)
@@ -169,44 +265,136 @@ void CPianoLEDs::Update (void)
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)
if ( ucVelocity == 0
|| nLit >= MAX_LIT_KEYS)
{
nLit++;
continue;
}
if (bHint)
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)
{
ucRed = Scale (HINT_COLOR_R, ucVelocity);
ucGreen = Scale (HINT_COLOR_G, ucVelocity);
ucBlue = Scale (HINT_COLOR_B, ucVelocity);
continue;
}
if (ucNote == 60)
{
PaintKey (nKey, W, 0, 0);
}
else
{
ucRed = Scale (NOTE_COLOR_R, ucVelocity);
ucGreen = Scale (NOTE_COLOR_G, ucVelocity);
ucBlue = Scale (NOTE_COLOR_B, ucVelocity);
PaintKey (nKey, 0, 0, W);
}
}
break;
#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++)
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++)
{
unsigned nLED = nBase + i;
assert (nLED < LED_COUNT);
m_Strip.SetLED (nLED, ucRed, ucGreen, ucBlue);
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)
{
RenderNotes ();
}
else
{
RenderCalibration ();
}
m_Strip.Update ();