Port to RP2040/RP2350, extract the shared logic

Raspberry Pi supply is unreliable, and Circle is Broadcom-only - there is no
Allwinner or Rockchip support anywhere in its tree, so an Orange Pi is not a
board swap but a restart on an unproven base. RP2040/RP2350 is the better
answer: available, ~$4, and a better fit for this job than the Zero ever was.

Structure. All the visualizer logic moves to src/ and is now
platform-independent, depending only on ILEDStrip (four methods) with MIDI
pushed in via OnMIDIPacket(). Each platform supplies a backend and a main
loop. The Circle build is unchanged in behaviour and still produces both
kernel images.

Pico backend:
- WS2812B from a PIO state machine, which clocks the 1.25us bit cell directly
  rather than faking it with 8 SPI bytes per data bit as the Circle build must.
- TinyUSB MIDI 1.0 device. Enumerates as an ordinary ALSA port, as the Circle
  gadget does. Packet framing comes from the USB MIDI Code Index Number rather
  than being re-derived.
- Mount, unmount, suspend and resume all clear held notes, so a chord held when
  the host goes away cannot stay lit.
- Latch spacing is enforced against a timestamp, so a caller cannot start a
  frame inside the WS2812B reset window.

Verified: builds clean for both pico (RP2040, 30052 bytes) and pico2 (RP2350,
28284 bytes), no warnings from project sources, and the Circle build still
produces kernel.img and kernel7.img. Tests pass across nine configurations.

Incidental findings. PIO frees both hardware SPI blocks; on a Pi Zero Circle
exposes only one SPI master (DEVICES=1 for RASPPI<4) and the WS2812B driver
monopolises it, so a display and the strip could not coexist there. RP2040/
RP2350 also support USB host and, on the W variants, BLE via btstack - both
of which section 3a records as impossible on Circle.

Also documents a known limitation found while looking at calibration: the
note-to-LED map is linear in semitone index, but a keybed is not. 52 white
keys span the same 1222mm, making one white key ~3.38 LEDs rather than 2. The
error drifts within each octave, worst at F, by up to ~0.87 LEDs (~6mm) even
after an optimal offset and scale. A geometric map would remove it. Not yet
implemented.

