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
+5
View File
@@ -18,6 +18,11 @@ for CFG in \
"single LED per key:-DLEDS_PER_KEY=1" \
"three LEDs per key:-DLEDS_PER_KEY=3" \
"hints disabled:-DHINT_MIDI_CHANNEL=MIDI_CHANNEL_NONE" \
"linear map:-DNOTE_MAP_GEOMETRIC=0" \
"linear map reversed:-DNOTE_MAP_GEOMETRIC=0 -DSTRIP_REVERSED=1" \
"positive offset:-DLED_OFFSET=3" \
"negative offset:-DLED_OFFSET=-2" \
"offset reversed:-DLED_OFFSET=4 -DSTRIP_REVERSED=1" \
; do
NAME=${CFG%%:*}
FLAGS=${CFG#*:}
+170 -30
View File
@@ -27,12 +27,20 @@ static unsigned CountLit (void)
return n;
}
static bool Dark (unsigned i)
static bool Dark (int i)
{
auto &p = Strip.m_Pixels.at (i);
// Off-strip is not lit. Signed, because a negative LED_OFFSET can push
// a key's start below zero.
if (i < 0 || i >= (int) Strip.m_Pixels.size ()) return true;
auto &p = Strip.m_Pixels[i];
return !p[0] && !p[1] && !p[2];
}
static bool OnStrip (int nBase)
{
return nBase >= 0 && nBase + (int) LEDS_PER_KEY <= (int) LED_COUNT;
}
// Deliver a plain MIDI message the way a platform backend would.
static void Inject (uint8_t a, uint8_t b, uint8_t c)
{
@@ -41,23 +49,26 @@ static void Inject (uint8_t a, uint8_t b, uint8_t c)
}
// every pixel of one key's span is lit
static bool Span (unsigned nBase)
static bool Span (int nBase)
{
for (unsigned i = 0; i < LEDS_PER_KEY; i++)
if (Dark (nBase + i)) return false;
if (Dark (nBase + (int) i)) return false;
return true;
}
static unsigned LedFor (uint8_t ucNote)
static int LedFor (uint8_t ucNote)
{
unsigned nKey = ucNote - MIDI_NOTE_MIN;
#if STRIP_REVERSED
return (KEY_COUNT - 1 - nKey) * LEDS_PER_KEY;
#else
return nKey * LEDS_PER_KEY;
#endif
return LEDs.GetKeyLED (ucNote - MIDI_NOTE_MIN);
}
#if LED_OFFSET != 0
static void AllOffAndClear (void)
{
LEDs.AllOff ();
LEDs.Update ();
}
#endif
int main (void)
{
printf ("STRIP_REVERSED=%d LED_COUNT=%d MAX_LIT_KEYS=%d GLOBAL_BRIGHTNESS=%d\n\n",
@@ -68,25 +79,25 @@ int main (void)
// --- lowest key, A0 = note 21 -------------------------------------
Inject (0x90, 21, 127);
LEDs.Update ();
#if STRIP_REVERSED
unsigned nLow = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174
#else
unsigned nLow = 0;
#endif
Check ("note 21 lights its whole key span", Span (nLow));
Check ("note 21 lights exactly LEDS_PER_KEY LEDs", CountLit () == LEDS_PER_KEY);
int nLow = LedFor (21);
if (OnStrip (nLow))
{
Check ("note 21 lights its whole key span", Span (nLow));
Check ("note 21 lights exactly LEDS_PER_KEY LEDs",
CountLit () == LEDS_PER_KEY);
}
// --- highest key, C8 = note 108 -----------------------------------
Inject (0x80, 21, 0);
Inject (0x90, 108, 127);
LEDs.Update ();
#if STRIP_REVERSED
unsigned nHigh = 0;
#else
unsigned nHigh = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174
#endif
Check ("note 108 lights its whole key span", Span (nHigh));
Check ("note 108 lights exactly LEDS_PER_KEY LEDs", CountLit () == LEDS_PER_KEY);
int nHigh = LedFor (108);
if (OnStrip (nHigh))
{
Check ("note 108 lights its whole key span", Span (nHigh));
Check ("note 108 lights exactly LEDS_PER_KEY LEDs",
CountLit () == LEDS_PER_KEY);
}
Check ("the two extremes are at opposite ends", nLow != nHigh);
// --- note off ------------------------------------------------------
@@ -140,10 +151,10 @@ int main (void)
// --- velocity sensitivity -------------------------------------------
Inject (0x90, 60, 127);
LEDs.Update ();
auto Loud = Strip.m_Pixels.at (LedFor (60));
auto Loud = Strip.m_Pixels.at (((LedFor (60)) + LED_COUNT) % LED_COUNT);
Inject (0x90, 60, 1);
LEDs.Update ();
auto Soft = Strip.m_Pixels.at (LedFor (60));
auto Soft = Strip.m_Pixels.at (((LedFor (60)) + LED_COUNT) % LED_COUNT);
#if VELOCITY_SENSITIVE
Check ("a soft note is dimmer than a loud one", Soft[2] < Loud[2]);
Check ("a soft note is still visible", Soft[2] > 0);
@@ -156,19 +167,19 @@ int main (void)
#if HINT_MIDI_CHANNEL != MIDI_CHANNEL_NONE
Inject (0x90 | HINT_MIDI_CHANNEL, 64, 127);
LEDs.Update ();
auto Hint = Strip.m_Pixels.at (LedFor (64));
auto Hint = Strip.m_Pixels.at (((LedFor (64)) + LED_COUNT) % LED_COUNT);
Check ("a hint note lights in the hint colour", Hint != Loud && (Hint[0] || Hint[1] || Hint[2]));
// a key actually played wins over a hint on the same key
Inject (0x90, 64, 127);
LEDs.Update ();
auto Both = Strip.m_Pixels.at (LedFor (64));
auto Both = Strip.m_Pixels.at (((LedFor (64)) + LED_COUNT) % LED_COUNT);
Check ("a played note overrides a hint on the same key", Both == Loud);
// releasing the played note falls back to the still-pending hint
Inject (0x80, 64, 0);
LEDs.Update ();
auto Back = Strip.m_Pixels.at (LedFor (64));
auto Back = Strip.m_Pixels.at (((LedFor (64)) + LED_COUNT) % LED_COUNT);
Check ("releasing a played note reveals the hint again", Back == Hint);
#endif
@@ -184,6 +195,135 @@ int main (void)
LEDs.Update ();
Check ("a 1-byte realtime message lights nothing", CountLit () == 0);
// --- every key lands on the strip -----------------------------------
bool bOnStrip = true;
for (unsigned k = 0; k < KEY_COUNT; k++)
{
int nStart = LEDs.GetKeyLED (k);
if (nStart < 0 || nStart + (int) LEDS_PER_KEY > (int) LED_COUNT)
bOnStrip = false;
}
#if LED_OFFSET == 0
Check ("every key maps onto the strip", bOnStrip);
#else
// A non-zero offset deliberately shifts an end key past the strip. The
// property that must hold is that those pixels are clipped, never
// wrapped round to the far end.
(void) bOnStrip;
bool bClipped = true;
for (unsigned k = 0; k < KEY_COUNT; k++)
{
int nStart = LEDs.GetKeyLED (k);
if (OnStrip (nStart))
{
continue;
}
// Count how many of this key's pixels are actually on the strip.
unsigned nExpect = 0;
for (unsigned i = 0; i < LEDS_PER_KEY; i++)
{
int nLED = nStart + (int) i;
if (nLED >= 0 && nLED < (int) LED_COUNT) nExpect++;
}
AllOffAndClear ();
Inject (0x90, (uint8_t) (MIDI_NOTE_MIN + k), 127);
LEDs.Update ();
if (CountLit () != nExpect) bClipped = false;
}
Check ("an offset clips off-strip pixels rather than wrapping", bClipped);
AllOffAndClear ();
#endif
// --- keys are monotonic across the keyboard --------------------------
bool bMonotonic = true;
for (unsigned k = 1; k < KEY_COUNT; k++)
{
#if STRIP_REVERSED
if (LEDs.GetKeyLED (k) > LEDs.GetKeyLED (k - 1)) bMonotonic = false;
#else
if (LEDs.GetKeyLED (k) < LEDs.GetKeyLED (k - 1)) bMonotonic = false;
#endif
}
Check ("key positions advance monotonically", bMonotonic);
#if NOTE_MAP_GEOMETRIC && !STRIP_REVERSED && LED_OFFSET == 0
// --- geometric map tracks real key positions -------------------------
// White keys should sit one white-key pitch apart, ~3.38 LEDs, not 2.
double dPitch = (double) LED_COUNT / WHITE_KEY_COUNT;
double dWorst = 0.0;
int nPrevWhite = -1;
for (unsigned k = 0; k < KEY_COUNT; k++)
{
uint8_t note = (uint8_t) (MIDI_NOTE_MIN + k);
switch (note % 12)
{
case 0: case 2: case 4: case 5: case 7: case 9: case 11:
break;
default:
continue;
}
if (nPrevWhite >= 0)
{
double d = LEDs.GetKeyLED (k) - nPrevWhite;
double e = d - dPitch;
if (e < 0) e = -e;
if (e > dWorst) dWorst = e;
}
nPrevWhite = LEDs.GetKeyLED (k);
}
Check ("white keys sit one white-key pitch apart", dWorst <= 1.0);
#endif
// --- calibration patterns --------------------------------------------
Inject (0xB0, CALIB_CC_PATTERN, CALIB_PATTERN_ENDS);
LEDs.Update ();
Check ("pattern ENDS lights exactly the two end pixels",
CountLit () == 2 && !Dark (0) && !Dark (LED_COUNT - 1));
Inject (0xB0, CALIB_CC_PATTERN, CALIB_PATTERN_ALL);
LEDs.Update ();
Check ("pattern ALL lights the whole strip", CountLit () == LED_COUNT);
bool bAllWithinCeiling = true;
#if GLOBAL_BRIGHTNESS < 255
for (auto &p : Strip.m_Pixels)
for (int c = 0; c < 3; c++)
if (p[c] > GLOBAL_BRIGHTNESS) bAllWithinCeiling = false;
#endif
Check ("pattern ALL still respects the brightness ceiling", bAllWithinCeiling);
Inject (0xB0, CALIB_CC_PATTERN, CALIB_PATTERN_WALK);
Inject (0xB0, CALIB_CC_INDEX_HI, 0);
Inject (0xB0, CALIB_CC_INDEX_LO, 5);
LEDs.Update ();
Check ("pattern WALK lights only the selected pixel",
CountLit () == 1 && !Dark (5));
// a 14-bit index beyond the strip must not paint anything
Inject (0xB0, CALIB_CC_INDEX_HI, 127);
Inject (0xB0, CALIB_CC_INDEX_LO, 127);
LEDs.Update ();
Check ("pattern WALK ignores an out-of-range index", CountLit () == 0);
Inject (0xB0, CALIB_CC_PATTERN, CALIB_PATTERN_OCTAVES);
LEDs.Update ();
Check ("pattern OCTAVES lights something", CountLit () > 0);
// notes held while a pattern runs must not survive it
Inject (0x90, 60, 127);
LEDs.Update ();
Check ("a pattern overrides note display", CountLit () > 0);
Inject (0xB0, CALIB_CC_PATTERN, CALIB_PATTERN_OFF);
LEDs.Update ();
Check ("leaving calibration restores note display",
CountLit () > 0 && !Dark (LedFor (60)));
Inject (0x80, 60, 0);
LEDs.Update ();
printf ("\n%s\n", g_nFail ? "FAILURES" : "all tests passed");
return g_nFail != 0;
}