From aea3a28206ead752b697cb9288009dcf8359ab15 Mon Sep 17 00:00:00 2001 From: Ashley Strahle Date: Tue, 30 Jun 2026 06:13:36 +0000 Subject: [PATCH] Add BLE MIDI transport (feature_bluetooth) --- include/app_task.h | 6 +- include/ble_midi_transport.h | 31 ++++++ src/app_task.cpp | 19 ++-- src/ble_midi_transport.cpp | 200 +++++++++++++++++++++++++++++++++++ src/main.cpp | 12 ++- 5 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 include/ble_midi_transport.h create mode 100644 src/ble_midi_transport.cpp diff --git a/include/app_task.h b/include/app_task.h index 652d7a0..5f7f7c4 100644 --- a/include/app_task.h +++ b/include/app_task.h @@ -2,6 +2,7 @@ #include #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]; diff --git a/include/ble_midi_transport.h b/include/ble_midi_transport.h new file mode 100644 index 0000000..e48614d --- /dev/null +++ b/include/ble_midi_transport.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include "midi_transport.h" + +class BleMidiTransport { +public: + BleMidiTransport(); + ~BleMidiTransport(); + + bool begin(); + void update(); + + void on_midi_receive(std::function 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(); + +private: + std::function receive_callback; + bool initialized; + bool client_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); +}; diff --git a/src/app_task.cpp b/src/app_task.cpp index b3ad99c..c0b8d32 100644 --- a/src/app_task.cpp +++ b/src/app_task.cpp @@ -1,8 +1,8 @@ #include "app_task.h" #include -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}; @@ -20,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); @@ -134,7 +138,8 @@ void AppTask::process_switch_event(uint8_t switch_id, bool pressed) { uint8_t value = pressed ? 127 : 0; if (pressed) { - midi_transport->send_cc(channel, cc_num, value); + usb_midi->send_cc(channel, cc_num, value); + if (ble_midi) ble_midi->send_cc(channel, cc_num, value); } Serial.printf("[APP] Switch %d -> Ch%d CC%d Val%d (%s)\n", diff --git a/src/ble_midi_transport.cpp b/src/ble_midi_transport.cpp new file mode 100644 index 0000000..dcc2e00 --- /dev/null +++ b/src/ble_midi_transport.cpp @@ -0,0 +1,200 @@ +#include "ble_midi_transport.h" +#include +#include +#include +#include +#include + +#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 BLEServer* ble_server = nullptr; +static BLEService* ble_service = nullptr; +static BLECharacteristic* ble_char = nullptr; +static bool device_connected = false; + +class ServerCallbacks : public BLEServerCallbacks { + void onConnect(BLEServer* server) override { + device_connected = true; + Serial.println("[BLE] Client connected"); + } + void onDisconnect(BLEServer* server) override { + device_connected = false; + Serial.println("[BLE] Client disconnected"); + BLEDevice::startAdvertising(); + } +}; + +class CharCallbacks : public BLECharacteristicCallbacks { + void onWrite(BLECharacteristic* 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..."); + + BLEDevice::init("JOC Midi"); + BLEDevice::setPower(ESP_PWR_LVL_P9); + + ble_server = BLEDevice::createServer(); + ble_server->setCallbacks(new ServerCallbacks()); + + ble_service = ble_server->createService(BLE_MIDI_SERVICE_UUID); + + ble_char = ble_service->createCharacteristic( + BLE_MIDI_CHAR_UUID, + BLECharacteristic::PROPERTY_READ | + BLECharacteristic::PROPERTY_WRITE_NR | + BLECharacteristic::PROPERTY_NOTIFY + ); + ble_char->addDescriptor(new BLE2902()); + ble_char->setCallbacks(new CharCallbacks()); + + ble_service->start(); + + BLEAdvertising* adv = BLEDevice::getAdvertising(); + adv->addServiceUUID(BLE_MIDI_SERVICE_UUID); + adv->setScanResponse(true); + adv->setMinPreferred(0x06); + adv->setMinPreferred(0x12); + BLEDevice::startAdvertising(); + + 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 callback) { + receive_callback = callback; +} + +void BleMidiTransport::send_midi_packet(const uint8_t* data, uint8_t len) { + if (!initialized || !client_connected || !ble_char) return; + + // BLE MIDI format: timestamp header + raw MIDI message + 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] = {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] = {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] = {0xB0 | (channel - 1), cc, value}; + send_midi_packet(packet, 3); + Serial.printf("[BLE OUT] Ch:%d CC:%d:%d\n", channel, cc, value); +} + +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; +} diff --git a/src/main.cpp b/src/main.cpp index 76fe3de..203a16b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include "midi_transport.h" +#include "ble_midi_transport.h" #include "Adafruit_TinyUSB.h" #include "pixel_stomp_mux.h" #include "led_stub.h" @@ -16,8 +17,9 @@ 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); +AppTask controller(&led_driver, &switch_driver, &midi_transport, &ble_midi_transport); TaskHandle_t midi_task_handle = NULL; @@ -26,6 +28,7 @@ void midi_task(void* parameter) { while (true) { midi_transport.update(); + ble_midi_transport.update(); vTaskDelay(1); } } @@ -328,7 +331,7 @@ void setup() { Serial.println("================================="); Serial.println(" Loopy MIDI Controller v0.1"); - Serial.println(" Phase 1: USB MIDI"); + Serial.println(" Phase 1.5: USB + BLE MIDI"); Serial.println(" Board: ESP32-S3-WROOM-1"); Serial.println("================================="); @@ -345,7 +348,10 @@ void setup() { Serial.println("[INIT] Initializing USB MIDI..."); midi_transport.begin(); - + + Serial.println("[INIT] Initializing BLE MIDI..."); + ble_midi_transport.begin(); + Serial.println("[INIT] Registering MIDI callbacks..."); controller.begin();