Claude-Session: https://claude.ai/code/session_01TVCB25LBsmeteWvaSMz4Ne
This commit is contained in:
prosolis
2026-08-27 22:53:00 -07:00
parent 138efc28ca
commit 469b321a40
27 changed files with 1046 additions and 267 deletions
+3
View File
@@ -10,3 +10,6 @@ boot/
# Circle build configuration, generated by ./build.sh # Circle build configuration, generated by ./build.sh
circle/Config.mk circle/Config.mk
# Pico build tree
pico/build/
+75 -29
View File
@@ -1,7 +1,16 @@
# Piano LED Visualizer — Circle firmware # Piano LED Visualizer — firmware
Bare-metal firmware for a Raspberry Pi Zero that lights a WS2812B strip above Bare-metal firmware that lights a WS2812B strip above an 88-key keybed in
an 88-key keybed in response to MIDI. response to MIDI. Two platforms, one shared implementation:
| Platform | Board | USB MIDI | Strip |
|---|---|---|---|
| **Pico** | RP2040 / RP2350 | TinyUSB device | PIO |
| **Circle** | Pi Zero / Zero 2 | `CUSBMIDIGadget` | SPI |
All the visualizer logic lives in `src/` and is shared verbatim. Each platform
supplies two small things: an `ILEDStrip` implementation, and a main loop that
feeds MIDI packets to `CPianoLEDs::OnMIDIPacket()`.
The design rationale, bill of materials, electrical notes and project phases The design rationale, bill of materials, electrical notes and project phases
live in [PIANO-LED-CIRCLE-PLAN.md](PIANO-LED-CIRCLE-PLAN.md). This file covers live in [PIANO-LED-CIRCLE-PLAN.md](PIANO-LED-CIRCLE-PLAN.md). This file covers
@@ -9,32 +18,53 @@ only how to build and run the firmware (plan Phase 1).
## What this is ## What this is
The Pi is a **USB MIDI gadget**. The PC is the host and owns everything else — The board is a **USB MIDI device**. The PC is the host and owns everything
the piano connection, the learning software, the song library. From the PC's else — the piano connection, the learning software, the song library. From the
side this firmware is just another ALSA MIDI output port: PC's side this firmware is just another ALSA MIDI output port:
``` ```
Casio PX-S7000 --USB-B--> PC --USB--> Pi Zero (this firmware) --> WS2812B strip Casio PX-S7000 --USB-B--> PC --USB--> board (this firmware) --> WS2812B strip
``` ```
There is no network stack, no filesystem, no shell. The Pi boots into this There is no network stack, no shell, and nothing writable at runtime. The
firmware in about a second and does one job. board boots into this firmware in about a second and does one job.
Circle has no OTG support, so the USB controller is gadget-only here. The On the Circle build the piano **cannot** be plugged into the board directly:
piano **cannot** be plugged into the Pi directly; all MIDI arrives from the PC. Circle has no OTG support, so its USB controller is gadget-only and all MIDI
must arrive from the PC. RP2040/RP2350 can do USB host, so that restriction is
a Circle property rather than an architectural one — but nothing here uses it.
## Layout ## Layout
| Path | | | Path | |
|---|---| |---|---|
| `firmware/config.h` | Every tunable. Start here. | | `src/config.h` | Every tunable. Start here. |
| `firmware/pianoleds.cpp` | Note-to-LED mapping, colour, brightness clamps. | | `src/pianoleds.cpp` | Note-to-LED mapping, colour, brightness clamps. Platform-independent. |
| `firmware/kernel.cpp` | USB gadget lifecycle and the main loop. | | `src/ledstrip.h` | `ILEDStrip` — the entire hardware surface the logic depends on. |
| `tests/` | Host-side tests for the mapping and clamps. | | `pico/` | RP2040 / RP2350 backend: TinyUSB MIDI + PIO WS2812. |
| `firmware/` | Circle backend: `CUSBMIDIGadget` + `CWS28XXStripe`. |
| `tests/` | Host-side tests. No toolchain or SDK needed. |
| `circle/` | Circle as a submodule, pinned to `Step51`. | | `circle/` | Circle as a submodule, pinned to `Step51`. |
| `build.sh` | Builds both kernel images. |
## Building ## Building — Pico (RP2040 / RP2350)
```sh
sudo apt-get install gcc-arm-none-eabi cmake
git clone --recursive https://github.com/raspberrypi/pico-sdk # if you lack one
PICO_SDK_PATH=/path/to/pico-sdk ./pico/build.sh
```
Produces `pico/build/pianoled.uf2`. Hold BOOTSEL while plugging the board in
and copy the `.uf2` onto the drive that appears.
`BOARD` selects the target (`pico`, `pico2`, `pico_w`, `pico2_w`; default
`pico2`). All four run this firmware unchanged — the radio variants simply
leave their radio unused.
The strip data pin is `WS2812_PIN` in `src/config.h`, default GPIO2. PIO can
drive it from any GPIO, so this is a free choice.
## Building — Circle (Raspberry Pi Zero)
Circle is a submodule pinned to `Step51`, so clone recursively: Circle is a submodule pinned to `Step51`, so clone recursively:
@@ -63,7 +93,9 @@ This produces two images in `boot/`, which coexist on one card:
The Pi picks the right one at boot, so the same SD card runs on either model. The Pi picks the right one at boot, so the same SD card runs on either model.
## SD card ## SD card (Circle only)
The Pico boots from internal flash and needs none of this.
FAT32, single partition. Copy in: FAT32, single partition. Copy in:
@@ -78,11 +110,14 @@ cannot corrupt it.
## Wiring ## Wiring
Verified against `circle/addon/WS28XX`: `CWS28XXStripe` clocks the WS2812B **On Pico**, the WS2812B waveform comes from a PIO state machine, so the data
waveform out over SPI at a fixed 6.4 MHz, encoding one LED bit per SPI byte. line is any GPIO you like — `WS2812_PIN` in `src/config.h`, default GPIO2.
On SPI master device 0 that puts the data line on: Both hardware SPI blocks stay free.
**MOSI = GPIO10 (BCM) = physical pin 19.** **On Circle**, verified against `circle/addon/WS28XX`: `CWS28XXStripe` clocks
the waveform out over SPI at a fixed 6.4 MHz, encoding one LED bit per SPI
byte. On SPI master device 0 that fixes the data line at **MOSI = GPIO10 (BCM)
= physical pin 19**, and it occupies the only SPI master a Pi Zero exposes.
Three things from plan section 7 that are not optional: Three things from plan section 7 that are not optional:
@@ -92,7 +127,8 @@ Three things from plan section 7 that are not optional:
- **Common ground.** The Pi's ground and the LED supply's ground must be tied. - **Common ground.** The Pi's ground and the LED supply's ground must be tied.
- **Power injection.** Feed 5V at both ends of the strip. - **Power injection.** Feed 5V at both ends of the strip.
Connect the PC to the Zero's **USB** port, not **PWR**, with a data cable. On a Pi Zero, connect the PC to the **USB** port, not **PWR**, with a data
cable. On a Pico, the single USB connector is the one.
## Configuring ## Configuring
@@ -109,9 +145,17 @@ question. Two are load-bearing:
Any of them can also be overridden at build time without editing the file: Any of them can also be overridden at build time without editing the file:
```sh ```sh
make -C firmware EXTRADEFINE=-DSTRIP_REVERSED=1 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.
## MIDI behaviour ## MIDI behaviour
- Notes 21108 (A0C8) map to the strip; anything outside is dropped. - Notes 21108 (A0C8) map to the strip; anything outside is dropped.
@@ -128,8 +172,10 @@ make -C firmware EXTRADEFINE=-DSTRIP_REVERSED=1
./tests/run.sh ./tests/run.sh
``` ```
Compiles the real `firmware/pianoleds.cpp` against stubbed Circle headers and Compiles the real `src/pianoleds.cpp` against a capture backend that records
exercises the mapping, the note-off paths, the range clamping and both power pixels in memory, and exercises the mapping, the note-off paths, the range
clamps across nine configuration variants. This does not need the ARM clamping and both power clamps across nine configuration variants.
toolchain and does not replace bench-testing on real hardware — it checks the
arithmetic, not the wiring. Because the logic depends only on `ILEDStrip`, this needs no ARM toolchain, no
Circle, and no pico-sdk — just `g++`. It checks the arithmetic, not the
wiring, and does not replace bench-testing on real hardware.
+4
View File
@@ -6,6 +6,10 @@ CIRCLEHOME = ../circle
OBJS = main.o kernel.o pianoleds.o OBJS = main.o kernel.o pianoleds.o
# Portable visualizer logic, shared with the Pico build.
VPATH = ../src
EXTRAINCLUDE += -I ../src
LIBS = $(CIRCLEHOME)/addon/WS28XX/libws28xx.a \ LIBS = $(CIRCLEHOME)/addon/WS28XX/libws28xx.a \
$(CIRCLEHOME)/lib/usb/gadget/libusbgadget.a \ $(CIRCLEHOME)/lib/usb/gadget/libusbgadget.a \
$(CIRCLEHOME)/lib/usb/libusb.a \ $(CIRCLEHOME)/lib/usb/libusb.a \
+38
View File
@@ -0,0 +1,38 @@
//
// circlestrip.h
//
// ILEDStrip backed by Circle's CWS28XXStripe (WS2812B over SPI).
//
#ifndef _circlestrip_h
#define _circlestrip_h
#include <WS28XX/ws28xxstripe.h>
#include "ledstrip.h"
#include "config.h"
class CCircleLEDStrip : public ILEDStrip
{
public:
CCircleLEDStrip (void)
: m_Stripe (WS2812B, LED_COUNT, 4000000, SPI_MASTER_DEVICE)
{
}
bool Initialize (void) override { return m_Stripe.Initialize (); }
unsigned GetLEDCount (void) const override { return m_Stripe.GetLEDCount (); }
void SetLED (unsigned nIndex, uint8_t nRed, uint8_t nGreen, uint8_t nBlue) override
{
m_Stripe.SetLED (nIndex, nRed, nGreen, nBlue);
}
bool Update (void) override { return m_Stripe.Update (); }
bool Blackout (void) override { return m_Stripe.Blackout (); }
private:
CWS28XXStripe m_Stripe;
};
#endif
+16 -2
View File
@@ -16,11 +16,21 @@
static const char FromKernel[] = "kernel"; static const char FromKernel[] = "kernel";
void CKernel::MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam)
{
CKernel *pThis = static_cast<CKernel *> (pParam);
assert (pThis != 0);
pThis->m_PianoLEDs.OnMIDIPacket (pPacket, nLength);
}
CKernel::CKernel (void) CKernel::CKernel (void)
: m_Timer (&m_Interrupt), : m_Timer (&m_Interrupt),
m_Logger (m_Options.GetLogLevel (), &m_Timer), m_Logger (m_Options.GetLogLevel (), &m_Timer),
m_pUSB (new CUSBMIDIGadget (&m_Interrupt)), m_pUSB (new CUSBMIDIGadget (&m_Interrupt)),
m_pMIDIDevice (0) m_pMIDIDevice (0),
m_PianoLEDs (m_LEDStrip)
{ {
m_ActLED.Blink (5); // show we are alive m_ActLED.Blink (5); // show we are alive
} }
@@ -95,7 +105,11 @@ void CKernel::UpdateMIDIDevice (void)
if (m_pMIDIDevice != 0) if (m_pMIDIDevice != 0)
{ {
m_PianoLEDs.AttachMIDIDevice (m_pMIDIDevice); // The gadget builds a new device object on each enumeration, so
// clear anything held at the moment the host went away.
m_PianoLEDs.AllOff ();
m_pMIDIDevice->RegisterPacketHandler (MIDIPacketHandler, this);
m_Logger.Write (FromKernel, LogNotice, "USB MIDI gadget connected"); m_Logger.Write (FromKernel, LogNotice, "USB MIDI gadget connected");
} }
+5
View File
@@ -16,6 +16,7 @@
#include <circle/usb/usbcontroller.h> #include <circle/usb/usbcontroller.h>
#include <circle/usb/usbmidi.h> #include <circle/usb/usbmidi.h>
#include "pianoleds.h" #include "pianoleds.h"
#include "circlestrip.h"
enum TShutdownMode enum TShutdownMode
{ {
@@ -39,6 +40,9 @@ private:
// after every re-enumeration. // after every re-enumeration.
void UpdateMIDIDevice (void); void UpdateMIDIDevice (void);
static void MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam);
private: private:
// do not change this order // do not change this order
CActLED m_ActLED; CActLED m_ActLED;
@@ -53,6 +57,7 @@ private:
CUSBController *m_pUSB; CUSBController *m_pUSB;
CUSBMIDIDevice *m_pMIDIDevice; CUSBMIDIDevice *m_pMIDIDevice;
CCircleLEDStrip m_LEDStrip;
CPianoLEDs m_PianoLEDs; CPianoLEDs m_PianoLEDs;
}; };
-60
View File
@@ -1,60 +0,0 @@
//
// pianoleds.h
//
// Maps incoming MIDI note events onto a WS2812B strip mounted above an
// 88-key keybed.
//
#ifndef _pianoleds_h
#define _pianoleds_h
#include <circle/usb/usbmidi.h>
#include <circle/types.h>
#include <WS28XX/ws28xxstripe.h>
#include "config.h"
class CPianoLEDs
{
public:
CPianoLEDs (void);
~CPianoLEDs (void);
boolean Initialize (void);
// Attach to a USB MIDI device. Safe to call again after the gadget has
// been re-enumerated, which destroys and recreates the device object.
void AttachMIDIDevice (CUSBMIDIDevice *pMIDIDevice);
// Push pending state to the strip. Call from the main loop only; this
// blocks for ~5.3ms of SPI traffic and must never run in IRQ context.
// Does nothing when no state has changed since the last call.
void Update (void);
// Extinguish every pixel and forget all held notes.
void AllOff (void);
private:
// Called in IRQ context by the USB MIDI driver.
static void MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam);
void OnMIDIPacket (const u8 *pPacket, unsigned nLength);
void SetKey (u8 ucNote, u8 ucVelocity, boolean bHint);
// Scale a colour channel by velocity and the global brightness ceiling.
static u8 Scale (u8 ucChannel, u8 ucVelocity);
static boolean ChannelMatches (u8 ucChannel, u8 ucWanted);
private:
CWS28XXStripe m_Stripe;
// Written in IRQ context, read by Update(). Index is
// note - MIDI_NOTE_MIN. Zero means the key is not lit.
volatile u8 m_KeyVelocity[KEY_COUNT];
volatile u8 m_HintVelocity[KEY_COUNT];
volatile boolean m_bDirty;
};
#endif
+43
View File
@@ -0,0 +1,43 @@
cmake_minimum_required(VERSION 3.13)
# Board: pico, pico2, pico_w, pico2_w. All run this firmware unchanged; the
# radio variants simply leave their radio unused.
set(PICO_BOARD pico2 CACHE STRING "Target board")
include(pico_sdk_import.cmake)
project(pianoled C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()
add_executable(pianoled
main.cpp
picostrip.cpp
../src/pianoleds.cpp
usb_descriptors.c
)
# PLATFORM_PICO selects the Pico wiring block in config.h.
target_compile_definitions(pianoled PRIVATE PLATFORM_PICO)
target_include_directories(pianoled PRIVATE
${CMAKE_CURRENT_LIST_DIR}
${CMAKE_CURRENT_LIST_DIR}/../src
)
pico_generate_pio_header(pianoled ${CMAKE_CURRENT_LIST_DIR}/ws2812.pio)
target_link_libraries(pianoled
pico_stdlib
hardware_pio
tinyusb_device
tinyusb_board
)
# Debug log on UART only. USB is the MIDI device and must not be shared.
pico_enable_stdio_uart(pianoled 1)
pico_enable_stdio_usb(pianoled 0)
pico_add_extra_outputs(pianoled)
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
#
# Builds the Piano LED Visualizer firmware for RP2040 / RP2350.
#
# Produces pico/build/pianoled.uf2. Hold BOOTSEL while plugging the board in
# and copy the .uf2 onto the mass-storage device that appears.
#
set -e
cd "$(dirname "$0")"
# Board: pico, pico2, pico_w, pico2_w. All run this firmware unchanged; the
# radio variants simply leave their radio unused.
BOARD=${BOARD:-pico2}
export PICO_SDK_PATH=${PICO_SDK_PATH:-$HOME/git/pico-sdk}
if [ ! -f "$PICO_SDK_PATH/pico_sdk_init.cmake" ]; then
echo "error: pico-sdk not found at $PICO_SDK_PATH" >&2
echo " git clone --recursive https://github.com/raspberrypi/pico-sdk" >&2
echo " or set PICO_SDK_PATH" >&2
exit 1
fi
if ! command -v arm-none-eabi-gcc >/dev/null 2>&1; then
echo "error: arm-none-eabi-gcc not found." >&2
echo " Debian/Ubuntu: sudo apt-get install gcc-arm-none-eabi" >&2
exit 1
fi
cmake -B build -S . -DPICO_BOARD="$BOARD"
cmake --build build -j "$(nproc)"
echo
echo "Built for $BOARD:"
ls -l build/pianoled.uf2
arm-none-eabi-size build/pianoled.elf
+110
View File
@@ -0,0 +1,110 @@
//
// main.cpp
//
// Piano LED Visualizer on RP2040 / RP2350.
//
// The board is a USB MIDI device: the PC is the host and owns the piano
// connection and everything else. From the PC's side this enumerates as an
// ordinary ALSA MIDI port. See PIANO-LED-CIRCLE-PLAN.md section 2.
//
#include "pianoleds.h"
#include "picostrip.h"
#include "pico/stdlib.h"
#include "tusb.h"
static CPicoLEDStrip s_Strip (LED_COUNT, WS2812_PIN);
static CPianoLEDs s_PianoLEDs (s_Strip);
// Number of MIDI bytes carried by a USB MIDI event packet, indexed by its
// Code Index Number (USB MIDI 1.0, table 4-1). 0 means "not a message we
// forward".
static const uint8_t s_CINLength[16] =
{
0, 0, 2, 3, 3, 1, 2, 3,
3, 3, 3, 3, 2, 2, 3, 1
};
// --------------------------------------------------------------------------
// TinyUSB device callbacks
// --------------------------------------------------------------------------
extern "C" void tud_mount_cb (void)
{
// Fresh session: nothing should still be lit from the last one.
s_PianoLEDs.AllOff ();
}
extern "C" void tud_umount_cb (void)
{
// Host went away, possibly mid-chord. Do not leave keys lit.
s_PianoLEDs.AllOff ();
}
extern "C" void tud_suspend_cb (bool remote_wakeup_en)
{
(void) remote_wakeup_en;
s_PianoLEDs.AllOff ();
}
extern "C" void tud_resume_cb (void)
{
s_PianoLEDs.AllOff ();
}
// --------------------------------------------------------------------------
static void PollMIDI (void)
{
uint8_t Packet[4];
while (tud_midi_available ())
{
if (!tud_midi_packet_read (Packet))
{
break;
}
// Packet[0] is cable number (high nibble) and Code Index Number
// (low nibble); the message itself is in Packet[1..3].
unsigned nLength = s_CINLength[Packet[0] & 0x0F];
if (nLength == 0)
{
continue;
}
s_PianoLEDs.OnMIDIPacket (&Packet[1], nLength);
}
}
int main (void)
{
stdio_init_all ();
if (!s_Strip.Initialize ())
{
// Nothing sensible left to do; make the failure visible rather
// than sitting dark and looking like a power problem.
while (true)
{
tight_loop_contents ();
}
}
s_PianoLEDs.Initialize ();
tusb_init ();
while (true)
{
tud_task ();
PollMIDI ();
// Rendering blocks for the strip's frame time, so it runs here
// rather than in the USB callback. Returns immediately when
// nothing has changed.
s_PianoLEDs.Update ();
}
return 0;
}
+121
View File
@@ -0,0 +1,121 @@
# This is a copy of <PICO_SDK_PATH>/external/pico_sdk_import.cmake
# This can be dropped into an external project to help locate this SDK
# It should be include()ed prior to project()
# Copyright 2020 (c) 2020 Raspberry Pi (Trading) Ltd.
#
# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
# following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
# disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
message("Using PICO_SDK_PATH from environment ('${PICO_SDK_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
message("Using PICO_SDK_FETCH_FROM_GIT from environment ('${PICO_SDK_FETCH_FROM_GIT}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
message("Using PICO_SDK_FETCH_FROM_GIT_PATH from environment ('${PICO_SDK_FETCH_FROM_GIT_PATH}')")
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_TAG} AND (NOT PICO_SDK_FETCH_FROM_GIT_TAG))
set(PICO_SDK_FETCH_FROM_GIT_TAG $ENV{PICO_SDK_FETCH_FROM_GIT_TAG})
message("Using PICO_SDK_FETCH_FROM_GIT_TAG from environment ('${PICO_SDK_FETCH_FROM_GIT_TAG}')")
endif ()
if (PICO_SDK_FETCH_FROM_GIT AND NOT PICO_SDK_FETCH_FROM_GIT_TAG)
set(PICO_SDK_FETCH_FROM_GIT_TAG "master")
message("Using master as default value for PICO_SDK_FETCH_FROM_GIT_TAG")
endif()
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch copy of SDK from git if not otherwise locatable")
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "location to download SDK")
set(PICO_SDK_FETCH_FROM_GIT_TAG "${PICO_SDK_FETCH_FROM_GIT_TAG}" CACHE FILEPATH "release tag for SDK")
if (NOT PICO_SDK_PATH)
if (PICO_SDK_FETCH_FROM_GIT)
include(FetchContent)
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
if (PICO_SDK_FETCH_FROM_GIT_PATH)
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH BASE_DIR "${CMAKE_SOURCE_DIR}")
endif ()
FetchContent_Declare(
pico_sdk
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
)
if (NOT pico_sdk)
message("Downloading Raspberry Pi Pico SDK")
# GIT_SUBMODULES_RECURSE was added in 3.17
if (${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.17.0")
FetchContent_Populate(
pico_sdk
QUIET
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
GIT_SUBMODULES_RECURSE FALSE
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
)
else ()
FetchContent_Populate(
pico_sdk
QUIET
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG ${PICO_SDK_FETCH_FROM_GIT_TAG}
SOURCE_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-src
BINARY_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-build
SUBBUILD_DIR ${FETCHCONTENT_BASE_DIR}/pico_sdk-subbuild
)
endif ()
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
endif ()
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
else ()
message(FATAL_ERROR
"SDK location was not specified. Please set PICO_SDK_PATH or set PICO_SDK_FETCH_FROM_GIT to on to fetch from git."
)
endif ()
endif ()
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
if (NOT EXISTS ${PICO_SDK_PATH})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
endif ()
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' does not appear to contain the Raspberry Pi Pico SDK")
endif ()
set(PICO_SDK_PATH ${PICO_SDK_PATH} CACHE PATH "Path to the Raspberry Pi Pico SDK" FORCE)
include(${PICO_SDK_INIT_CMAKE_FILE})
+108
View File
@@ -0,0 +1,108 @@
//
// picostrip.cpp
//
#include "picostrip.h"
#include "ws2812.pio.h"
#include "pico/stdlib.h"
#include <stdlib.h>
#include <string.h>
// WS2812B bit cell is 1.25us; the PIO program spends 10 cycles per bit.
static const float WS2812_FREQ = 800000.0f;
// Datasheet reset is >50us of low. 300us is the commonly used safe value and
// costs nothing at this frame rate.
static const uint64_t WS2812_RESET_US = 300;
CPicoLEDStrip::CPicoLEDStrip (unsigned nLEDCount, unsigned nPin)
: m_nLEDCount (nLEDCount),
m_nPin (nPin),
m_pBuffer (nullptr),
m_PIO (nullptr),
m_nSM (0),
m_nOffset (0),
m_bInitialized (false),
m_nLastFrameUs (0)
{
}
CPicoLEDStrip::~CPicoLEDStrip (void)
{
free (m_pBuffer);
m_pBuffer = nullptr;
}
bool CPicoLEDStrip::Initialize (void)
{
m_pBuffer = (uint32_t *) calloc (m_nLEDCount, sizeof (uint32_t));
if (m_pBuffer == nullptr)
{
return false;
}
// Let the SDK place the program on whichever PIO block has room, so
// this cannot collide with anything else added later.
if (!pio_claim_free_sm_and_add_program_for_gpio_range (
&ws2812_program, &m_PIO, &m_nSM, &m_nOffset, m_nPin, 1, true))
{
return false;
}
ws2812_program_init (m_PIO, m_nSM, m_nOffset, m_nPin, WS2812_FREQ);
m_bInitialized = true;
m_nLastFrameUs = time_us_64 ();
return true;
}
void CPicoLEDStrip::SetLED (unsigned nIndex, uint8_t nRed, uint8_t nGreen, uint8_t nBlue)
{
if (nIndex >= m_nLEDCount)
{
return;
}
// WS2812B wants GRB. The PIO shifts out MSB first with a 24-bit
// threshold, so the triple is left-justified in the word.
m_pBuffer[nIndex] = ((uint32_t) nGreen << 24)
| ((uint32_t) nRed << 16)
| ((uint32_t) nBlue << 8);
}
void CPicoLEDStrip::WaitForLatch (void)
{
uint64_t nElapsed = time_us_64 () - m_nLastFrameUs;
if (nElapsed < WS2812_RESET_US)
{
busy_wait_us (WS2812_RESET_US - nElapsed);
}
}
bool CPicoLEDStrip::Update (void)
{
if (!m_bInitialized)
{
return false;
}
WaitForLatch ();
for (unsigned i = 0; i < m_nLEDCount; i++)
{
// Blocking push. The FIFO drains at 800kbit/s, so this paces the
// loop naturally and needs no DMA at this strip length.
pio_sm_put_blocking (m_PIO, m_nSM, m_pBuffer[i]);
}
m_nLastFrameUs = time_us_64 ();
return true;
}
bool CPicoLEDStrip::Blackout (void)
{
memset (m_pBuffer, 0, m_nLEDCount * sizeof (uint32_t));
return Update ();
}
+46
View File
@@ -0,0 +1,46 @@
//
// picostrip.h
//
// ILEDStrip on RP2040 / RP2350, driving WS2812B from a PIO state machine.
//
#ifndef _picostrip_h
#define _picostrip_h
#include "ledstrip.h"
#include "hardware/pio.h"
class CPicoLEDStrip : public ILEDStrip
{
public:
CPicoLEDStrip (unsigned nLEDCount, unsigned nPin);
~CPicoLEDStrip (void);
bool Initialize (void) override;
unsigned GetLEDCount (void) const override { return m_nLEDCount; }
void SetLED (unsigned nIndex, uint8_t nRed, uint8_t nGreen, uint8_t nBlue) override;
bool Update (void) override;
bool Blackout (void) override;
private:
// Blocks until the strip has latched, so a caller cannot start a new
// frame inside the reset window.
void WaitForLatch (void);
private:
unsigned m_nLEDCount;
unsigned m_nPin;
uint32_t *m_pBuffer; // one GRB word per LED, left-justified
PIO m_PIO;
unsigned m_nSM;
unsigned m_nOffset;
bool m_bInitialized;
uint64_t m_nLastFrameUs;
};
#endif
+33
View File
@@ -0,0 +1,33 @@
//
// tusb_config.h
//
#ifndef _TUSB_CONFIG_H_
#define _TUSB_CONFIG_H_
#ifndef CFG_TUSB_MCU
#error CFG_TUSB_MCU must be defined by the SDK
#endif
#define CFG_TUSB_OS OPT_OS_PICO
#define CFG_TUSB_RHPORT0_MODE OPT_MODE_DEVICE
#ifndef CFG_TUSB_MEM_SECTION
#define CFG_TUSB_MEM_SECTION
#endif
#ifndef CFG_TUSB_MEM_ALIGN
#define CFG_TUSB_MEM_ALIGN __attribute__ ((aligned(4)))
#endif
#define CFG_TUD_ENDPOINT0_SIZE 64
// Device classes: MIDI only. No CDC, no HID, nothing else to enumerate.
#define CFG_TUD_MIDI 1
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
#define CFG_TUD_HID 0
#define CFG_TUD_VENDOR 0
#define CFG_TUD_MIDI_RX_BUFSIZE 64
#define CFG_TUD_MIDI_TX_BUFSIZE 64
#endif
+101
View File
@@ -0,0 +1,101 @@
//
// usb_descriptors.c
//
// A single USB MIDI 1.0 streaming interface. From the PC this enumerates as
// an ordinary ALSA MIDI port, exactly as the Circle gadget build does.
//
#include "tusb.h"
#define USB_VID 0xCafe
#define USB_PID 0x4001
#define USB_BCD 0x0200
static const tusb_desc_device_t desc_device =
{
.bLength = sizeof (tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = USB_BCD,
.bDeviceClass = 0x00,
.bDeviceSubClass = 0x00,
.bDeviceProtocol = 0x00,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = USB_VID,
.idProduct = USB_PID,
.bcdDevice = 0x0100,
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01
};
const uint8_t *tud_descriptor_device_cb (void)
{
return (const uint8_t *) &desc_device;
}
enum { ITF_NUM_MIDI = 0, ITF_NUM_MIDI_STREAMING, ITF_NUM_TOTAL };
#define EPNUM_MIDI_OUT 0x01
#define EPNUM_MIDI_IN 0x81
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_MIDI_DESC_LEN)
static const uint8_t desc_configuration[] =
{
TUD_CONFIG_DESCRIPTOR (1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100),
TUD_MIDI_DESCRIPTOR (ITF_NUM_MIDI, 0, EPNUM_MIDI_OUT, EPNUM_MIDI_IN, 64)
};
const uint8_t *tud_descriptor_configuration_cb (uint8_t index)
{
(void) index;
return desc_configuration;
}
static const char *const string_desc_arr[] =
{
(const char[]) { 0x09, 0x04 }, // 0: English (0x0409)
"PianoLED", // 1: Manufacturer
"Piano LED Visualizer", // 2: Product
"000000000001", // 3: Serial
"Piano LED MIDI", // 4: MIDI interface
};
static uint16_t _desc_str[32];
const uint16_t *tud_descriptor_string_cb (uint8_t index, uint16_t langid)
{
(void) langid;
uint8_t chr_count;
if (index == 0)
{
memcpy (&_desc_str[1], string_desc_arr[0], 2);
chr_count = 1;
}
else
{
if (index >= sizeof (string_desc_arr) / sizeof (string_desc_arr[0]))
{
return NULL;
}
const char *str = string_desc_arr[index];
chr_count = (uint8_t) strlen (str);
if (chr_count > 31)
{
chr_count = 31;
}
for (uint8_t i = 0; i < chr_count; i++)
{
_desc_str[1 + i] = str[i];
}
}
_desc_str[0] = (uint16_t) ((TUSB_DESC_STRING << 8) | (2 * chr_count + 2));
return _desc_str;
}
+51
View File
@@ -0,0 +1,51 @@
;
; ws2812.pio
;
; WS2812B waveform generator. One PIO state machine clocks the 1.25us bit
; cell directly, which is what PIO is for - unlike the Circle build, which
; has to fake the waveform by streaming 8 SPI bytes per data bit.
;
; Timing per bit (T1+T2+T3 = 10 cycles = 1.25us, so the SM runs at 8MHz):
; '0' = 0.4us high, 0.85us low
; '1' = 0.8us high, 0.45us low
;
.program ws2812
.side_set 1
.define public T1 3
.define public T2 3
.define public T3 4
.wrap_target
bitloop:
out x, 1 side 0 [T3 - 1]
jmp !x do_zero side 1 [T1 - 1]
do_one:
jmp bitloop side 1 [T2 - 1]
do_zero:
nop side 0 [T2 - 1]
.wrap
% c-sdk {
#include "hardware/clocks.h"
static inline void ws2812_program_init (PIO pio, uint sm, uint offset, uint pin, float freq)
{
pio_gpio_init (pio, pin);
pio_sm_set_consecutive_pindirs (pio, sm, pin, 1, true);
pio_sm_config c = ws2812_program_get_default_config (offset);
sm_config_set_sideset_pins (&c, pin);
// 24 bits per LED, shifted out MSB first, auto-pulled a byte-triple at a time.
sm_config_set_out_shift (&c, false, true, 24);
sm_config_set_fifo_join (&c, PIO_FIFO_JOIN_TX);
int cycles_per_bit = ws2812_T1 + ws2812_T2 + ws2812_T3;
float div = clock_get_hz (clk_sys) / (freq * cycles_per_bit);
sm_config_set_clkdiv (&c, div);
pio_sm_init (pio, sm, offset, &c);
pio_sm_set_enabled (pio, sm, true);
}
%}
+26 -7
View File
@@ -1,7 +1,10 @@
// //
// config.h // config.h
// //
// Piano LED Visualizer on Circle - all tunable parameters. // Piano LED Visualizer - all tunable parameters.
//
// Shared by every platform backend (Circle on a Pi Zero, RP2040/RP2350 on a
// Pico). Nothing here is platform-specific except the wiring block at the end.
// //
// Every value in this file is a product decision that Phase 0 of // Every value in this file is a product decision that Phase 0 of
// PIANO-LED-CIRCLE-PLAN.md exists to answer. Bench-test on Raspberry Pi OS // PIANO-LED-CIRCLE-PLAN.md exists to answer. Bench-test on Raspberry Pi OS
@@ -128,19 +131,35 @@
#endif #endif
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Hardware wiring (VERIFIED against circle/addon/WS28XX, do not guess) // Hardware wiring - platform specific
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// //
// CWS28XXStripe clocks the WS2812B waveform out over SPI at a fixed 6.4MHz, // Whichever board is used, plan section 7 still applies in full: the data line
// encoding each LED bit as one SPI byte. On SPI master device 0 that puts the // needs a 74AHCT125 to reach 5V logic, the board ground must be tied to the LED
// data line on: // supply ground, and 5V must be injected at both ends of the strip. Neither
// board can power the strip itself.
#ifdef PLATFORM_PICO
// RP2040 / RP2350. The PIO state machine can drive the WS2812B waveform from
// any GPIO, so this is a free choice rather than a constraint.
#ifndef WS2812_PIN
#define WS2812_PIN 2
#endif
#else
// Circle on a Raspberry Pi. VERIFIED against circle/addon/WS28XX: CWS28XXStripe
// clocks the WS2812B waveform out over SPI at a fixed 6.4MHz, encoding each LED
// bit as one SPI byte. On SPI master device 0 that fixes the data line at
// //
// MOSI = GPIO10 (BCM) = physical pin 19 // MOSI = GPIO10 (BCM) = physical pin 19
// //
// Feed that through a 74AHCT125 to get a 5V logic level at the strip, and tie // It is not a free choice on this platform.
// the Pi's ground to the LED supply ground. See plan section 7.
#ifndef SPI_MASTER_DEVICE #ifndef SPI_MASTER_DEVICE
#define SPI_MASTER_DEVICE 0 #define SPI_MASTER_DEVICE 0
#endif #endif
#endif #endif
#endif
+37
View File
@@ -0,0 +1,37 @@
//
// ledstrip.h
//
// Platform-neutral LED strip sink.
//
// This is the entire hardware surface the visualizer logic depends on.
// Implemented once per platform:
//
// firmware/circlestrip.h Circle on a Pi Zero, WS2812B over SPI
// pico/picostrip.h RP2040/RP2350, WS2812B over PIO
// tests/stubs captures pixels in memory for host tests
//
#ifndef _ledstrip_h
#define _ledstrip_h
#include <stdint.h>
class ILEDStrip
{
public:
virtual ~ILEDStrip (void) {}
virtual bool Initialize (void) = 0;
virtual unsigned GetLEDCount (void) const = 0;
// nIndex is 0-based.
virtual void SetLED (unsigned nIndex, uint8_t nRed, uint8_t nGreen, uint8_t nBlue) = 0;
// Push the pixel buffer to the strip.
virtual bool Update (void) = 0;
// Switch all LEDs off immediately.
virtual bool Blackout (void) = 0;
};
#endif
+32 -49
View File
@@ -2,7 +2,7 @@
// pianoleds.cpp // pianoleds.cpp
// //
#include "pianoleds.h" #include "pianoleds.h"
#include <circle/util.h> #include <string.h>
#include <assert.h> #include <assert.h>
// MIDI status nibbles // MIDI status nibbles
@@ -14,9 +14,9 @@
#define MIDI_CC_ALL_SOUND_OFF 120 #define MIDI_CC_ALL_SOUND_OFF 120
#define MIDI_CC_ALL_NOTES_OFF 123 #define MIDI_CC_ALL_NOTES_OFF 123
CPianoLEDs::CPianoLEDs (void) CPianoLEDs::CPianoLEDs (ILEDStrip &Strip)
: m_Stripe (WS2812B, LED_COUNT, 4000000, SPI_MASTER_DEVICE), : m_Strip (Strip),
m_bDirty (TRUE) m_bDirty (true)
{ {
memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity); memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity);
memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity); memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity);
@@ -26,49 +26,32 @@ CPianoLEDs::~CPianoLEDs (void)
{ {
} }
boolean CPianoLEDs::Initialize (void) bool CPianoLEDs::Initialize (void)
{ {
if (!m_Stripe.Initialize ()) if (!m_Strip.Initialize ())
{ {
return FALSE; return false;
} }
assert (m_Strip.GetLEDCount () >= LED_COUNT);
// Start from a known-dark strip rather than whatever the pixels held // Start from a known-dark strip rather than whatever the pixels held
// when power came up. // when power came up.
return m_Stripe.Blackout (); return m_Strip.Blackout ();
} }
void CPianoLEDs::AttachMIDIDevice (CUSBMIDIDevice *pMIDIDevice) void CPianoLEDs::OnMIDIPacket (const uint8_t *pPacket, unsigned nLength)
{ {
assert (pMIDIDevice != 0); // The platform hands us one already-framed MIDI message of 1-3 bytes.
// Anything
// The gadget destroys and recreates its CUSBMIDIDevice across a suspend,
// so any notes held at that moment would otherwise stay lit forever.
AllOff ();
pMIDIDevice->RegisterPacketHandler (MIDIPacketHandler, this);
}
void CPianoLEDs::MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam)
{
CPianoLEDs *pThis = static_cast<CPianoLEDs *> (pParam);
assert (pThis != 0);
pThis->OnMIDIPacket (pPacket, nLength);
}
void CPianoLEDs::OnMIDIPacket (const u8 *pPacket, unsigned nLength)
{
// Circle hands us one already-framed MIDI message of 1-3 bytes. Anything
// shorter than a channel message cannot be a note event. // shorter than a channel message cannot be a note event.
if (nLength < 3) if (nLength < 3)
{ {
return; return;
} }
u8 ucStatus = pPacket[0] & 0xF0; uint8_t ucStatus = pPacket[0] & 0xF0;
u8 ucChannel = pPacket[0] & 0x0F; uint8_t ucChannel = pPacket[0] & 0x0F;
switch (ucStatus) switch (ucStatus)
{ {
@@ -94,7 +77,7 @@ void CPianoLEDs::OnMIDIPacket (const u8 *pPacket, unsigned nLength)
} }
} }
void CPianoLEDs::SetKey (u8 ucNote, u8 ucVelocity, boolean bHint) void CPianoLEDs::SetKey (uint8_t ucNote, uint8_t ucVelocity, bool bHint)
{ {
// Drop anything off the ends of the keybed rather than trusting the // Drop anything off the ends of the keybed rather than trusting the
// input; an out-of-range note would index past the strip. // input; an out-of-range note would index past the strip.
@@ -115,7 +98,7 @@ void CPianoLEDs::SetKey (u8 ucNote, u8 ucVelocity, boolean bHint)
m_KeyVelocity[nKey] = ucVelocity; m_KeyVelocity[nKey] = ucVelocity;
} }
m_bDirty = TRUE; m_bDirty = true;
} }
void CPianoLEDs::AllOff (void) void CPianoLEDs::AllOff (void)
@@ -123,25 +106,25 @@ void CPianoLEDs::AllOff (void)
memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity); memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity);
memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity); memset ((void *) m_HintVelocity, 0, sizeof m_HintVelocity);
m_bDirty = TRUE; m_bDirty = true;
} }
boolean CPianoLEDs::ChannelMatches (u8 ucChannel, u8 ucWanted) bool CPianoLEDs::ChannelMatches (uint8_t ucChannel, uint8_t ucWanted)
{ {
if (ucWanted == MIDI_CHANNEL_NONE) if (ucWanted == MIDI_CHANNEL_NONE)
{ {
return FALSE; return false;
} }
if (ucWanted == MIDI_CHANNEL_ANY) if (ucWanted == MIDI_CHANNEL_ANY)
{ {
return TRUE; return true;
} }
return ucChannel == ucWanted; return ucChannel == ucWanted;
} }
u8 CPianoLEDs::Scale (u8 ucChannel, u8 ucVelocity) uint8_t CPianoLEDs::Scale (uint8_t ucChannel, uint8_t ucVelocity)
{ {
unsigned nValue = ucChannel; unsigned nValue = ucChannel;
@@ -157,7 +140,7 @@ u8 CPianoLEDs::Scale (u8 ucChannel, u8 ucVelocity)
nValue = nValue * nPercent / 100; nValue = nValue * nPercent / 100;
#endif #endif
return (u8) nValue; return (uint8_t) nValue;
} }
void CPianoLEDs::Update (void) void CPianoLEDs::Update (void)
@@ -170,25 +153,25 @@ void CPianoLEDs::Update (void)
// Clear the flag before reading state, not after. An event arriving // Clear the flag before reading state, not after. An event arriving
// mid-render then leaves the flag set and we render again next pass, // mid-render then leaves the flag set and we render again next pass,
// rather than being dropped. // rather than being dropped.
m_bDirty = FALSE; m_bDirty = false;
unsigned nLit = 0; unsigned nLit = 0;
for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++) for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++)
{ {
u8 ucVelocity = m_KeyVelocity[nKey]; uint8_t ucVelocity = m_KeyVelocity[nKey];
boolean bHint = FALSE; bool bHint = false;
if (ucVelocity == 0) if (ucVelocity == 0)
{ {
// A key being played wins over a "next note" hint on it. // A key being played wins over a "next note" hint on it.
ucVelocity = m_HintVelocity[nKey]; ucVelocity = m_HintVelocity[nKey];
bHint = TRUE; bHint = true;
} }
u8 ucRed = 0; uint8_t ucRed = 0;
u8 ucGreen = 0; uint8_t ucGreen = 0;
u8 ucBlue = 0; uint8_t ucBlue = 0;
// Bound the number of simultaneously lit keys, so no sequence of // Bound the number of simultaneously lit keys, so no sequence of
// MIDI events can drive the strip past the supply's budget. // MIDI events can drive the strip past the supply's budget.
@@ -222,9 +205,9 @@ void CPianoLEDs::Update (void)
unsigned nLED = nBase + i; unsigned nLED = nBase + i;
assert (nLED < LED_COUNT); assert (nLED < LED_COUNT);
m_Stripe.SetLED (nLED, ucRed, ucGreen, ucBlue); m_Strip.SetLED (nLED, ucRed, ucGreen, ucBlue);
} }
} }
m_Stripe.Update (); m_Strip.Update ();
} }
+57
View File
@@ -0,0 +1,57 @@
//
// pianoleds.h
//
// Maps incoming MIDI note events onto an LED strip mounted above an
// 88-key keybed.
//
// Platform-independent. The only hardware dependency is ILEDStrip; MIDI is
// pushed in by the platform calling OnMIDIPacket().
//
#ifndef _pianoleds_h
#define _pianoleds_h
#include <stdint.h>
#include "ledstrip.h"
#include "config.h"
class CPianoLEDs
{
public:
CPianoLEDs (ILEDStrip &Strip);
~CPianoLEDs (void);
bool Initialize (void);
// Feed one framed MIDI message of 1-3 bytes. Safe to call from an
// interrupt or USB callback; it only records state.
void OnMIDIPacket (const uint8_t *pPacket, unsigned nLength);
// Push pending state to the strip. Call from the main loop only; this
// blocks for the strip's frame time and must not run in a callback.
// Does nothing when no state has changed since the last call.
void Update (void);
// Extinguish every pixel and forget all held notes. Call on USB
// (re)connection, so notes held at disconnect do not stay lit.
void AllOff (void);
private:
void SetKey (uint8_t ucNote, uint8_t ucVelocity, bool bHint);
// Scale a colour channel by velocity and the global brightness ceiling.
static uint8_t Scale (uint8_t ucChannel, uint8_t ucVelocity);
static bool ChannelMatches (uint8_t ucChannel, uint8_t ucWanted);
private:
ILEDStrip &m_Strip;
// Written from the MIDI callback, read by Update(). Index is
// note - MIDI_NOTE_MIN. Zero means the key is not lit.
volatile uint8_t m_KeyVelocity[KEY_COUNT];
volatile uint8_t m_HintVelocity[KEY_COUNT];
volatile bool m_bDirty;
};
#endif
+2 -2
View File
@@ -23,8 +23,8 @@ for CFG in \
FLAGS=${CFG#*:} FLAGS=${CFG#*:}
printf '\n=== %s ===\n' "$NAME" printf '\n=== %s ===\n' "$NAME"
g++ -std=c++17 -Wall -Wextra -Wno-unused-parameter $FLAGS \ g++ -std=c++17 -Wall -Wextra -Wno-unused-parameter $FLAGS \
-o "$OUT/t" -Itests/stubs -Ifirmware \ -o "$OUT/t" -Itests/stubs -Isrc \
tests/test_pianoleds.cpp firmware/pianoleds.cpp tests/test_pianoleds.cpp src/pianoleds.cpp
"$OUT/t" || FAIL=1 "$OUT/t" || FAIL=1
done done
-25
View File
@@ -1,25 +0,0 @@
#ifndef _stub_ws28xx_h
#define _stub_ws28xx_h
#include <circle/types.h>
#include <vector>
#include <array>
enum TWS28XXType { WS2801, WS2812, WS2812B, SK6812 = WS2812B };
// Captures what the firmware pushed to the strip, so tests can inspect pixels.
class CWS28XXStripe
{
public:
std::vector<std::array<u8,3>> m_Pixels;
unsigned m_nUpdates = 0;
CWS28XXStripe (TWS28XXType, unsigned nLEDCount, unsigned = 4000000, unsigned = 0)
: m_Pixels (nLEDCount, {0,0,0}) {}
boolean Initialize (void) { return TRUE; }
void SetLED (unsigned n, u8 r, u8 g, u8 b) { m_Pixels.at (n) = {r,g,b}; }
boolean Update (void) { m_nUpdates++; return TRUE; }
boolean Blackout (void)
{
for (auto &p : m_Pixels) p = {0,0,0};
return TRUE;
}
};
#endif
+39
View File
@@ -0,0 +1,39 @@
//
// capturestrip.h
//
// ILEDStrip that records pixels in memory, so host tests can inspect what
// the firmware would have pushed to real hardware.
//
#ifndef _capturestrip_h
#define _capturestrip_h
#include "ledstrip.h"
#include <vector>
#include <array>
class CCaptureLEDStrip : public ILEDStrip
{
public:
std::vector<std::array<uint8_t,3>> m_Pixels;
unsigned m_nUpdates = 0;
explicit CCaptureLEDStrip (unsigned nLEDCount) : m_Pixels (nLEDCount, {0,0,0}) {}
bool Initialize (void) override { return true; }
unsigned GetLEDCount (void) const override { return m_Pixels.size (); }
void SetLED (unsigned n, uint8_t r, uint8_t g, uint8_t b) override
{
m_Pixels.at (n) = {r,g,b};
}
bool Update (void) override { m_nUpdates++; return true; }
bool Blackout (void) override
{
for (auto &p : m_Pixels) p = {0,0,0};
return true;
}
};
#endif
-10
View File
@@ -1,10 +0,0 @@
#ifndef _stub_types_h
#define _stub_types_h
#include <cstdint>
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef int boolean;
#define TRUE 1
#define FALSE 0
#endif
-22
View File
@@ -1,22 +0,0 @@
#ifndef _stub_usbmidi_h
#define _stub_usbmidi_h
#include <circle/types.h>
typedef void TMIDIPacketHandlerEx (unsigned nCable, u8 *pPacket, unsigned nLength,
unsigned nDevice, void *pParam);
class CUSBMIDIDevice
{
public:
TMIDIPacketHandlerEx *m_pHandler = nullptr;
void *m_pParam = nullptr;
void RegisterPacketHandler (TMIDIPacketHandlerEx *p, void *pParam)
{
m_pHandler = p; m_pParam = pParam;
}
// Deliver a plain MIDI message the way Circle's driver would.
void Inject (u8 a, u8 b, u8 c)
{
u8 packet[3] = {a, b, c};
if (m_pHandler) m_pHandler (0, packet, 3, 1, m_pParam);
}
};
#endif
-4
View File
@@ -1,4 +0,0 @@
#ifndef _stub_util_h
#define _stub_util_h
#include <cstring>
#endif
+63 -57
View File
@@ -2,9 +2,8 @@
// Host-side tests for the note-to-LED mapping and the power clamps. // Host-side tests for the note-to-LED mapping and the power clamps.
// Compiles the real firmware/pianoleds.cpp against stubbed Circle headers. // Compiles the real firmware/pianoleds.cpp against stubbed Circle headers.
// //
#define private public // inspect the captured strip state
#include "pianoleds.h" #include "pianoleds.h"
#undef private #include "capturestrip.h"
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@@ -17,29 +16,39 @@ static void Check (const char *pName, bool bCond)
if (!bCond) g_nFail++; if (!bCond) g_nFail++;
} }
static unsigned CountLit (CPianoLEDs &L) static CCaptureLEDStrip Strip (LED_COUNT);
static CPianoLEDs LEDs (Strip);
static unsigned CountLit (void)
{ {
unsigned n = 0; unsigned n = 0;
for (auto &p : L.m_Stripe.m_Pixels) for (auto &p : Strip.m_Pixels)
if (p[0] || p[1] || p[2]) n++; if (p[0] || p[1] || p[2]) n++;
return n; return n;
} }
static bool Dark (CPianoLEDs &L, unsigned i) static bool Dark (unsigned i)
{ {
auto &p = L.m_Stripe.m_Pixels.at (i); auto &p = Strip.m_Pixels.at (i);
return !p[0] && !p[1] && !p[2]; return !p[0] && !p[1] && !p[2];
} }
// Deliver a plain MIDI message the way a platform backend would.
static void Inject (uint8_t a, uint8_t b, uint8_t c)
{
uint8_t packet[3] = {a, b, c};
LEDs.OnMIDIPacket (packet, 3);
}
// every pixel of one key's span is lit // every pixel of one key's span is lit
static bool Span (CPianoLEDs &L, unsigned nBase) static bool Span (unsigned nBase)
{ {
for (unsigned i = 0; i < LEDS_PER_KEY; i++) for (unsigned i = 0; i < LEDS_PER_KEY; i++)
if (Dark (L, nBase + i)) return false; if (Dark (nBase + i)) return false;
return true; return true;
} }
static unsigned LedFor (u8 ucNote) static unsigned LedFor (uint8_t ucNote)
{ {
unsigned nKey = ucNote - MIDI_NOTE_MIN; unsigned nKey = ucNote - MIDI_NOTE_MIN;
#if STRIP_REVERSED #if STRIP_REVERSED
@@ -54,63 +63,60 @@ int main (void)
printf ("STRIP_REVERSED=%d LED_COUNT=%d MAX_LIT_KEYS=%d GLOBAL_BRIGHTNESS=%d\n\n", printf ("STRIP_REVERSED=%d LED_COUNT=%d MAX_LIT_KEYS=%d GLOBAL_BRIGHTNESS=%d\n\n",
STRIP_REVERSED, LED_COUNT, MAX_LIT_KEYS, GLOBAL_BRIGHTNESS); STRIP_REVERSED, LED_COUNT, MAX_LIT_KEYS, GLOBAL_BRIGHTNESS);
CUSBMIDIDevice MIDI;
CPianoLEDs LEDs;
LEDs.Initialize (); LEDs.Initialize ();
LEDs.AttachMIDIDevice (&MIDI);
// --- lowest key, A0 = note 21 ------------------------------------- // --- lowest key, A0 = note 21 -------------------------------------
MIDI.Inject (0x90, 21, 127); Inject (0x90, 21, 127);
LEDs.Update (); LEDs.Update ();
#if STRIP_REVERSED #if STRIP_REVERSED
unsigned nLow = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174 unsigned nLow = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174
#else #else
unsigned nLow = 0; unsigned nLow = 0;
#endif #endif
Check ("note 21 lights its whole key span", Span (LEDs, nLow)); Check ("note 21 lights its whole key span", Span (nLow));
Check ("note 21 lights exactly LEDS_PER_KEY LEDs", CountLit (LEDs) == LEDS_PER_KEY); Check ("note 21 lights exactly LEDS_PER_KEY LEDs", CountLit () == LEDS_PER_KEY);
// --- highest key, C8 = note 108 ----------------------------------- // --- highest key, C8 = note 108 -----------------------------------
MIDI.Inject (0x80, 21, 0); Inject (0x80, 21, 0);
MIDI.Inject (0x90, 108, 127); Inject (0x90, 108, 127);
LEDs.Update (); LEDs.Update ();
#if STRIP_REVERSED #if STRIP_REVERSED
unsigned nHigh = 0; unsigned nHigh = 0;
#else #else
unsigned nHigh = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174 unsigned nHigh = (KEY_COUNT - 1) * LEDS_PER_KEY; // 174
#endif #endif
Check ("note 108 lights its whole key span", Span (LEDs, nHigh)); Check ("note 108 lights its whole key span", Span (nHigh));
Check ("note 108 lights exactly LEDS_PER_KEY LEDs", CountLit (LEDs) == LEDS_PER_KEY); Check ("note 108 lights exactly LEDS_PER_KEY LEDs", CountLit () == LEDS_PER_KEY);
Check ("the two extremes are at opposite ends", nLow != nHigh); Check ("the two extremes are at opposite ends", nLow != nHigh);
// --- note off ------------------------------------------------------ // --- note off ------------------------------------------------------
MIDI.Inject (0x80, 108, 0); Inject (0x80, 108, 0);
LEDs.Update (); LEDs.Update ();
Check ("note off extinguishes the key", CountLit (LEDs) == 0); Check ("note off extinguishes the key", CountLit () == 0);
// --- note on with velocity 0 is a note off ------------------------- // --- note on with velocity 0 is a note off -------------------------
MIDI.Inject (0x90, 60, 100); Inject (0x90, 60, 100);
LEDs.Update (); LEDs.Update ();
Check ("note on lights middle C", CountLit (LEDs) == LEDS_PER_KEY); Check ("note on lights middle C", CountLit () == LEDS_PER_KEY);
MIDI.Inject (0x90, 60, 0); Inject (0x90, 60, 0);
LEDs.Update (); LEDs.Update ();
Check ("note on velocity 0 acts as note off", CountLit (LEDs) == 0); Check ("note on velocity 0 acts as note off", CountLit () == 0);
// --- out-of-range notes are dropped, not clamped into the strip ---- // --- out-of-range notes are dropped, not clamped into the strip ----
MIDI.Inject (0x90, 20, 127); Inject (0x90, 20, 127);
MIDI.Inject (0x90, 109, 127); Inject (0x90, 109, 127);
MIDI.Inject (0x90, 0, 127); Inject (0x90, 0, 127);
MIDI.Inject (0x90, 127, 127); Inject (0x90, 127, 127);
LEDs.Update (); LEDs.Update ();
Check ("notes outside 21-108 are ignored", CountLit (LEDs) == 0); Check ("notes outside 21-108 are ignored", CountLit () == 0);
// --- brightness ceiling -------------------------------------------- // --- brightness ceiling --------------------------------------------
for (u8 n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) for (uint8_t n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++)
MIDI.Inject (0x90, n, 127); Inject (0x90, n, 127);
LEDs.Update (); LEDs.Update ();
bool bWithinCeiling = true; bool bWithinCeiling = true;
#if GLOBAL_BRIGHTNESS < 255 // at 255 a u8 channel cannot exceed the ceiling by construction #if GLOBAL_BRIGHTNESS < 255 // at 255 a u8 channel cannot exceed the ceiling by construction
for (auto &p : LEDs.m_Stripe.m_Pixels) for (auto &p : Strip.m_Pixels)
for (int c = 0; c < 3; c++) for (int c = 0; c < 3; c++)
if (p[c] > GLOBAL_BRIGHTNESS) bWithinCeiling = false; if (p[c] > GLOBAL_BRIGHTNESS) bWithinCeiling = false;
#endif #endif
@@ -118,65 +124,65 @@ int main (void)
// --- simultaneous-key cap ------------------------------------------ // --- simultaneous-key cap ------------------------------------------
Check ("all 88 keys held stays within MAX_LIT_KEYS", Check ("all 88 keys held stays within MAX_LIT_KEYS",
CountLit (LEDs) <= MAX_LIT_KEYS * LEDS_PER_KEY); CountLit () <= MAX_LIT_KEYS * LEDS_PER_KEY);
// --- all notes off -------------------------------------------------- // --- all notes off --------------------------------------------------
MIDI.Inject (0xB0, 123, 0); Inject (0xB0, 123, 0);
LEDs.Update (); LEDs.Update ();
Check ("CC 123 (all notes off) clears the strip", CountLit (LEDs) == 0); Check ("CC 123 (all notes off) clears the strip", CountLit () == 0);
for (u8 n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) for (uint8_t n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++)
MIDI.Inject (0x90, n, 127); Inject (0x90, n, 127);
MIDI.Inject (0xB0, 120, 0); Inject (0xB0, 120, 0);
LEDs.Update (); LEDs.Update ();
Check ("CC 120 (all sound off) clears the strip", CountLit (LEDs) == 0); Check ("CC 120 (all sound off) clears the strip", CountLit () == 0);
// --- velocity sensitivity ------------------------------------------- // --- velocity sensitivity -------------------------------------------
MIDI.Inject (0x90, 60, 127); Inject (0x90, 60, 127);
LEDs.Update (); LEDs.Update ();
auto Loud = LEDs.m_Stripe.m_Pixels.at (LedFor (60)); auto Loud = Strip.m_Pixels.at (LedFor (60));
MIDI.Inject (0x90, 60, 1); Inject (0x90, 60, 1);
LEDs.Update (); LEDs.Update ();
auto Soft = LEDs.m_Stripe.m_Pixels.at (LedFor (60)); auto Soft = Strip.m_Pixels.at (LedFor (60));
#if VELOCITY_SENSITIVE #if VELOCITY_SENSITIVE
Check ("a soft note is dimmer than a loud one", Soft[2] < Loud[2]); Check ("a soft note is dimmer than a loud one", Soft[2] < Loud[2]);
Check ("a soft note is still visible", Soft[2] > 0); Check ("a soft note is still visible", Soft[2] > 0);
#else #else
Check ("velocity does not change brightness", Soft[2] == Loud[2]); Check ("velocity does not change brightness", Soft[2] == Loud[2]);
#endif #endif
MIDI.Inject (0x80, 60, 0); Inject (0x80, 60, 0);
// --- hint channel ---------------------------------------------------- // --- hint channel ----------------------------------------------------
#if HINT_MIDI_CHANNEL != MIDI_CHANNEL_NONE #if HINT_MIDI_CHANNEL != MIDI_CHANNEL_NONE
MIDI.Inject (0x90 | HINT_MIDI_CHANNEL, 64, 127); Inject (0x90 | HINT_MIDI_CHANNEL, 64, 127);
LEDs.Update (); LEDs.Update ();
auto Hint = LEDs.m_Stripe.m_Pixels.at (LedFor (64)); auto Hint = Strip.m_Pixels.at (LedFor (64));
Check ("a hint note lights in the hint colour", Hint != Loud && (Hint[0] || Hint[1] || Hint[2])); 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 // a key actually played wins over a hint on the same key
MIDI.Inject (0x90, 64, 127); Inject (0x90, 64, 127);
LEDs.Update (); LEDs.Update ();
auto Both = LEDs.m_Stripe.m_Pixels.at (LedFor (64)); auto Both = Strip.m_Pixels.at (LedFor (64));
Check ("a played note overrides a hint on the same key", Both == Loud); Check ("a played note overrides a hint on the same key", Both == Loud);
// releasing the played note falls back to the still-pending hint // releasing the played note falls back to the still-pending hint
MIDI.Inject (0x80, 64, 0); Inject (0x80, 64, 0);
LEDs.Update (); LEDs.Update ();
auto Back = LEDs.m_Stripe.m_Pixels.at (LedFor (64)); auto Back = Strip.m_Pixels.at (LedFor (64));
Check ("releasing a played note reveals the hint again", Back == Hint); Check ("releasing a played note reveals the hint again", Back == Hint);
#endif #endif
// --- reconnect clears held notes ------------------------------------- // --- reconnect clears held notes -------------------------------------
MIDI.Inject (0x90, 60, 127); Inject (0x90, 60, 127);
LEDs.AttachMIDIDevice (&MIDI); LEDs.AllOff ();
LEDs.Update (); LEDs.Update ();
Check ("re-enumeration clears notes held at suspend", CountLit (LEDs) == 0); Check ("re-enumeration clears notes held at suspend", CountLit () == 0);
// --- short packets are not parsed as notes ---------------------------- // --- short packets are not parsed as notes ----------------------------
u8 Short[1] = {0xF8}; // clock, 1 byte uint8_t Short[1] = {0xF8}; // clock, 1 byte
MIDI.m_pHandler (0, Short, 1, 1, MIDI.m_pParam); LEDs.OnMIDIPacket (Short, 1);
LEDs.Update (); LEDs.Update ();
Check ("a 1-byte realtime message lights nothing", CountLit (LEDs) == 0); Check ("a 1-byte realtime message lights nothing", CountLit () == 0);
printf ("\n%s\n", g_nFail ? "FAILURES" : "all tests passed"); printf ("\n%s\n", g_nFail ? "FAILURES" : "all tests passed");
return g_nFail != 0; return g_nFail != 0;