Compare commits

..
Author SHA1 Message Date
ash 1bf2ea9f2f Swap init order: USB before BLE to avoid PHY interference
BLE radio turning on corrupts USB PHY when TinyUSB initializes after it.
Initialize USB first (stable USB), then BLE radio.
2026-07-01 07:13:47 +00:00
ash 00337559ac Add BLE diagnostics: controller status, MAC, advertising result
Remove getMinInterval/getMaxInterval (not available in v1.4.0).
Add esp_bt_controller_get_status() before/after init.
Add esp_bt_dev_get_address() for MAC address.
Print adv->start() return value.
Add explicit advertising interval settings (100-200ms).
2026-07-01 07:09:30 +00:00
ash 9ba48b3f9c Roll back to 2d3a5a9 + CONFIG_BT_ENABLED + double-init patch 2026-07-01 07:06:45 +00:00
ash 01eaf9ce80 Fix enable-after-Arduino-init: always try to enable
Arduino framework inits BT controller but does NOT enable it.
Previous patch skipped both init AND enable when init returned
ESP_ERR_INVALID_STATE, leaving controller in init'd-but-not-enabled
state. Now we always try to enable regardless of init outcome.
2026-07-01 07:01:39 +00:00
ash c26116a7ef Set CONFIG_BT_ENABLED + re-add double-init patch
Without CONFIG_BT_ENABLED in sdkconfig.h, NimBLEDevice.cpp lines
are excluded by #if defined(CONFIG_BT_ENABLED) preprocessor guard,
making NimBLEDevice::init() a no-op. No BT controller init ever
occurs -> no RF calibration -> invisible BLE advertising.

With CONFIG_BT_ENABLED: Arduino framework pre-inits BT controller,
then NimBLEDevice::init() double-inits -> ESP_ERROR_CHECK abort.
Fix: patch NimBLEDevice.cpp to check return code and skip init if
already running.
2026-07-01 06:57:19 +00:00
ash fec2ea1db2 Move all MIDI processing to Core 0
Move controller.update() inside midi_task on Core 0 to eliminate
TinyUSB cross-core race condition. Previously:
 - Core 1 (loop()): usb_midi.writePacket() in controller.update()
 - Core 0 (midi_task): usb_midi.available()/readPacket()
This race condition causes crashes on button press.

Now:
 - Core 0 (midi_task): midi_transport.update() + controller.update()
 - Core 1 (loop()): led_driver.update() + serial commands only
2026-07-01 06:51:48 +00:00
ash 762500e227 Fix BLE crash: move all NimBLE calls to Core 0
Root cause: Arduino Core 3.x breaks cross-core NimBLE API calls.
Calling BLEMidiServer.controlChange() from loop() on Core 1 while
NimBLE host runs on Core 0 causes immediate crash.

Fix:
- Move ble_midi_transport.begin() to ble_init_task on Core 0
- All BLE MIDI sends use queue_cc() (cross-core safe flag setter)
- ble_midi_transport.update() processes queued sends on Core 0
- Pin NimBLE-Arduino to v1.4.3 (stable LTS for BLE MIDI)
- Keep CONFIG_BT_ENABLED out of sdkconfig.h (causes double-init)
2026-07-01 06:46:15 +00:00
ash 62d1e430d5 Switch from NimBLE-Arduino to ESP32-BLE-MIDI library
Replace direct NimBLE-Arduino dependency with max22/ESP32-BLE-MIDI
wrapper library (v0.3.2). Rewrite ble_midi_transport to use
BLEMidiServer API instead of raw NimBLEDevice calls.

Removes patch_nimble_device() from pre_build.py — no longer needed
since BT controller init is managed by the library internally.

