diff --git a/README.md b/README.md index 40e00fc..30a2cde 100644 --- a/README.md +++ b/README.md @@ -149,12 +149,68 @@ make -C firmware EXTRADEFINE=-DSTRIP_REVERSED=1 # Circle cmake -B pico/build -S pico -DCMAKE_CXX_FLAGS=-DSTRIP_REVERSED=1 # Pico ``` -> **Known limitation:** the note-to-LED mapping is linear in semitone index, -> but a real keybed is not — 52 white keys span the same 1222mm, so one white -> key is ~3.38 LEDs rather than 2. This drifts within each octave, worst at F, -> by up to ~0.87 LEDs (~6mm) even after an optimal offset and scale. A -> geometric map derived from white-key positions would remove it. Not yet -> implemented. +### Note-to-LED mapping + +`NOTE_MAP_GEOMETRIC` (default 1) derives each key's position from white-key +geometry: 52 white keys span the strip, so a white key is `LED_COUNT / 52` +pixels — **~3.38 at 176 LEDs, not 2** — with black keys on the boundaries. + +Setting it to 0 restores the plan's original `(note - 21) * 2`. That map is +linear in semitone index, but a keybed is not: it drifts within each octave, +worst at F, by up to ~0.87 LEDs (~6mm) even after an optimal offset and scale. +Keep it only to reproduce the original behaviour. + +One consequence of the geometric map: adjacent key spans **overlap**, because +the semitone pitch (~1.7 LEDs) is narrower than `LEDS_PER_KEY`. That is +expected, and the renderer paints only lit keys so a neighbour cannot erase +them. + +## Calibration + +This is a headless appliance, so calibration runs over MIDI — the one channel +that already exists. `tools/calibrate.sh` drives it from the PC: + +```sh +tools/calibrate.sh list # find the port +tools/calibrate.sh ends # pixel 0 (red), last pixel (green) +tools/calibrate.sh octaves # every C, middle C in red +tools/calibrate.sh keys # every key: white green, black blue +tools/calibrate.sh walk 37 # one pixel only +tools/calibrate.sh sweep # walk every pixel in turn +tools/calibrate.sh all # every pixel — voltage droop test +tools/calibrate.sh off # back to normal +``` + +A pattern replaces the note display entirely while it is active; `off` +restores it. + +### Procedure + +Work in this order — each step depends on the one before. + +1. **`ends`** — one pixel lights at each end of the strip. If red is at the + treble end, set `STRIP_REVERSED 1` and rebuild. If either end is dark, the + strip is not the length `LED_COUNT` assumes. +2. **`all`** — every pixel white. Watch the far end: if it drifts dim or warm, + the strip needs 5V injected at that end too. This draws roughly + `LED_COUNT × 3 × GLOBAL_BRIGHTNESS/255 × 20mA` — about 4A at the defaults, + inside a 6A supply but well beyond normal play, which caps at + `MAX_LIT_KEYS`. +3. **`keys`** — every key lit, whites and blacks in different colours. Check + the colours line up with the actual keys across the whole span. This is the + fastest way to see a mapping or length error. +4. **`octaves`** — every C, middle C in red. Drift shows up as the marks + walking off the keys as you move up the keyboard. With the geometric map + they should stay put. +5. **`sweep`** or **`walk `** — step one pixel at a time until you find the + pixel sitting over A0. If that is not the pixel the firmware expects, the + difference is your `LED_OFFSET`. Set it and rebuild. +6. **`chromatic`** — plays every key in turn. Watch for the lit span leading + or lagging the key as it climbs. + +Then decide the product questions the firmware cannot: colours *through the +diffuser* (not bare), brightness, and whether velocity should modulate +anything. All of them live in `src/config.h`. ## MIDI behaviour @@ -165,6 +221,8 @@ cmake -B pico/build -S pico -DCMAKE_CXX_FLAGS=-DSTRIP_REVERSED=1 # Pico for the plan's Phase 3 "light the next key to play". A key actually being played takes precedence over a hint on the same key. - Notes held when the USB host suspends are cleared, so nothing stays lit. +- CC 20 selects a calibration pattern; CC 21/22 set the pixel for the walk + pattern. See **Calibration** above. ## Tests diff --git a/src/config.h b/src/config.h index 77ca35a..e54df1a 100644 --- a/src/config.h +++ b/src/config.h @@ -41,6 +41,34 @@ #define STRIP_REVERSED 0 #endif +// Global shift, in pixels, applied after mapping. Absorbs where the strip was +// actually cut and where the profile ended up on the instrument - things the +// geometry cannot know. Positive moves every key towards higher pixel indices. +// Find it with calibration pattern 4 (single-LED walk); see README. +#ifndef LED_OFFSET +#define LED_OFFSET 0 +#endif + +// Note-to-LED mapping. +// +// 1 = geometric. Derives each key's position from white-key geometry: +// 52 white keys span the strip, so one white key is LED_COUNT/52 +// pixels (~3.38 at 176 LEDs) with black keys on the boundaries. +// 0 = linear. The plan's original (note - 21) * LEDS_PER_KEY. +// +// Linear is wrong on a real keybed, because semitones are not evenly spaced: +// it drifts within each octave, worst at F, by up to ~0.87 LEDs (~6mm) even +// after an optimal offset and scale. Geometric removes that. Keep linear only +// to reproduce the original behaviour. +#ifndef NOTE_MAP_GEOMETRIC +#define NOTE_MAP_GEOMETRIC 1 +#endif + +// Number of white keys spanned by the strip. 52 for a standard 88-key keybed. +#ifndef WHITE_KEY_COUNT +#define WHITE_KEY_COUNT 52 +#endif + // -------------------------------------------------------------------------- // Power safety (plan section 7) - NOT optional // -------------------------------------------------------------------------- @@ -130,6 +158,38 @@ #define HINT_MIDI_CHANNEL 15 // channel 16 in a DAW #endif +// -------------------------------------------------------------------------- +// Calibration (plan Phase 0) +// -------------------------------------------------------------------------- +// +// This is a headless appliance with no console, so calibration is driven over +// MIDI - the one channel that already exists. Send these CCs from the PC; see +// tools/calibrate.sh. +// +// Patterns are a diagnostic overlay: while one is active it replaces the note +// display entirely, and pattern 0 restores normal operation. + +// CC selecting the active pattern. +#ifndef CALIB_CC_PATTERN +#define CALIB_CC_PATTERN 20 +#endif + +// CCs setting the pixel index for CALIB_PATTERN_WALK, as a 14-bit value: +// index = (CC21 << 7) | CC22. +#ifndef CALIB_CC_INDEX_HI +#define CALIB_CC_INDEX_HI 21 +#endif +#ifndef CALIB_CC_INDEX_LO +#define CALIB_CC_INDEX_LO 22 +#endif + +#define CALIB_PATTERN_OFF 0 // normal operation +#define CALIB_PATTERN_ENDS 1 // first and last pixel only +#define CALIB_PATTERN_OCTAVES 2 // every C, to expose mapping drift +#define CALIB_PATTERN_KEYS 3 // every key, alternating colour +#define CALIB_PATTERN_WALK 4 // one pixel, chosen by CC21/CC22 +#define CALIB_PATTERN_ALL 5 // every pixel, for voltage droop testing + // -------------------------------------------------------------------------- // Hardware wiring - platform specific // -------------------------------------------------------------------------- diff --git a/src/pianoleds.cpp b/src/pianoleds.cpp index 74954e6..6ca125d 100644 --- a/src/pianoleds.cpp +++ b/src/pianoleds.cpp @@ -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 (); diff --git a/src/pianoleds.h b/src/pianoleds.h index b839632..3a5422b 100644 --- a/src/pianoleds.h +++ b/src/pianoleds.h @@ -35,9 +35,28 @@ public: // (re)connection, so notes held at disconnect do not stay lit. void AllOff (void); + // Currently active calibration pattern, CALIB_PATTERN_OFF when running + // normally. Exposed for tests. + unsigned GetCalibrationPattern (void) const { return m_nCalibPattern; } + + // First pixel of a key's span, after mapping, offset and orientation. + // KEY_COUNT entries, valid once Initialize() has run. Exposed for tests. + int GetKeyLED (unsigned nKey) const { return m_KeyLED[nKey]; } + private: void SetKey (uint8_t ucNote, uint8_t ucVelocity, bool bHint); + // Fill m_KeyLED from the configured mapping. + void BuildKeyMap (void); + + void RenderNotes (void); + void RenderCalibration (void); + + // Light one key's span, clamping to the strip. + void PaintKey (unsigned nKey, uint8_t nRed, uint8_t nGreen, uint8_t nBlue); + + void PaintPixel (int nLED, uint8_t nRed, uint8_t nGreen, uint8_t nBlue); + // Scale a colour channel by velocity and the global brightness ceiling. static uint8_t Scale (uint8_t ucChannel, uint8_t ucVelocity); @@ -52,6 +71,15 @@ private: volatile uint8_t m_HintVelocity[KEY_COUNT]; volatile bool m_bDirty; + + // Calibration overlay, set from the MIDI callback. + volatile unsigned m_nCalibPattern; + volatile unsigned m_nCalibIndex; + volatile unsigned m_nCalibIndexHi; + + // Start pixel of each key's span. Signed, because a negative + // LED_OFFSET can push low keys off the end of the strip. + int m_KeyLED[KEY_COUNT]; }; #endif diff --git a/tests/run.sh b/tests/run.sh index 36dfeae..8b1ec5e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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#*:} diff --git a/tests/test_pianoleds.cpp b/tests/test_pianoleds.cpp index 72e474a..afe207f 100644 --- a/tests/test_pianoleds.cpp +++ b/tests/test_pianoleds.cpp @@ -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; } diff --git a/tools/calibrate.sh b/tools/calibrate.sh new file mode 100755 index 0000000..91e60c8 --- /dev/null +++ b/tools/calibrate.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# +# Drives the firmware's calibration patterns from the PC over ALSA MIDI. +# See "Calibration" in README.md for the procedure. +# +# Usage: +# tools/calibrate.sh list list MIDI ports +# tools/calibrate.sh ends pixel 0 (red) and last pixel (green) +# tools/calibrate.sh octaves every C, middle C in red +# tools/calibrate.sh keys every key, white green / black blue +# tools/calibrate.sh walk light pixel n only +# tools/calibrate.sh sweep [ms] walk every pixel in turn +# tools/calibrate.sh all every pixel, for voltage droop +# tools/calibrate.sh off back to normal operation +# tools/calibrate.sh note [vel] play one MIDI note +# tools/calibrate.sh chromatic [ms] play every key, low to high +# +# Set PORT to the device's ALSA port (e.g. PORT=24:0). If unset, the script +# picks the first port whose name matches PORT_MATCH. +# +set -e + +PORT_MATCH=${PORT_MATCH:-Piano LED} + +CC_PATTERN=20 +CC_INDEX_HI=21 +CC_INDEX_LO=22 + +PAT_OFF=0; PAT_ENDS=1; PAT_OCTAVES=2; PAT_KEYS=3; PAT_WALK=4; PAT_ALL=5 + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "error: $1 not found (install alsa-utils)" >&2; exit 1; } +} + +find_port() { + if [ -n "$PORT" ]; then echo "$PORT"; return; fi + local p + p=$(aconnect -l | awk -v m="$PORT_MATCH" ' + /^client /{ cl=$2; sub(":","",cl); name=$0 } + /^ +[0-9]+ / { if (name ~ m) { port=$1; print cl ":" port; exit } }') + if [ -z "$p" ]; then + echo "error: no MIDI port matching \"$PORT_MATCH\"." >&2 + echo " Run '$0 list', then set PORT=client:port" >&2 + exit 1 + fi + echo "$p" +} + +# Send raw MIDI bytes to the port. +send() { + need amidi + local hex="$*" + amidi -p "$(alsa_rawmidi_port)" -S "$hex" 2>/dev/null && return 0 + # amidi needs a rawmidi device; fall back to the sequencer via aplaymidi + echo "error: could not send. Set PORT and ensure the device is connected." >&2 + exit 1 +} + +# amidi addresses rawmidi (hw:X,Y), not sequencer ports. +alsa_rawmidi_port() { + if [ -n "$RAWMIDI" ]; then echo "$RAWMIDI"; return; fi + need amidi + local p + p=$(amidi -l | awk -v m="$PORT_MATCH" '$0 ~ m { print $2; exit }') + if [ -z "$p" ]; then + echo "error: no rawmidi device matching \"$PORT_MATCH\"." >&2 + echo " Run 'amidi -l', then set RAWMIDI=hw:X,Y" >&2 + exit 1 + fi + echo "$p" +} + +cc() { printf 'B0 %02X %02X' "$1" "$2"; } + +pattern() { send "$(cc $CC_PATTERN $1)"; } + +walk() { + local n=$1 + send "$(cc $CC_PATTERN $PAT_WALK) $(cc $CC_INDEX_HI $((n >> 7))) $(cc $CC_INDEX_LO $((n & 127)))" +} + +case "${1:-}" in +list) + echo "--- sequencer ports (aconnect -l) ---"; aconnect -l || true + echo; echo "--- rawmidi devices (amidi -l) ---"; amidi -l || true + ;; +ends) pattern $PAT_ENDS; echo "pattern: ends" ;; +octaves) pattern $PAT_OCTAVES; echo "pattern: octaves" ;; +keys) pattern $PAT_KEYS; echo "pattern: keys" ;; +all) pattern $PAT_ALL; echo "pattern: all (watch the far end for warm colour)" ;; +off) pattern $PAT_OFF; echo "pattern: off" ;; +walk) + [ -n "${2:-}" ] || { echo "usage: $0 walk " >&2; exit 1; } + walk "$2"; echo "pixel $2" + ;; +sweep) + MS=${2:-120} + COUNT=${LED_COUNT:-176} + echo "sweeping 0..$((COUNT-1)); Ctrl-C to stop" + for ((i=0; i [velocity]" >&2; exit 1; } + V=${3:-100} + send "$(printf '90 %02X %02X' "$2" "$V")" + echo "note $2 on (velocity $V); '$0 off' or send note-off to clear" + ;; +chromatic) + MS=${2:-150} + echo "playing notes 21..108; Ctrl-C to stop" + for ((n=21; n<=108; n++)); do + send "$(printf '90 %02X 64' "$n")" + printf '\rnote %3d' "$n" + sleep "$(echo "$MS/1000" | bc -l)" + send "$(printf '80 %02X 00' "$n")" + done + echo + ;; +*) + sed -n "2,/^# Set PORT/p" "$0" | sed 's/^# \{0,1\}//' + exit 1 + ;; +esac