diff --git a/.gitignore b/.gitignore index e2abac5..3df4149 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ boot/ # Circle build configuration, generated by ./build.sh circle/Config.mk + +# Pico build tree +pico/build/ diff --git a/README.md b/README.md index b0270df..40e00fc 100644 --- a/README.md +++ b/README.md @@ -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 -an 88-key keybed in response to MIDI. +Bare-metal firmware that lights a WS2812B strip above an 88-key keybed in +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 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 -The Pi is a **USB MIDI gadget**. The PC is the host and owns everything else — -the piano connection, the learning software, the song library. From the PC's -side this firmware is just another ALSA MIDI output port: +The board is a **USB MIDI device**. The PC is the host and owns everything +else — the piano connection, the learning software, the song library. From the +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 -firmware in about a second and does one job. +There is no network stack, no shell, and nothing writable at runtime. The +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 -piano **cannot** be plugged into the Pi directly; all MIDI arrives from the PC. +On the Circle build the piano **cannot** be plugged into the board directly: +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 | Path | | |---|---| -| `firmware/config.h` | Every tunable. Start here. | -| `firmware/pianoleds.cpp` | Note-to-LED mapping, colour, brightness clamps. | -| `firmware/kernel.cpp` | USB gadget lifecycle and the main loop. | -| `tests/` | Host-side tests for the mapping and clamps. | +| `src/config.h` | Every tunable. Start here. | +| `src/pianoleds.cpp` | Note-to-LED mapping, colour, brightness clamps. Platform-independent. | +| `src/ledstrip.h` | `ILEDStrip` — the entire hardware surface the logic depends on. | +| `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`. | -| `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: @@ -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. -## SD card +## SD card (Circle only) + +The Pico boots from internal flash and needs none of this. FAT32, single partition. Copy in: @@ -78,11 +110,14 @@ cannot corrupt it. ## Wiring -Verified against `circle/addon/WS28XX`: `CWS28XXStripe` clocks the WS2812B -waveform out over SPI at a fixed 6.4 MHz, encoding one LED bit per SPI byte. -On SPI master device 0 that puts the data line on: +**On Pico**, the WS2812B waveform comes from a PIO state machine, so the data +line is any GPIO you like — `WS2812_PIN` in `src/config.h`, default GPIO2. +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: @@ -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. - **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 @@ -109,9 +145,17 @@ question. Two are load-bearing: Any of them can also be overridden at build time without editing the file: ```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 - Notes 21–108 (A0–C8) map to the strip; anything outside is dropped. @@ -128,8 +172,10 @@ make -C firmware EXTRADEFINE=-DSTRIP_REVERSED=1 ./tests/run.sh ``` -Compiles the real `firmware/pianoleds.cpp` against stubbed Circle headers and -exercises the mapping, the note-off paths, the range clamping and both power -clamps across nine configuration variants. This does not need the ARM -toolchain and does not replace bench-testing on real hardware — it checks the -arithmetic, not the wiring. +Compiles the real `src/pianoleds.cpp` against a capture backend that records +pixels in memory, and exercises the mapping, the note-off paths, the range +clamping and both power clamps across nine configuration variants. + +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. diff --git a/firmware/Makefile b/firmware/Makefile index 14cbf41..2907062 100644 --- a/firmware/Makefile +++ b/firmware/Makefile @@ -6,6 +6,10 @@ CIRCLEHOME = ../circle 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 \ $(CIRCLEHOME)/lib/usb/gadget/libusbgadget.a \ $(CIRCLEHOME)/lib/usb/libusb.a \ diff --git a/firmware/circlestrip.h b/firmware/circlestrip.h new file mode 100644 index 0000000..5ef5de2 --- /dev/null +++ b/firmware/circlestrip.h @@ -0,0 +1,38 @@ +// +// circlestrip.h +// +// ILEDStrip backed by Circle's CWS28XXStripe (WS2812B over SPI). +// +#ifndef _circlestrip_h +#define _circlestrip_h + +#include +#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 diff --git a/firmware/kernel.cpp b/firmware/kernel.cpp index 88fea81..d4f6510 100644 --- a/firmware/kernel.cpp +++ b/firmware/kernel.cpp @@ -16,11 +16,21 @@ static const char FromKernel[] = "kernel"; +void CKernel::MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength, + unsigned nDevice, void *pParam) +{ + CKernel *pThis = static_cast (pParam); + assert (pThis != 0); + + pThis->m_PianoLEDs.OnMIDIPacket (pPacket, nLength); +} + CKernel::CKernel (void) : m_Timer (&m_Interrupt), m_Logger (m_Options.GetLogLevel (), &m_Timer), m_pUSB (new CUSBMIDIGadget (&m_Interrupt)), - m_pMIDIDevice (0) + m_pMIDIDevice (0), + m_PianoLEDs (m_LEDStrip) { m_ActLED.Blink (5); // show we are alive } @@ -95,7 +105,11 @@ void CKernel::UpdateMIDIDevice (void) 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"); } diff --git a/firmware/kernel.h b/firmware/kernel.h index 6a4bef4..04c4204 100644 --- a/firmware/kernel.h +++ b/firmware/kernel.h @@ -16,6 +16,7 @@ #include #include #include "pianoleds.h" +#include "circlestrip.h" enum TShutdownMode { @@ -39,6 +40,9 @@ private: // after every re-enumeration. void UpdateMIDIDevice (void); + static void MIDIPacketHandler (unsigned nCable, u8 *pPacket, unsigned nLength, + unsigned nDevice, void *pParam); + private: // do not change this order CActLED m_ActLED; @@ -53,6 +57,7 @@ private: CUSBController *m_pUSB; CUSBMIDIDevice *m_pMIDIDevice; + CCircleLEDStrip m_LEDStrip; CPianoLEDs m_PianoLEDs; }; diff --git a/firmware/pianoleds.h b/firmware/pianoleds.h deleted file mode 100644 index d060812..0000000 --- a/firmware/pianoleds.h +++ /dev/null @@ -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 -#include -#include -#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 diff --git a/pico/CMakeLists.txt b/pico/CMakeLists.txt new file mode 100644 index 0000000..62e42bc --- /dev/null +++ b/pico/CMakeLists.txt @@ -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) diff --git a/pico/build.sh b/pico/build.sh new file mode 100755 index 0000000..c84216b --- /dev/null +++ b/pico/build.sh @@ -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 diff --git a/pico/main.cpp b/pico/main.cpp new file mode 100644 index 0000000..7308e57 --- /dev/null +++ b/pico/main.cpp @@ -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; +} diff --git a/pico/pico_sdk_import.cmake b/pico/pico_sdk_import.cmake new file mode 100644 index 0000000..d493cc2 --- /dev/null +++ b/pico/pico_sdk_import.cmake @@ -0,0 +1,121 @@ +# This is a copy of /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}) diff --git a/pico/picostrip.cpp b/pico/picostrip.cpp new file mode 100644 index 0000000..115d2fd --- /dev/null +++ b/pico/picostrip.cpp @@ -0,0 +1,108 @@ +// +// picostrip.cpp +// +#include "picostrip.h" +#include "ws2812.pio.h" +#include "pico/stdlib.h" +#include +#include + +// 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 (); +} diff --git a/pico/picostrip.h b/pico/picostrip.h new file mode 100644 index 0000000..f1ac00a --- /dev/null +++ b/pico/picostrip.h @@ -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 diff --git a/pico/tusb_config.h b/pico/tusb_config.h new file mode 100644 index 0000000..f4a2c94 --- /dev/null +++ b/pico/tusb_config.h @@ -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 diff --git a/pico/usb_descriptors.c b/pico/usb_descriptors.c new file mode 100644 index 0000000..1617e9f --- /dev/null +++ b/pico/usb_descriptors.c @@ -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; +} diff --git a/pico/ws2812.pio b/pico/ws2812.pio new file mode 100644 index 0000000..8829c6d --- /dev/null +++ b/pico/ws2812.pio @@ -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); +} +%} diff --git a/firmware/config.h b/src/config.h similarity index 82% rename from firmware/config.h rename to src/config.h index 3aeaedb..77ca35a 100644 --- a/firmware/config.h +++ b/src/config.h @@ -1,7 +1,10 @@ // // 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 // PIANO-LED-CIRCLE-PLAN.md exists to answer. Bench-test on Raspberry Pi OS @@ -128,19 +131,35 @@ #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, -// encoding each LED bit as one SPI byte. On SPI master device 0 that puts the -// data line on: +// Whichever board is used, plan section 7 still applies in full: the data line +// needs a 74AHCT125 to reach 5V logic, the board ground must be tied to the LED +// 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 // -// Feed that through a 74AHCT125 to get a 5V logic level at the strip, and tie -// the Pi's ground to the LED supply ground. See plan section 7. +// It is not a free choice on this platform. #ifndef SPI_MASTER_DEVICE #define SPI_MASTER_DEVICE 0 #endif #endif + +#endif diff --git a/src/ledstrip.h b/src/ledstrip.h new file mode 100644 index 0000000..21308f6 --- /dev/null +++ b/src/ledstrip.h @@ -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 + +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 diff --git a/firmware/pianoleds.cpp b/src/pianoleds.cpp similarity index 69% rename from firmware/pianoleds.cpp rename to src/pianoleds.cpp index 263231b..74954e6 100644 --- a/firmware/pianoleds.cpp +++ b/src/pianoleds.cpp @@ -2,7 +2,7 @@ // pianoleds.cpp // #include "pianoleds.h" -#include +#include #include // MIDI status nibbles @@ -14,9 +14,9 @@ #define MIDI_CC_ALL_SOUND_OFF 120 #define MIDI_CC_ALL_NOTES_OFF 123 -CPianoLEDs::CPianoLEDs (void) -: m_Stripe (WS2812B, LED_COUNT, 4000000, SPI_MASTER_DEVICE), - m_bDirty (TRUE) +CPianoLEDs::CPianoLEDs (ILEDStrip &Strip) +: m_Strip (Strip), + m_bDirty (true) { memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity); 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 // 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 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 (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 + // The platform hands us one already-framed MIDI message of 1-3 bytes. + // Anything // shorter than a channel message cannot be a note event. if (nLength < 3) { return; } - u8 ucStatus = pPacket[0] & 0xF0; - u8 ucChannel = pPacket[0] & 0x0F; + uint8_t ucStatus = pPacket[0] & 0xF0; + uint8_t ucChannel = pPacket[0] & 0x0F; 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 // 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_bDirty = TRUE; + m_bDirty = true; } void CPianoLEDs::AllOff (void) @@ -123,25 +106,25 @@ void CPianoLEDs::AllOff (void) memset ((void *) m_KeyVelocity, 0, sizeof m_KeyVelocity); 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) { - return FALSE; + return false; } if (ucWanted == MIDI_CHANNEL_ANY) { - return TRUE; + return true; } return ucChannel == ucWanted; } -u8 CPianoLEDs::Scale (u8 ucChannel, u8 ucVelocity) +uint8_t CPianoLEDs::Scale (uint8_t ucChannel, uint8_t ucVelocity) { unsigned nValue = ucChannel; @@ -157,7 +140,7 @@ u8 CPianoLEDs::Scale (u8 ucChannel, u8 ucVelocity) nValue = nValue * nPercent / 100; #endif - return (u8) nValue; + return (uint8_t) nValue; } void CPianoLEDs::Update (void) @@ -170,25 +153,25 @@ void CPianoLEDs::Update (void) // 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_bDirty = false; unsigned nLit = 0; for (unsigned nKey = 0; nKey < KEY_COUNT; nKey++) { - u8 ucVelocity = m_KeyVelocity[nKey]; - boolean bHint = FALSE; + uint8_t ucVelocity = m_KeyVelocity[nKey]; + bool bHint = false; if (ucVelocity == 0) { // A key being played wins over a "next note" hint on it. ucVelocity = m_HintVelocity[nKey]; - bHint = TRUE; + bHint = true; } - u8 ucRed = 0; - u8 ucGreen = 0; - u8 ucBlue = 0; + 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. @@ -222,9 +205,9 @@ void CPianoLEDs::Update (void) unsigned nLED = nBase + i; assert (nLED < LED_COUNT); - m_Stripe.SetLED (nLED, ucRed, ucGreen, ucBlue); + m_Strip.SetLED (nLED, ucRed, ucGreen, ucBlue); } } - m_Stripe.Update (); + m_Strip.Update (); } diff --git a/src/pianoleds.h b/src/pianoleds.h new file mode 100644 index 0000000..b839632 --- /dev/null +++ b/src/pianoleds.h @@ -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 +#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 diff --git a/tests/run.sh b/tests/run.sh index b604ffe..36dfeae 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -23,8 +23,8 @@ for CFG in \ FLAGS=${CFG#*:} printf '\n=== %s ===\n' "$NAME" g++ -std=c++17 -Wall -Wextra -Wno-unused-parameter $FLAGS \ - -o "$OUT/t" -Itests/stubs -Ifirmware \ - tests/test_pianoleds.cpp firmware/pianoleds.cpp + -o "$OUT/t" -Itests/stubs -Isrc \ + tests/test_pianoleds.cpp src/pianoleds.cpp "$OUT/t" || FAIL=1 done diff --git a/tests/stubs/WS28XX/ws28xxstripe.h b/tests/stubs/WS28XX/ws28xxstripe.h deleted file mode 100644 index 198ce7b..0000000 --- a/tests/stubs/WS28XX/ws28xxstripe.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef _stub_ws28xx_h -#define _stub_ws28xx_h -#include -#include -#include -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> 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 diff --git a/tests/stubs/capturestrip.h b/tests/stubs/capturestrip.h new file mode 100644 index 0000000..1615eb0 --- /dev/null +++ b/tests/stubs/capturestrip.h @@ -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 +#include + +class CCaptureLEDStrip : public ILEDStrip +{ +public: + std::vector> 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 diff --git a/tests/stubs/circle/types.h b/tests/stubs/circle/types.h deleted file mode 100644 index 7361611..0000000 --- a/tests/stubs/circle/types.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef _stub_types_h -#define _stub_types_h -#include -typedef uint8_t u8; -typedef uint16_t u16; -typedef uint32_t u32; -typedef int boolean; -#define TRUE 1 -#define FALSE 0 -#endif diff --git a/tests/stubs/circle/usb/usbmidi.h b/tests/stubs/circle/usb/usbmidi.h deleted file mode 100644 index a49323d..0000000 --- a/tests/stubs/circle/usb/usbmidi.h +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef _stub_usbmidi_h -#define _stub_usbmidi_h -#include -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 diff --git a/tests/stubs/circle/util.h b/tests/stubs/circle/util.h deleted file mode 100644 index 920f5ec..0000000 --- a/tests/stubs/circle/util.h +++ /dev/null @@ -1,4 +0,0 @@ -#ifndef _stub_util_h -#define _stub_util_h -#include -#endif diff --git a/tests/test_pianoleds.cpp b/tests/test_pianoleds.cpp index ee0d93b..72e474a 100644 --- a/tests/test_pianoleds.cpp +++ b/tests/test_pianoleds.cpp @@ -2,9 +2,8 @@ // Host-side tests for the note-to-LED mapping and the power clamps. // Compiles the real firmware/pianoleds.cpp against stubbed Circle headers. // -#define private public // inspect the captured strip state #include "pianoleds.h" -#undef private +#include "capturestrip.h" #include #include @@ -17,29 +16,39 @@ static void Check (const char *pName, bool bCond) 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; - for (auto &p : L.m_Stripe.m_Pixels) + for (auto &p : Strip.m_Pixels) if (p[0] || p[1] || p[2]) 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]; } +// 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 -static bool Span (CPianoLEDs &L, unsigned nBase) +static bool Span (unsigned nBase) { for (unsigned i = 0; i < LEDS_PER_KEY; i++) - if (Dark (L, nBase + i)) return false; + if (Dark (nBase + i)) return false; return true; } -static unsigned LedFor (u8 ucNote) +static unsigned LedFor (uint8_t ucNote) { unsigned nKey = ucNote - MIDI_NOTE_MIN; #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", STRIP_REVERSED, LED_COUNT, MAX_LIT_KEYS, GLOBAL_BRIGHTNESS); - CUSBMIDIDevice MIDI; - CPianoLEDs LEDs; LEDs.Initialize (); - LEDs.AttachMIDIDevice (&MIDI); // --- lowest key, A0 = note 21 ------------------------------------- - MIDI.Inject (0x90, 21, 127); + 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 (LEDs, nLow)); - Check ("note 21 lights exactly LEDS_PER_KEY LEDs", CountLit (LEDs) == LEDS_PER_KEY); + 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 ----------------------------------- - MIDI.Inject (0x80, 21, 0); - MIDI.Inject (0x90, 108, 127); + 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 (LEDs, nHigh)); - Check ("note 108 lights exactly LEDS_PER_KEY LEDs", CountLit (LEDs) == LEDS_PER_KEY); + 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 ------------------------------------------------------ - MIDI.Inject (0x80, 108, 0); + Inject (0x80, 108, 0); 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 ------------------------- - MIDI.Inject (0x90, 60, 100); + Inject (0x90, 60, 100); LEDs.Update (); - Check ("note on lights middle C", CountLit (LEDs) == LEDS_PER_KEY); - MIDI.Inject (0x90, 60, 0); + Check ("note on lights middle C", CountLit () == LEDS_PER_KEY); + Inject (0x90, 60, 0); 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 ---- - MIDI.Inject (0x90, 20, 127); - MIDI.Inject (0x90, 109, 127); - MIDI.Inject (0x90, 0, 127); - MIDI.Inject (0x90, 127, 127); + Inject (0x90, 20, 127); + Inject (0x90, 109, 127); + Inject (0x90, 0, 127); + Inject (0x90, 127, 127); LEDs.Update (); - Check ("notes outside 21-108 are ignored", CountLit (LEDs) == 0); + Check ("notes outside 21-108 are ignored", CountLit () == 0); // --- brightness ceiling -------------------------------------------- - for (u8 n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) - MIDI.Inject (0x90, n, 127); + for (uint8_t n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) + Inject (0x90, n, 127); LEDs.Update (); bool bWithinCeiling = true; #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++) if (p[c] > GLOBAL_BRIGHTNESS) bWithinCeiling = false; #endif @@ -118,65 +124,65 @@ int main (void) // --- simultaneous-key cap ------------------------------------------ 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 -------------------------------------------------- - MIDI.Inject (0xB0, 123, 0); + Inject (0xB0, 123, 0); 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++) - MIDI.Inject (0x90, n, 127); - MIDI.Inject (0xB0, 120, 0); + for (uint8_t n = MIDI_NOTE_MIN; n <= MIDI_NOTE_MAX; n++) + Inject (0x90, n, 127); + Inject (0xB0, 120, 0); 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 ------------------------------------------- - MIDI.Inject (0x90, 60, 127); + Inject (0x90, 60, 127); LEDs.Update (); - auto Loud = LEDs.m_Stripe.m_Pixels.at (LedFor (60)); - MIDI.Inject (0x90, 60, 1); + auto Loud = Strip.m_Pixels.at (LedFor (60)); + Inject (0x90, 60, 1); LEDs.Update (); - auto Soft = LEDs.m_Stripe.m_Pixels.at (LedFor (60)); + auto Soft = Strip.m_Pixels.at (LedFor (60)); #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); #else Check ("velocity does not change brightness", Soft[2] == Loud[2]); #endif - MIDI.Inject (0x80, 60, 0); + Inject (0x80, 60, 0); // --- hint channel ---------------------------------------------------- #if HINT_MIDI_CHANNEL != MIDI_CHANNEL_NONE - MIDI.Inject (0x90 | HINT_MIDI_CHANNEL, 64, 127); + Inject (0x90 | HINT_MIDI_CHANNEL, 64, 127); 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])); // a key actually played wins over a hint on the same key - MIDI.Inject (0x90, 64, 127); + Inject (0x90, 64, 127); 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); // releasing the played note falls back to the still-pending hint - MIDI.Inject (0x80, 64, 0); + Inject (0x80, 64, 0); 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); #endif // --- reconnect clears held notes ------------------------------------- - MIDI.Inject (0x90, 60, 127); - LEDs.AttachMIDIDevice (&MIDI); + Inject (0x90, 60, 127); + LEDs.AllOff (); 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 ---------------------------- - u8 Short[1] = {0xF8}; // clock, 1 byte - MIDI.m_pHandler (0, Short, 1, 1, MIDI.m_pParam); + uint8_t Short[1] = {0xF8}; // clock, 1 byte + LEDs.OnMIDIPacket (Short, 1); 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"); return g_nFail != 0;