The library uses NimBLE v1.4.1 internally and configures advertising
without setScanResponse() or setName(), which may resolve the invisible
BLE advertising issue on ESP32-S3.
2026-07-01 06:34:36 +00:00
ash 1517eda13e add CONFIG_BT_ENABLED to sdkconfig.h for proper BT RF calibration; patch NimBLEDevice.cpp to skip controller init if already done by Arduino framework 2026-07-01 06:11:28 +00:00
ash 855c97d8c6 revert to NimBLE-Arduino v1.4.0 (v2.2.3 caused button crash regression); restore v1.4.0 callback signatures, service->start(), and setScanResponse 2026-07-01 04:47:22 +00:00
ash 993a09fa7c fix v2.2.3 API: update callback signatures (onConnect, onDisconnect, onWrite) and remove deprecated service->start() 2026-07-01 04:30:50 +00:00
ash 18844c17c0 upgrade NimBLE-Arduino to v2.2.3 for proper ESP32-S3 support; fix setScanResponse->enableScanResponse API change 2026-07-01 04:27:22 +00:00
ash 7530772acd add adv->setName, remove diagnostics and ext adv flag; revert to stable 2026-07-01 04:18:00 +00:00
ash 94567e3485 fix: NimBLEDevice::init() returns void, remove bool check 2026-07-01 04:02:04 +00:00
ash beb59da3e0 add init() return check, controller status, and MAC address debug 2026-06-30 22:07:06 +00:00
ash 2d3a5a9031 queue MIDI sends with delay(1) to avoid race; force legacy advertising 2026-06-30 21:59:55 +00:00
ash 72a10fa13a add TinyUSBDevice.ready() guard in send_cc to prevent crash 2026-06-30 13:55:53 +00:00
ash d8c109d9e5 remove setName and health check (caused crashes), add isAdvertising() check 2026-06-30 13:14:19 +00:00
ash 5ee5949420 add adv->setName(JOC Midi) and health check to restart advertising 2026-06-30 12:55:29 +00:00
ash 8515963e07 pre_build.py: remove CONFIG_BT_ENABLED from sdkconfig patching (fixes BT pre-init conflict with USB) 2026-06-30 12:34:00 +00:00
ash e2db658037 TEST: disable BLE init to isolate button press crash 2026-06-30 12:12:50 +00:00
ash 8833e08c0a add debug prints before switch read and in process_switch_event; skip MIDI sends 2026-06-30 08:54:00 +00:00
ash 3e92d6bb7c swap init order (USB before BLE), add USB ready check and debug prints 2026-06-30 08:48:04 +00:00
ash be8b0dc22d add debug prints around button press to find crash location 2026-06-30 08:35:47 +00:00
ash 6038b3bf58 remove health check - back to exact source of working commit 6e3173c 2026-06-30 07:59:07 +00:00
ash 1f7bdc1e3a revert setName, keep health check 2026-06-30 07:55:10 +00:00
ash af6fd2dc26 fix: add adv->setName, add periodic health check to diagnose invisible BLE device 2026-06-30 07:44:04 +00:00
ash 6e3173c9b8 fix: remove manual 0x2902/CCCD descriptor creation - NimBLE auto-creates it for NOTIFY characteristics; asserts on manual create 2026-06-30 07:37:48 +00:00
ash d19d1acbdd chore: add granular debug prints around BLE characteristic/descriptor creation to pinpoint crash 2026-06-30 07:31:34 +00:00
ash a21c7bf4d3 fix: remove manual BT controller pre-init - NimBLE-Arduino 1.4.0 already handles ESP32-S3 internally; double-init caused ESP_ERROR_CHECK abort 2026-06-30 07:24:34 +00:00
ash 6a737bcee2 fix: patch nimconfig.h with #ifndef guards for ROLE defines to eliminate redefinition warnings 2026-06-30 07:15:14 +00:00
ash 323bc072ee fix: remove redundant ROLE defines from pre_build.py, suppress -Wno-macro-redefined 2026-06-30 07:09:15 +00:00
ash 13aef3b2a0 Init BT controller with ESP_BT_MODE_BLE before NimBLEDevice::init to avoid BTDM crash on ESP32-S3 2026-06-30 06:59:32 +00:00
ash 68efe30682 Remove redundant manual BT controller init; let NimBLEDevice::init handle it internally with BLE-only mode 2026-06-30 06:49:43 +00:00
ash 2db09b76bf Add BT controller diagnostics, init BLE before USB MIDI 2026-06-30 06:43:58 +00:00
ash 8a36296043 Add sdkconfig.defaults for BLE, remove bad CONFIG flags, increase stack 2026-06-30 06:36:40 +00:00
ash eab0b76c9d Fix: NimBLEDevice::init returns void, remove bool check 2026-06-30 06:30:58 +00:00
ash 95d182cda6 Replace NimBLE2902 with createDescriptor for CCCD 2026-06-30 06:29:30 +00:00
ash c3bbf08768 Fix: use just NimBLEDevice.h, remove missing NimBLE2902.h 2026-06-30 06:25:25 +00:00
ash f5eca2c7bc Switch to NimBLE stack, add BT build flags, add error checking 2026-06-30 06:24:19 +00:00
ash 892e4fe061 Fix: on_receive public, explicit casts for narrowing 2026-06-30 06:19:10 +00:00
ash aea3a28206 Add BLE MIDI transport (feature_bluetooth) 2026-06-30 06:13:36 +00:00
15 changed files with 531 additions and 757 deletions
-467
View File
@@ -1,467 +0,0 @@
# Loopy MIDI Controller - USB MIDI Foot Controller
A compact ESP32-S3 based USB MIDI foot controller with hardware acceleration, designed to work seamlessly with Loopy Pro.
## Overview
This project implements a USB MIDI device that accepts foot pedal inputs and converts them to MIDI Continuous Controller (CC) messages. It's designed to be a reliable replacement for the MIDI-to-USB adapters used with Loopy Pro, offering low latency (1-5ms) and USB compatibility without Bluetooth interference.
**Key Features:**
- **USB MIDI Interface**: Uses ESP32-S3's native USB MIDI support for stable, reliable connectivity
- **Expression Pedal Support**: Read analog input with configurable calibration
- **Launchpad X Compatible**: Per-pad LED colors matching Novation Launchpad hardware
- **Low Latency Design**: 1-5ms response time for responsive control
- **MIDI Clock Sync**: Pixel 6 pulses in time with Loopy Pro tempo
- **Hardware Acceleration**: Uses multiple cores efficiently without cross-core LED issues
## Hardware Requirements
### PCB/Layout Notes
- **ESP32-S3-WROOM-1** microcontroller
- **Daisy-chained 74HC165** shift registers for button inputs (16 buttons total)
- **2x RMT WS2812C** LED drivers for 10 programmable RGB pixels
- **ADC on GPIO4** for expression pedal input
- **WS2812 external LED** for boot animation (GPIO12)
### Required Components
- ESP32-S3 development board (ESP32-S3-WROOM-1 preferred)
- 74HC165 shift register (2x for 16 buttons)
- WS2812C LED strip (10 pixels)
- PWM/ DAC capable for expression pedal
- Level shifters if needed for input voltages
## Software Architecture
### Core Design
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ USB MIDI │ │ MIDI Task │ │ LED Driver │
│ (Core 1) │ │ (Core 0) │ │ (Core 0) │
│ - Loopy Pro │◄──►│ - Clock Parity │◄──►│ - Pixel Pulse │
│ recognition │ │ Detection │ │ on beats │
│ - Note/CC │ │ - MIDI update │◄──►│ - Button LED │
│ processing │ │ processing │ │ feedback │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌─────────────────┐
│ Expression │
│ Pedal (Core 0)│
│ - ADC Read │
│ - CC 4 output │
└─────────────────┘
```
### Key Subsystems
#### 1. USB MIDI Transport (Core 1)
- Adafruit TinyUSB library integration
- Proper VID/PID spoofing (Novation Launchpad X: 0x1235/0x0103)
- MIDI event parsing and routing to Core 0
- Re-enumeration support for stable device connection
#### 2. MIDI Task (Core 0)
- Core 0 exclusive for all FastLED operations (prevents crashes)
- MIDI clock (0xF8) detection and counting
- Beat detection using tick count / 24 (24 PPQN)
- Pixel 6 pulsing on every MIDI beat
- Button-to-CC message conversion
#### 3. LED Driver (Core 0)
- Per-pad color configuration (Launchpad X compatibility)
- Velocity-based color mapping
- Dim off-state for power efficiency
- Launchpad-style startup animation
#### 4. Expression Pedal (Core 0)
- ADC input on GPIO4 (calibrated min/max)
- CC 4 output on MIDI channel 1
- Hysteresis filtering for stable readings
- 5ms update interval for responsive control
#### 5. Switch Driver (Core 0)
- 16 buttons via daisy-chained 74HC165
- 5ms debounce for reliable input
- Direct button-to-CC mapping
- Per-pad LED feedback
## Configuration Options
### MIDI Mapping
The controller uses a Launchpad X-style mapping for intuitive control:
| Pad | Hardware Button | MIDI Channel | Note/Pitch | CC | Default Color |
|-----|-----------------|--------------|------------|----|---------------|
| 0-1 | 0-1 | 1 | 36-45 | - | Blue (0x0000FF) |
| 2-4 | 2-4 | 1 | 36-45 | - | Orange (0xFF6600) |
| 5 | 5 | 1 | 36 | - | Red (0xFF0000) |
| 6 | 6 | 1 | 36 | - | White (0xFFFFFF) |
| 7 | 7 | 1 | 36 | - | Amber (0xFFBF00) |
| 8-9 | 8-9 | 1 | 36-37 | - | Purple (0x9900FF) |
**For Loopy Pro Users:**
- Buttons generate CC messages (not Note On/Off)
- CC values: 2-11 for buttons 0-9
- CC 4 for expression pedal
- Pixel colors provide visual feedback
### MIDI Clock Synchronization
Pixel 6 provides visual feedback synchronized to Loopy Pro's tempo:
1. **Configure Loopy Pro**: Enable MIDI Clock output targeting JOC Midi
2. **Device Recognition**: Loopy Pro detects the VID/PID (0x1235/0x0103)
3. **Visual Feedback**: Pixel 6 pulses white on each MIDI Clock beat (0xF8)
4. **Pulse Animation**: Quadratic fade (80ms duration) between beats
**Troubleshooting**: If pixel 6 doesn't pulse:
- Verify Loopy Pro has MIDI Clock enabled
- Ensure JOC Midi is the clock target
- Check MIDI device permissions
- Verify MIDI input in Loopy Pro shows "JOC Midi"
### PlatformIO Configuration
#### Build Options (`platformio.ini`)
```ini
[env]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
build_type = release
monitor_speed = 115200
upload_speed = 921600
build_flags =
-DCORE_DEBUG_LEVEL=0
-DARDUINO_USB_MODE=0
-DARDUINO_USB_LAUNCHER_MODE=0
lib_deps =
adafruit/[email protected]
https://github.com/FastLED/FastLED/archive/refs/tags/3.6.0.zip
extra_scripts =
pre_build.py
extra_script.py
```
#### Build Flags Explanation
- `-DARDUINO_USB_MODE=0`: USB in CDC/MIDI mode (no virtual serial)
- `-DARDUINO_USB_LAUNCHER_MODE=0`: No USB launcher mode
- `-DCORE_DEBUG_LEVEL=0`: Disable debug output
- `--allow-multiple-definition`: Required for Adafruit TinyUSB compatibility
#### Build Scripts
**`pre_build.py`**: Patches board definitions with Launchpad X VID/PID
**`extra_script.py`**: Ensures proper TinyUSB linking order
## Usage
### Initial Setup
1. **Flash Firmware**
```bash
platformio run --target upload
```
2. **Open Serial Monitor** (115200 baud)
- Shows startup sequence
- MIDI activity diagnostics
- System status
3. **Available Commands** (type in serial)
- `help` - Show all commands
- `dump` - Display button states
- `probe` - Hardware diagnostics
- `ledon`/`ledoff` - Turn all LEDs on/off
- `ledtest` - Color cycle test
- `exp` - Show expression pedal ADC/value
- `usb` - USB status
- `gpiotest` - Raw GPIO diagnostics
- `miditest` - Simulate MIDI input
- `padtest` - Test individual pads
- `mapping` - Show current pad mapping
### MIDI Configuration in Loopy Pro
1. **System Settings**
- Name: "JOC Midi"
- Manufacturer: "JOC"
- Model: "JOC Midi"
2. **MIDI Setup**
- Port 1: Enabled
- Input Channel: All
- Output Channel: 1 (or preferred)
3. **Sync Configuration**
- Sync Master: LOOPY (if Loopy Pro is master)
- Clock Output: Enabled
- Clock Targets: JOC Midi
### Operation
1. **Button Presses**
- Press any button to send corresponding CC message
- Pixel color changes to match button state
2. **Expression Pedal**
- Connect foot pedal to ADC input
- Calibrate min (heel) and max (toe) positions
- Watch pixel 5 for pedal value feedback
3. **Visual Feedback**
- Buttons: Color indicates CC state
- Expression pedal: Pixel 5 brightness reflects CC value
- Sync: Pixel 6 pulses with MIDI Clock
## Customization
### Adding New MIDI Functions
1. **Add New CC Mappings**
```cpp
// In switch_stub.h or app_task.cpp
static const uint8_t BUTTONS_TO_CC[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
```
2. **Custom LED Patterns**
```cpp
// In led_stub.cpp
void apply_custom_color(uint8_t index, uint8_t velocity) {
if (index == 6) { // Example: Custom color for expression pedal
mux_ptr->set_led_color(index, 0, 255, 0); // Green
return;
}
apply_pad_color(index, velocity);
}
```
### Changing MIDI Clock Behavior
1. **Modify Beat Detection**
```cpp
// In main.cpp, midi_task
uint32_t beat_interval = 24; // PPQN for MIDI Clock
if (tick / beat_interval != last_beat) {
last_beat = tick / beat_interval;
// Trigger pulse
}
```
2. **Change Pulse Animation**
```cpp
// In main.cpp, midi_task
float pulse_speed = 1.0f / 1.0; // Adjust pulse speed
mux.set_led_color(6, v * pulse_speed, v * pulse_speed, v * pulse_speed);
```
### Modifying Expression Pedal
1. **Change ADC Pin**
```cpp
// In expression_pedal.h/expression_pedal.cpp
static const uint8_t EXP_PEDAL_PIN = 4; // or another GPIO
```
2. **Adjust Calibration**
```cpp
// In main.cpp
default exp_pedal.cal_min(0); // Set based on testing
default exp_pedal.cal_max(1023); // Set based on testing
```
3. **Change MIDI CC**
```cpp
// In expression_pedal.cpp
send_cc(1, 4, value); // Keep existing
// Or change:
send_cc(1, 7, value); // Volume instead of expression
```
### Custom Pad Layout
1. **Map Pads Differently**
```cpp
// In app_task.h/app_task.cpp
#define PAD_NOTE_MAPPING {36, 37, 38, 39, 40, 41, 42, 43, 44, 45}
#define PAD_CC_MAPPING {2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
```
2. **Add Special Functions**
```cpp
// In app_task.cpp
void process_special_button(uint8_t led_index) {
if (led_index == 5) { // Expression pedal button
// Toggle pedal mode
}
if (led_index == 6) { // MIDI Clock toggle
// Toggle clock visualization
}
}
```
## Testing and Diagnostics
### Serial Commands for Debugging
| Command | Description |
|---------|-------------|
| `probe` | Test all buttons and LEDs |
| `gpiotest` | Check GPIO pin states |
| `ledtest` | Cycle through all LED colors |
| `miditest` | Simulate MIDI input for testing |
| `dump` | Show current button states |
### Troubleshooting Common Issues
#### Issue: Pixel 6 doesn't pulse
**Cause**: MIDI Clock not arriving
**Solution**:
1. Check Loopy Pro sync settings
2. Verify MIDI Clock target is JOC Midi
3. Examine `[CLK] !! NO MIDI CLOCK RECEIVED !!` in serial
#### Issue: Buttons not sending MIDI
**Cause**: Shift register issues
**Solution**:
1. Run `probe` command
2. Check wiring and pin connections
3. Verify shift register operation
#### Issue: Expression pedal unresponsive
**Cause**: ADC calibration incorrect
**Solution**:
1. Use `exp` command to see raw ADC values
2. Adjust calibration min/max values
3. Check voltage range and connection
#### Issue: USB connection drops
**Cause**: Re-enumeration issues
**Solution**:
1. Check mounted() status in `[MIDI] USB mounted:` messages
2. Ensure proper VID/PID values in build
3. Verify TinyUSB initialization
## Build Instructions
### Prerequisites
1. **PlatformIO IDE** or
2. **Arduino CLI** with ESP32 core support
### Quick Build
```bash
# Using PlatformIO
platformio run
# Using Arduino CLI
cd your-project
arduino-cli compile --builder chitrak/micropython-builder --fqbn esp32-s3-devkitc-1 .
```
### Upload
```bash
# Using PlatformIO
platformio upload
# Using Arduino CLI
arduino-cli upload -p /dev/ttyUSB0 --fqbn esp32-s3-devkitc-1 .
```
### Troubleshooting Build Issues
#### Common Build Errors
1. **Adafruit TinyUSB conflicts**
- Solution: Use the provided `extra_script.py` with `--allow-multiple-definition`
- Ensure build_flags match exactly
2. **Memory Issues**
- Solution: Reduce debug output, use release build type
- Check stack sizes in platformio.ini
3. **Pin Conflicts**
- Solution: Adjust GPIO pins in configuration files
- Verify all components use different pins
#### Hardware Issues
1. **LED Driver Problems**
- Solution: Test with simple color output commands
- Check wiring and power supply
2. **Button Issues**
- Solution: Use `probe` command regularly
- Ensure proper pull-up/pull-down configurations
## Future Enhancements
### Planned Features
1. **USB MIDI Sysex Support**
- Launchpad X programmer mode
- Bank select and patch change
2. **Advanced Expression Pedal**
- Rotary encoding support
- Multiple pedal modes (CC1/CC4)
3. **Enhanced LED Effects**
- Breathing animations
- SOS patterns for diagnostics
- Battery level indicator
4. **Additional MIDI Functions**
- Pitch Bend support
- Aftertouch
- Poly Pressure
### Custom Configuration Examples
#### For Loopy Pro DJs
```cpp
// Loopy-specific CC mappings
static const uint8_t LOOPY_CC_MAP[10] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
```
#### For External Controllers
```cpp
// CC-based interaction
static const uint8_t EXTERNAL_CC_TARGET = 1;
send_cc(EXTERNAL_CC_TARGET, 7, value); // Volume
```
#### For Recording Studios
```cpp
// Note-based for drum triggers
static const uint8_t DRUM_NOTES[10] = {36, 38, 40, 42, 44, 45, 47, 48, 50, 52};
```
## License
This project is provided as-is with no explicit license. The code is intended for educational and personal use. Modifications and distributions should respect original authors' intentions where specified.
## Acknowledgments
- **Novation Launchpad**: Inspiration for layout and color scheme
- **ESP32-S3**: Powerful microcontroller with native USB MIDI
- **Adafruit TinyUSB**: Reliable USB MIDI stack
- **FastLED**: Efficient LED control library
- **Contributors**: All who tested and provided feedback
## Contact
For issues or questions:
1. Check the project documentation
2. Review serial output for diagnostic messages
3. Use available testing commands
4. Submit issues with complete build logs if encountering problems
+9 -2
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include "midi_transport.h"
#include "ble_midi_transport.h"
#include "led_stub.h"
#include "switch_stub.h"
@@ -14,7 +15,7 @@ struct PadMapping {
class AppTask {
public:
AppTask(LedStub* led, SwitchStub* sw, UsbMidiTransport* midi);
AppTask(LedStub* led, SwitchStub* sw, UsbMidiTransport* usb_midi, BleMidiTransport* ble_midi = nullptr);
void begin();
void update();
@@ -23,7 +24,8 @@ public:
private:
LedStub* led_driver;
SwitchStub* switch_driver;
UsbMidiTransport* midi_transport;
UsbMidiTransport* usb_midi;
BleMidiTransport* ble_midi;
static const uint8_t NUM_PADS = 10;
PadMapping pad_mapping[NUM_PADS];
@@ -36,6 +38,11 @@ private:
uint8_t sysex_len = 0;
bool sysex_active = false;
bool pending_cc = false;
uint8_t pending_channel = 0;
uint8_t pending_cc_num = 0;
uint8_t pending_value = 0;
void process_switch_event(uint8_t switch_id, bool pressed);
void run_palette_test();
void handle_sysex(const uint8_t* data, uint8_t len);
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
#include <functional>
#include "midi_transport.h"
class BleMidiTransport {
public:
BleMidiTransport();
~BleMidiTransport();
bool begin();
void update();
void on_midi_receive(std::function<void(const MidiEvent&)> callback);
void send_note_on(uint8_t channel, uint8_t note, uint8_t velocity);
void send_note_off(uint8_t channel, uint8_t note, uint8_t velocity);
void send_cc(uint8_t channel, uint8_t cc, uint8_t value);
bool is_connected();
void send_midi_packet(const uint8_t* data, uint8_t len);
void on_receive(const uint8_t* data, size_t len);
size_t parse_ble_midi(uint8_t status, const uint8_t* data, size_t len, size_t offset, MidiEvent& event);
private:
std::function<void(const MidiEvent&)> receive_callback;
bool initialized;
bool client_connected;
};
-36
View File
@@ -1,36 +0,0 @@
#pragma once
#include <cstdint>
class UsbMidiTransport;
class ExpressionPedal {
public:
ExpressionPedal(uint8_t adc_pin = 5);
void begin();
void update(UsbMidiTransport& midi);
uint8_t get_value() const { return current_value; }
uint16_t get_raw() const { return current_raw; }
void set_cal_min();
void set_cal_max();
uint16_t get_cal_min() const { return cal_min; }
uint16_t get_cal_max() const { return cal_max; }
private:
uint8_t adc_pin;
uint8_t current_value;
uint8_t last_sent_value;
uint8_t midi_channel;
uint8_t midi_cc;
static const uint8_t HYSTERESIS = 3;
static const uint16_t READ_INTERVAL_MS = 5;
uint32_t last_read_time;
uint16_t current_raw;
uint16_t cal_min;
uint16_t cal_max;
uint8_t adc_to_midi(uint16_t adc_value);
};
-4
View File
@@ -3,10 +3,6 @@
#include <cstdint>
#include <functional>
extern volatile uint32_t midi_tick_count;
extern volatile uint16_t last_spp_position;
extern volatile bool spp_valid;
struct MidiEvent {
enum Type {
NOTE_ON,
+3
View File
@@ -8,6 +8,7 @@ framework = arduino
lib_deps =
adafruit/Adafruit TinyUSB [email protected]
fastled/FastLED@^3.9.0
h2zero/NimBLE-Arduino@^1.4.0
build_unflags =
-DARDUINO_USB_MODE=1
@@ -16,6 +17,8 @@ build_flags =
-DARDUINO_USB_MODE=0
-DARDUINO_USB_CDC_ON_BOOT=1
-DUSE_TINYUSB=1
-Wno-macro-redefined
-DMYNEWT_VAL_BLE_EXT_ADV=0
monitor_speed = 115200
+169 -3
View File
@@ -1,5 +1,6 @@
import os
import fileinput
import re
def patch_usb_ids():
# Find the core's pins_arduino.h for ESP32-S3 DevKitC-1
@@ -15,7 +16,7 @@ def patch_usb_ids():
pins_file = os.path.join(core_variants, "esp32s3", "pins_arduino.h")
if os.path.exists(pins_file):
print(f"Patching {pins_file} with Launchpad X VID/PID")
print(f"Patching {pins_file} with JOC Midi USB descriptors")
# Read and replace
with open(pins_file, 'r') as f:
@@ -46,4 +47,169 @@ def patch_usb_ids():
else:
print(f"WARNING: Could not find pins_arduino.h at {pins_file}")
patch_usb_ids()
def patch_sdkconfig_bt():
# Find the precompiled sdkconfig.h for this board variant
sdk_dir = os.path.expanduser(
"~/.platformio/packages/framework-arduinoespressif32/tools/sdk/esp32s3/qio_opi/include"
)
alt_name = os.path.join(os.environ.get("PLATFORMIO_PACKAGES_DIR", ""),
"framework-arduinoespressif32/tools/sdk/esp32s3/qio_opi/include")
if not os.path.exists(sdk_dir):
sdk_dir = alt_name
sdkconfig_file = os.path.join(sdk_dir, "sdkconfig.h")
if not os.path.exists(sdkconfig_file):
print(f"WARNING: sdkconfig.h not found at {sdkconfig_file}")
return
print(f"Patching {sdkconfig_file} with BLE/NimBLE support")
with open(sdkconfig_file, 'r') as f:
content = f.read()
defines = {
"CONFIG_BT_ENABLED": 1,
"CONFIG_BTDM_CTRL_MODE_BLE_ONLY": 1,
"CONFIG_BT_NIMBLE_MAX_CONNECTIONS": 1,
"CONFIG_BT_NIMBLE_TASK_STACK_SIZE": 6144,
}
changed = False
for name, value in defines.items():
pattern = re.compile(
r'^[#/]*\s*#?\s*(define\s+' + re.escape(name) + r'\b).*$',
re.MULTILINE
)
if pattern.search(content):
# Already defined, replace the line
content = pattern.sub(r'#define ' + name + ' ' + str(value), content)
changed = True
elif f'#define {name}' not in content:
# Not present at all, add it
content += f'\n#define {name} {value}'
changed = True
if changed:
with open(sdkconfig_file, 'w') as f:
f.write(content)
print("BLE/NimBLE sdkconfig patched successfully")
else:
print("BLE/NimBLE already configured in sdkconfig")
def patch_nimconfig():
# Find nimconfig.h in the NimBLE-Arduino library
search_dirs = [
os.path.join(os.getcwd(), ".pio", "libdeps"),
os.path.expanduser("~/.platformio/lib"),
]
for env_dir in ["esp32s3", ""]:
candidate = os.path.join(
os.getcwd(), ".pio", "libdeps",
env_dir, "NimBLE-Arduino", "src", "nimconfig.h"
) if env_dir else ""
if candidate and os.path.exists(candidate):
nimconfig_path = candidate
break
else:
# Walk search dirs as fallback
nimconfig_path = None
for base in search_dirs:
if not os.path.exists(base):
continue
for root, _dirs, files in os.walk(base):
if "nimconfig.h" in files:
nimconfig_path = os.path.join(root, "nimconfig.h")
break
if nimconfig_path:
break
if not nimconfig_path:
print("WARNING: nimconfig.h not found, skipping ROLE define patch")
return
print(f"Patching {nimconfig_path} with #ifndef guards for ROLE defines")
with open(nimconfig_path, 'r') as f:
content = f.read()
for role in ["CENTRAL", "OBSERVER", "PERIPHERAL", "BROADCASTER"]:
pattern = re.compile(
'#ifndef CONFIG_BT_NIMBLE_ROLE_' + role + '_DISABLED\n'
'#define CONFIG_BT_NIMBLE_ROLE_' + role + '\n'
'#endif'
)
replacement = (
'#ifndef CONFIG_BT_NIMBLE_ROLE_' + role + '_DISABLED\n'
'#ifndef CONFIG_BT_NIMBLE_ROLE_' + role + '\n'
'#define CONFIG_BT_NIMBLE_ROLE_' + role + '\n'
'#endif\n'
'#endif'
)
if pattern.search(content):
content = pattern.sub(replacement, content)
print(f" Guarded CONFIG_BT_NIMBLE_ROLE_{role}")
else:
print(f" NOTE: CONFIG_BT_NIMBLE_ROLE_{role} pattern not found (may already be guarded)")
with open(nimconfig_path, 'w') as f:
f.write(content)
print("nimconfig.h patched successfully")
def patch_nimble_device():
search_dirs = [
os.path.join(os.getcwd(), ".pio", "libdeps"),
os.path.expanduser("~/.platformio/lib"),
]
dev_path = None
for base in search_dirs:
if not os.path.exists(base):
continue
for root, _dirs, files in os.walk(base):
if "NimBLEDevice.cpp" in files:
dev_path = os.path.join(root, "NimBLEDevice.cpp")
break
if dev_path:
break
if not dev_path:
print("WARNING: NimBLEDevice.cpp not found, skipping controller init patch")
return
print(f"Patching {dev_path} to handle double BT controller init")
with open(dev_path, 'r') as f:
content = f.read()
# Arduino framework inits controller but does NOT enable it.
# Handle both init outcomes: skip if already initialized, but always try to enable.
patterns = [
(r'(\s*)ESP_ERROR_CHECK\(esp_bt_controller_init\(&bt_cfg\)\);\s*'
r'\1ESP_ERROR_CHECK\(esp_bt_controller_enable\(ESP_BT_MODE_BLE\)\);\s*'
r'\1ESP_ERROR_CHECK\(esp_nimble_hci_init\(\)\);',
r'\1esp_err_t __bt_err = esp_bt_controller_init(&bt_cfg);\n'
r'\1if (__bt_err == ESP_OK || __bt_err == ESP_ERR_INVALID_STATE) {\n'
r'\1 ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE));\n'
r'\1}\n'
r'\1ESP_ERROR_CHECK(esp_nimble_hci_init());')
]
for pattern, replacement in patterns:
new_content = re.sub(pattern, replacement, content)
if new_content != content:
content = new_content
with open(dev_path, 'w') as f:
f.write(content)
print(" Patched esp_bt_controller_init to skip if already initialized")
return
print(" WARNING: Could not find init pattern in NimBLEDevice.cpp")
patch_usb_ids()
patch_sdkconfig_bt()
patch_nimconfig()
patch_nimble_device()
+7
View File
@@ -0,0 +1,7 @@
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BTDM_CTRL_MODE_BLE_ONLY=y
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_TASK_STACK_SIZE=6144
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
+29 -22
View File
@@ -1,10 +1,8 @@
#include "app_task.h"
#include <Arduino.h>
extern volatile uint8_t beats_per_bar;
AppTask::AppTask(LedStub* led, SwitchStub* sw, UsbMidiTransport* midi)
: led_driver(led), switch_driver(sw), midi_transport(midi) {
AppTask::AppTask(LedStub* led, SwitchStub* sw, UsbMidiTransport* usb_midi, BleMidiTransport* ble_midi)
: led_driver(led), switch_driver(sw), usb_midi(usb_midi), ble_midi(ble_midi) {
// Launchpad X standard: bottom row = notes 36-45 (C2 to A2) on channel 1
const uint8_t launchpad_notes[10] = {36, 37, 38, 39, 40, 41, 42, 43, 44, 45};
@@ -22,11 +20,15 @@ AppTask::AppTask(LedStub* led, SwitchStub* sw, UsbMidiTransport* midi)
void AppTask::begin() {
Serial.println("[APP] Registering MIDI callbacks...");
midi_transport->on_midi_receive([this](const MidiEvent& event) {
auto handler = [this](const MidiEvent& event) {
process_midi_event(event);
});
};
usb_midi->on_midi_receive(handler);
if (ble_midi) {
ble_midi->on_midi_receive(handler);
}
Serial.println("[APP] Controller ready - CC mode");
for (uint8_t i = 0; i < NUM_PADS; i++) {
Serial.printf("[APP] Pad %d -> CC%d -> LED%d\n", i + 1, cc_map[i], i);
@@ -38,13 +40,22 @@ void AppTask::update() {
bool is_pressed = switch_driver->is_pressed(i);
if (is_pressed && !last_switch_state[i]) {
Serial.printf("[APP] Switch %d pressed\n", i);
process_switch_event(i, true);
last_switch_state[i] = true;
} else if (!is_pressed && last_switch_state[i]) {
Serial.printf("[APP] Switch %d released\n", i);
process_switch_event(i, false);
last_switch_state[i] = false;
}
}
if (pending_cc) {
pending_cc = false;
delay(1);
usb_midi->send_cc(pending_channel, pending_cc_num, pending_value);
if (ble_midi) ble_midi->send_cc(pending_channel, pending_cc_num, pending_value);
}
}
void AppTask::process_midi_event(const MidiEvent& event) {
@@ -126,28 +137,24 @@ void AppTask::process_midi_event(const MidiEvent& event) {
}
void AppTask::process_switch_event(uint8_t switch_id, bool pressed) {
// Time signature combo: hold pad 10 (switch 9) + press pad 1/2/3
if (switch_id <= 2 && switch_driver->is_pressed(9)) {
if (pressed) {
switch (switch_id) {
case 0: beats_per_bar = 4; Serial.println("[APP] Time sig: 4/4"); break;
case 1: beats_per_bar = 3; Serial.println("[APP] Time sig: 3/4"); break;
case 2: beats_per_bar = 6; Serial.println("[APP] Time sig: 6/4"); break;
}
}
return; // suppress CC in combo mode
}
for (uint8_t i = 0; i < NUM_PADS; i++) {
if (pad_mapping[i].physical_switch == switch_id) {
uint8_t channel = pad_mapping[i].midi_channel;
uint8_t cc_num = cc_map[i];
// Use palette index 127 (magenta) for visible feedback
uint8_t value = pressed ? 127 : 0;
Serial.printf("[APP] Switch %d -> Ch%d CC%d Val%d (%s)\n",
switch_id, channel, cc_num, value,
pressed ? "PRESS" : "RELEASE");
if (pressed) {
midi_transport->send_cc(channel, cc_num, value);
// Send MIDI via a flag that loop() processes
pending_cc = true;
pending_channel = channel;
pending_cc_num = cc_num;
pending_value = value;
}
break;
}
}
+247
View File
@@ -0,0 +1,247 @@
#include "ble_midi_transport.h"
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <esp_bt.h>
#include <esp_bt_main.h>
#include <esp_bt_device.h>
#define BLE_MIDI_SERVICE_UUID "03B80E5A-EDE8-4B33-A751-6CE34EC4C700"
#define BLE_MIDI_CHAR_UUID "7772E5DB-3868-4112-A1A9-F2669D106BF3"
static BleMidiTransport* instance = nullptr;
static NimBLEServer* ble_server = nullptr;
static NimBLEService* ble_service = nullptr;
static NimBLECharacteristic* ble_char = nullptr;
static bool device_connected = false;
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* server) override {
device_connected = true;
Serial.println("[BLE] Client connected");
}
void onDisconnect(NimBLEServer* server) override {
device_connected = false;
Serial.println("[BLE] Client disconnected, restarting advertising");
NimBLEDevice::startAdvertising();
}
};
class CharCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic* characteristic) override {
std::string value = characteristic->getValue();
if (value.length() > 0 && instance) {
instance->on_receive((const uint8_t*)value.data(), value.length());
}
}
};
BleMidiTransport::BleMidiTransport() : initialized(false), client_connected(false) {
instance = this;
}
BleMidiTransport::~BleMidiTransport() {
if (instance == this) instance = nullptr;
}
bool BleMidiTransport::begin() {
Serial.println("[BLE] Initializing BLE MIDI...");
Serial.println("[BLE] NimBLEDevice init...");
esp_bt_controller_status_t ctrl_status = esp_bt_controller_get_status();
Serial.printf("[BLE] BT controller status before init: %d (0=IDLE,1=INITED,2=ENABLED)\n", ctrl_status);
NimBLEDevice::init("JOC Midi");
Serial.println("[BLE] NimBLEDevice initialized");
ctrl_status = esp_bt_controller_get_status();
Serial.printf("[BLE] BT controller status after init: %d\n", ctrl_status);
const uint8_t* mac = esp_bt_dev_get_address();
if (mac) {
Serial.printf("[BLE] Device MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
} else {
Serial.println("[BLE] Device MAC: NOT AVAILABLE");
}
Serial.println("[BLE] Creating server...");
ble_server = NimBLEDevice::createServer();
if (!ble_server) {
Serial.println("[BLE] FAILED to create server");
return false;
}
Serial.println("[BLE] Server created, setting callbacks...");
ble_server->setCallbacks(new ServerCallbacks());
Serial.println("[BLE] Creating service...");
ble_service = ble_server->createService(BLE_MIDI_SERVICE_UUID);
if (!ble_service) {
Serial.println("[BLE] FAILED to create service");
return false;
}
Serial.println("[BLE] Creating characteristic...");
Serial.flush();
ble_char = ble_service->createCharacteristic(
BLE_MIDI_CHAR_UUID,
NIMBLE_PROPERTY::READ |
NIMBLE_PROPERTY::WRITE_NR |
NIMBLE_PROPERTY::NOTIFY
);
Serial.println("[BLE] characteristic pointer ok");
if (!ble_char) {
Serial.println("[BLE] FAILED to create characteristic");
return false;
}
// 0x2902 (CCCD) is auto-created by NimBLE stack when characteristic
// has NOTIFY or INDICATE property - do NOT manually create it.
Serial.println("[BLE] Setting callbacks...");
ble_char->setCallbacks(new CharCallbacks());
Serial.println("[BLE] Callbacks set");
Serial.println("[BLE] Starting service...");
ble_service->start();
Serial.println("[BLE] Service started");
Serial.println("[BLE] Starting advertising...");
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
adv->addServiceUUID(BLE_MIDI_SERVICE_UUID);
adv->setScanResponse(true);
adv->setMinInterval(160); // 100ms (160 * 0.625ms)
adv->setMaxInterval(320); // 200ms (320 * 0.625ms)
bool adv_started = adv->start();
Serial.printf("[BLE] adv->start() returned: %d\n", adv_started);
vTaskDelay(pdMS_TO_TICKS(500));
if (adv->isAdvertising()) {
Serial.println("[BLE] Advertising confirmed started");
} else {
Serial.println("[BLE] WARNING: isAdvertising() reports false!");
}
initialized = true;
Serial.println("[BLE] BLE MIDI advertising as 'JOC Midi'");
return true;
}
void BleMidiTransport::update() {
client_connected = device_connected;
}
void BleMidiTransport::on_midi_receive(std::function<void(const MidiEvent&)> callback) {
receive_callback = callback;
}
void BleMidiTransport::send_midi_packet(const uint8_t* data, uint8_t len) {
if (!initialized || !client_connected || !ble_char) return;
uint16_t timestamp = micros() & 0x3FFF;
uint8_t packet[16];
uint8_t idx = 0;
packet[idx++] = 0x80 | (timestamp >> 7);
packet[idx++] = timestamp & 0x7F;
for (uint8_t i = 0; i < len && idx < 16; i++) {
packet[idx++] = data[i];
}
ble_char->setValue(packet, idx);
ble_char->notify();
}
void BleMidiTransport::send_note_on(uint8_t channel, uint8_t note, uint8_t velocity) {
uint8_t packet[3] = {(uint8_t)(0x90 | (channel - 1)), note, velocity};
send_midi_packet(packet, 3);
Serial.printf("[BLE OUT] Ch:%d NOTE_ON:%d:%d\n", channel, note, velocity);
}
void BleMidiTransport::send_note_off(uint8_t channel, uint8_t note, uint8_t velocity) {
uint8_t packet[3] = {(uint8_t)(0x80 | (channel - 1)), note, velocity};
send_midi_packet(packet, 3);
Serial.printf("[BLE OUT] Ch:%d NOTE_OFF:%d:%d\n", channel, note, velocity);
}
void BleMidiTransport::send_cc(uint8_t channel, uint8_t cc, uint8_t value) {
uint8_t packet[3] = {(uint8_t)(0xB0 | (channel - 1)), cc, value};
send_midi_packet(packet, 3);
}
bool BleMidiTransport::is_connected() {
return initialized && client_connected;
}
void BleMidiTransport::on_receive(const uint8_t* data, size_t len) {
if (len < 3 || !receive_callback) return; // need timestamp(2) + status(1)
// BLE MIDI format: [ts_hi|0x80] [ts_lo] [status ...]
size_t offset = 2; // skip timestamp header
while (offset < len) {
uint8_t status = data[offset++];
if (status < 0x80) continue; // skip non-status bytes
uint8_t type = status & 0xF0;
uint8_t channel = (status & 0x0F) + 1;
MidiEvent event;
event.channel = channel;
event.timestamp = millis();
event.type = MidiEvent::NOTE_ON; // default
offset = parse_ble_midi(status, data, len, offset, event);
if (event.type == MidiEvent::SYSEX) {
Serial.printf("[BLE IN] Sysex len=%zu\n", len);
} else {
Serial.printf("[BLE IN] Ch:%d %s:%d:%d\n",
event.channel,
event.type == MidiEvent::NOTE_ON ? "NOTE_ON" :
event.type == MidiEvent::NOTE_OFF ? "NOTE_OFF" :
event.type == MidiEvent::CONTROL_CHANGE ? "CC" : "OTHER",
event.data1, event.data2);
}
if (receive_callback) {
receive_callback(event);
}
}
}
size_t BleMidiTransport::parse_ble_midi(uint8_t status, const uint8_t* data, size_t len, size_t offset, MidiEvent& event) {
uint8_t type = status & 0xF0;
event.data1 = 0;
event.data2 = 0;
if (offset >= len) return offset;
event.data1 = data[offset++];
switch (type) {
case 0x80:
event.type = MidiEvent::NOTE_OFF;
if (offset < len) event.data2 = data[offset++];
break;
case 0x90:
event.type = MidiEvent::NOTE_ON;
if (offset < len) event.data2 = data[offset++];
if (event.data2 == 0) event.type = MidiEvent::NOTE_OFF;
break;
case 0xB0:
event.type = MidiEvent::CONTROL_CHANGE;
if (offset < len) event.data2 = data[offset++];
break;
case 0xC0:
event.type = MidiEvent::PROGRAM_CHANGE;
break;
case 0xE0:
event.type = MidiEvent::PITCH_BEND;
if (offset < len) event.data2 = data[offset++];
break;
default:
break;
}
return offset;
}
-70
View File
@@ -1,70 +0,0 @@
#include "expression_pedal.h"
#include "midi_transport.h"
#include <Arduino.h>
ExpressionPedal::ExpressionPedal(uint8_t adc_pin)
: adc_pin(adc_pin)
, current_value(0)
, last_sent_value(255)
, midi_channel(1)
, midi_cc(4)
, last_read_time(0)
, current_raw(0)
, cal_min(36)
, cal_max(1180)
{
}
void ExpressionPedal::begin() {
Serial.printf("[EXP] Expression pedal on ADC1_CH3 (GPIO%d)\n", adc_pin);
analogReadResolution(12);
pinMode(adc_pin, INPUT);
delay(10);
current_raw = analogRead(adc_pin);
current_value = adc_to_midi(current_raw);
last_sent_value = current_value;
Serial.printf("[EXP] Starting: raw ADC=%d -> MIDI=%d (cal: %d-%d)\n",
current_raw, current_value, cal_min, cal_max);
Serial.printf("[EXP] Move pedal full range to see raw values\n");
}
void ExpressionPedal::update(UsbMidiTransport& midi) {
uint32_t now = millis();
if (now - last_read_time < READ_INTERVAL_MS) return;
last_read_time = now;
current_raw = analogRead(adc_pin);
uint8_t new_value = adc_to_midi(current_raw);
if (new_value > current_value + HYSTERESIS ||
new_value + HYSTERESIS < current_value ||
(new_value != current_value && (new_value == 0 || new_value == 127))) {
current_value = new_value;
}
if (current_value != last_sent_value) {
last_sent_value = current_value;
midi.send_cc(midi_channel, midi_cc, current_value);
Serial.printf("[EXP] CC%d: %d (raw ADC: %d)\n", midi_cc, current_value, current_raw);
}
}
void ExpressionPedal::set_cal_min() {
cal_min = current_raw;
if (cal_min >= cal_max) cal_max = cal_min + 1;
Serial.printf("[EXP] cal_min set to %d (ADC raw)\n", cal_min);
}
void ExpressionPedal::set_cal_max() {
cal_max = current_raw;
if (cal_max <= cal_min) cal_min = cal_max - 1;
Serial.printf("[EXP] cal_max set to %d (ADC raw)\n", cal_max);
}
uint8_t ExpressionPedal::adc_to_midi(uint16_t adc_value) {
if (adc_value <= cal_min) return 0;
if (adc_value >= cal_max) return 127;
uint32_t span = cal_max - cal_min;
uint32_t offset = adc_value - cal_min;
return (uint8_t)(offset * 127 / span);
}
+7 -32
View File
@@ -142,35 +142,6 @@ static uint32_t velocity_to_color(uint8_t velocity) {
return launchpad_palette[velocity];
}
static const uint32_t pad_base_colors[10] = {
0x0000FF, // 0: Blue
0x0000FF, // 1: Blue
0xFF6600, // 2: Orange
0xFF6600, // 3: Orange
0xFF6600, // 4: Orange
0xFF0000, // 5: Red
0xFFFFFF, // 6: White
0xFFBF00, // 7: Amber
0x9900FF, // 8: Purple
0x9900FF, // 9: Purple
};
static void apply_pad_color(uint8_t index, uint8_t velocity) {
if (index >= 10) return;
uint32_t base = pad_base_colors[index];
uint8_t r = (base >> 16) & 0xFF;
uint8_t g = (base >> 8) & 0xFF;
uint8_t b = base & 0xFF;
if (velocity == 0) {
r = (uint16_t)r * 20 / 255;
g = (uint16_t)g * 20 / 255;
b = (uint16_t)b * 20 / 255;
}
mux_ptr->set_led_color(index, r, g, b);
}
DefaultLedStub::DefaultLedStub() : initialized(false) {
for (int i = 0; i < NUM_LEDS; i++) {
led_states[i].active = false;
@@ -232,14 +203,18 @@ void DefaultLedStub::begin() {
void DefaultLedStub::set_led_state(uint8_t note, uint8_t channel, uint8_t velocity, int8_t led_index) {
if (!initialized || !mux_ptr) return;
// Direct index mode (used by CC feedback / pad colors)
// Direct index mode (used by CC feedback)
if (led_index >= 0 && led_index < NUM_LEDS) {
led_states[led_index].active = (velocity > 0);
led_states[led_index].note = note;
led_states[led_index].channel = channel;
led_states[led_index].velocity = velocity;
led_states[led_index].timestamp = millis();
apply_pad_color(led_index, velocity);
uint32_t color = velocity_to_color(velocity);
uint8_t r = (color >> 16) & 0xFF;
uint8_t g = (color >> 8) & 0xFF;
uint8_t b = color & 0xFF;
mux_ptr->set_led_color(led_index, r, g, b);
mux_ptr->show();
Serial.printf("[LED] Set LED %d: note=%d ch=%d vel=%d\n", led_index, note, channel, velocity);
return;
@@ -290,7 +265,7 @@ void DefaultLedStub::clear_all() {
led_states[i].note = 0;
led_states[i].channel = 0;
led_states[i].timestamp = 0;
apply_pad_color(i, 0);
mux_ptr->set_led_color(i, 0, 0, 0);
}
mux_ptr->show();
Serial.println("[LED] All cleared");
+12 -57
View File
@@ -5,60 +5,30 @@
#include <driver/gpio.h>
#include <esp_rom_sys.h>
#include "midi_transport.h"
#include "ble_midi_transport.h"
#include "Adafruit_TinyUSB.h"
#include "pixel_stomp_mux.h"
#include "led_stub.h"
#include "switch_stub.h"
#include "app_task.h"
#include "expression_pedal.h"
PixelStompMux mux(12, 10, 11, 9);
DefaultLedStub led_driver;
DefaultSwitchStub switch_driver;
UsbMidiTransport midi_transport;
BleMidiTransport ble_midi_transport;
AppTask controller(&led_driver, &switch_driver, &midi_transport);
ExpressionPedal exp_pedal(4);
volatile uint32_t flash_latency = 55; // ms delay to compensate for Loopy Pro rendering
volatile uint8_t beats_per_bar = 4; // change to match your project's time signature
AppTask controller(&led_driver, &switch_driver, &midi_transport, &ble_midi_transport);
TaskHandle_t midi_task_handle = NULL;
void midi_task(void* parameter) {
Serial.println("[TASK] MIDI task started on core 0");
// Beat timing fixes for precise beat alignment - sync to real MIDI time, not hardcoded tempo
// UINT32_MAX ensures tick 0 (first 0xF8 after START) triggers a flash
uint32_t last_beat = UINT32_MAX;
uint32_t flash_start = 0;
while (true) {
midi_transport.update();
exp_pedal.update(midi_transport);
uint32_t tick = midi_tick_count;
uint32_t now = millis();
uint32_t current_beat = tick / 24;
if (current_beat != last_beat) {
last_beat = current_beat;
flash_start = now + flash_latency;
// All beats flash white
mux.set_led_color(6, 255, 255, 255);
}
if (flash_start > 0 && now >= flash_start) {
mux.show();
if (now - flash_start >= 50) {
mux.set_led_color(6, 20, 20, 20);
mux.show();
flash_start = 0;
}
}
ble_midi_transport.update();
vTaskDelay(1);
}
}
@@ -116,19 +86,6 @@ void handle_serial_command(const String& cmd) {
mux.set_led_color(1, 255, 255, 255);
mux.show();
Serial.println("[CMD] Pixel 1 WHITE (max brightness)");
} else if (cmd == "exp") {
Serial.printf("[CMD] EXP ADC=%d MIDI=%d\n",
exp_pedal.get_raw(), exp_pedal.get_value());
} else if (cmd == "latency") {
Serial.printf("[CMD] Current flash latency: %d ms\n", flash_latency);
} else if (cmd.startsWith("latency ")) {
int val = atoi(cmd.c_str() + 8);
if (val >= 0 && val <= 500) {
flash_latency = val;
Serial.printf("[CMD] Flash latency set to %d ms\n", flash_latency);
} else {
Serial.println("[CMD] Latency must be 0-500 ms");
}
} else if (cmd == "usb") {
Serial.printf("[CMD] USB mounted: %s\n", TinyUSBDevice.mounted() ? "YES" : "NO");
Serial.printf("[CMD] USB ready: %s\n", TinyUSBDevice.ready() ? "YES" : "NO");
@@ -172,9 +129,6 @@ void handle_serial_command(const String& cmd) {
Serial.println(" read - raw button read");
Serial.println(" red/green/blue - solid colour");
Serial.println(" pixel0/pixel1 - single pixel test");
Serial.println(" exp - expression pedal ADC/MIDI value");
Serial.println(" latency - show current flash latency");
Serial.println(" latency N - set flash latency to N ms (0-500)");
Serial.println(" usb - USB connection status and descriptor info");
Serial.println(" gpiotest - raw GPIO pin diagnostic");
Serial.println(" rawled - bit-bang WS2812 (no library)");
@@ -377,7 +331,7 @@ void setup() {
Serial.println("=================================");
Serial.println(" Loopy MIDI Controller v0.1");
Serial.println(" Phase 1: USB MIDI + Expression Pedal");
Serial.println(" Phase 1.5: USB + BLE MIDI");
Serial.println(" Board: ESP32-S3-WROOM-1");
Serial.println("=================================");
@@ -392,12 +346,13 @@ void setup() {
switch_driver.set_mux(&mux);
switch_driver.begin();
Serial.println("[INIT] Initializing USB MIDI...");
Serial.println("[INIT] Initializing USB MIDI first (before BLE)...");
midi_transport.begin();
Serial.println("[INIT] Initializing Expression Pedal...");
exp_pedal.begin();
delay(1000);
Serial.println("[INIT] Initializing BLE MIDI...");
ble_midi_transport.begin();
Serial.println("[INIT] Registering MIDI callbacks...");
controller.begin();
@@ -429,5 +384,5 @@ void loop() {
handle_serial_command(cmd);
}
delay(1);
delay(10);
}
+16 -63
View File
@@ -2,11 +2,6 @@
#include <Arduino.h>
#include "Adafruit_TinyUSB.h"
volatile uint32_t midi_tick_count = 0;
volatile uint16_t last_spp_position = 0;
volatile bool spp_valid = false;
extern volatile uint8_t beats_per_bar;
static Adafruit_USBD_MIDI usb_midi;
UsbMidiTransport::UsbMidiTransport() : initialized(false) {
@@ -53,68 +48,25 @@ void UsbMidiTransport::update() {
TinyUSBDevice.mounted() ? "YES" : "NO");
}
if (usb_midi.available()) {
while (usb_midi.available()) {
uint8_t packet[4];
if (usb_midi.readPacket(packet)) {
uint8_t cin = packet[0] & 0x0F;
if (cin == 0x0F) {
if (packet[1] == 0xF8) {
midi_tick_count++;
} else if (packet[1] == 0xFA) {
if (spp_valid) {
midi_tick_count = last_spp_position * 6;
spp_valid = false;
Serial.printf("[CLK] START at SPP=%d -> tick %d\n", last_spp_position, midi_tick_count);
} else {
midi_tick_count = 0xFFFFFFFF;
Serial.println("[CLK] START (no SPP) - next F8 = tick 0");
}
} else if (packet[1] == 0xFB) {
// CONTINUE - same as START for our purposes
if (spp_valid) {
midi_tick_count = last_spp_position * 6;
spp_valid = false;
Serial.printf("[CLK] CONTINUE at SPP=%d -> tick %d\n", last_spp_position, midi_tick_count);
} else {
Serial.println("[CLK] CONTINUE (no SPP)");
}
}
} else if (cin == 0x03 && packet[1] == 0xF2) {
// Song Position Pointer
uint16_t prev_spp = last_spp_position;
last_spp_position = (packet[3] << 7) | packet[2];
spp_valid = true;
Serial.printf("[CLK] SPP=%d\n", last_spp_position);
MidiEvent event;
parse_midi_packet(packet, 4, event);
// Auto-detect time signature from SPP delta (sent at bar boundaries)
if (prev_spp > 0 && last_spp_position > prev_spp) {
uint16_t delta = last_spp_position - prev_spp;
if (delta >= 8 && delta <= 64 && delta % 4 == 0) {
uint8_t detected = delta / 4;
if (detected >= 2 && detected <= 16 && detected != beats_per_bar) {
beats_per_bar = detected;
Serial.printf("[CLK] Auto-detected %d/4 time from SPP delta=%d\n", detected, delta);
}
}
}
} else {
MidiEvent event;
parse_midi_packet(packet, 4, event);
const char* type_str = "UNK";
switch (event.type) {
case MidiEvent::NOTE_ON: type_str = "NOTE_ON"; break;
case MidiEvent::NOTE_OFF: type_str = "NOTE_OFF"; break;
case MidiEvent::CONTROL_CHANGE: type_str = "CC"; break;
case MidiEvent::PROGRAM_CHANGE: type_str = "PC"; break;
case MidiEvent::PITCH_BEND: type_str = "PB"; break;
default: break;
}
Serial.printf("[MIDI IN] Ch:%d %s:%d:%d\n", event.channel, type_str, event.data1, event.data2);
const char* type_str = "UNK";
switch (event.type) {
case MidiEvent::NOTE_ON: type_str = "NOTE_ON"; break;
case MidiEvent::NOTE_OFF: type_str = "NOTE_OFF"; break;
case MidiEvent::CONTROL_CHANGE: type_str = "CC"; break;
case MidiEvent::PROGRAM_CHANGE: type_str = "PC"; break;
case MidiEvent::PITCH_BEND: type_str = "PB"; break;
default: break;
}
Serial.printf("[MIDI IN] Ch:%d %s:%d:%d\n", event.channel, type_str, event.data1, event.data2);
if (receive_callback) {
receive_callback(event);
}
if (receive_callback) {
receive_callback(event);
}
}
}
@@ -140,6 +92,7 @@ void UsbMidiTransport::send_note_off(uint8_t channel, uint8_t note, uint8_t velo
void UsbMidiTransport::send_cc(uint8_t channel, uint8_t cc, uint8_t value) {
if (!initialized) return;
if (!TinyUSBDevice.ready()) return;
uint8_t packet[4] = {0x0B, (uint8_t)(0xB0 | (channel - 1)), cc, value};
usb_midi.writePacket(packet);
}
+1 -1
View File
@@ -11,7 +11,7 @@ DefaultSwitchStub::DefaultSwitchStub() : initialized(false) {
switch_states[i].current_state = false;
switch_states[i].previous_state = false;
switch_states[i].last_change_time = 0;
switch_states[i].debounce_time = 5;
switch_states[i].debounce_time = 50;
}
}