Some time ago, I made a quick little project to connect my AKAI MPK Mini MIDI keyboard to an ESP32-S3.
Here’s a short demo video:
This post is about this very first, simple version.
The first step, if you have the same board I do, is to make a small hardware change (solder USB-OTG pads) so that the USB-C port can power the keyboard.

The idea is:
- The USB-C port labeled
USB
will be connected to the keyboard. - The board itself will be powered through the port labeled
COM
.
For the software, I used the EspUsbHost library by tanakamasayuki on GitHub.
Since this was just a quick experiment, I didn’t need all the extra features from the library, so I tweaked it a bit to fit my needs.
Here’s the .h
file with my changes, compared to the original:
#ifndef __EspUsbHost_H__
#define __EspUsbHost_H__
#include <Arduino.h>
#include <usb/usb_host.h>
#include <class/hid/hid.h>
#include <rom/usb/usb_common.h>
class EspUsbHost {
public:
bool isReady = false;
uint8_t interval;
unsigned long lastCheck;
struct endpoint_data_t {
uint8_t bInterfaceNumber; // Added
uint8_t bInterfaceClass;
uint8_t bInterfaceSubClass;
uint8_t bInterfaceProtocol;
uint8_t bCountryCode;
};
endpoint_data_t endpoint_data_list[17];
uint8_t _bInterfaceNumber; // Added
uint8_t _bInterfaceClass;
uint8_t _bInterfaceSubClass;
uint8_t _bInterfaceProtocol;
uint8_t _bCountryCode;
esp_err_t claim_err;
usb_host_client_handle_t clientHandle;
usb_device_handle_t deviceHandle;
uint32_t eventFlags;
usb_transfer_t *usbTransfer[16];
uint8_t usbTransferSize;
uint8_t usbInterface[16];
uint8_t usbInterfaceSize;
hid_local_enum_t hidLocal;
void begin(void);
void task(void);
static void _clientEventCallback(const usb_host_client_event_msg_t *eventMsg, void *arg);
void _configCallback(const usb_config_desc_t *config_desc);
void onConfig(const uint8_t bDescriptorType, const uint8_t *p); // Modified
static String getUsbDescString(const usb_str_desc_t *str_desc);
static void _onReceive(usb_transfer_t *transfer);
static void _printPcapText(const char* title, uint16_t function, uint8_t direction, uint8_t endpoint, uint8_t type, uint8_t size, uint8_t stage, const uint8_t *data);
esp_err_t submitControl(const uint8_t bmRequestType, const uint8_t bDescriptorIndex, const uint8_t bDescriptorType, const uint16_t wInterfaceNumber, const uint16_t wDescriptorLength);
static void _onReceiveControl(usb_transfer_t *transfer);
virtual void onReceive(const usb_transfer_t *transfer){};
virtual void onGone(const usb_host_client_event_msg_t *eventMsg){};
virtual uint8_t getKeycodeToAscii(uint8_t keycode, uint8_t shift);
virtual void onKeyboard(hid_keyboard_report_t report, hid_keyboard_report_t last_report);
virtual void onKeyboardKey(uint8_t ascii, uint8_t keycode, uint8_t modifier);
virtual void onMouse(hid_mouse_report_t report, uint8_t last_buttons);
virtual void onMouseButtons(hid_mouse_report_t report, uint8_t last_buttons);
virtual void onMouseMove(hid_mouse_report_t report);
void _onDataGamepad();
static void _onReceiveMIDI(usb_transfer_t *transfer); // Added
void setHIDLocal(hid_local_enum_t code);
};
#define HID_KEYCODE_TO_ASCII_JA \
{0 , 0 }, /* 0x00 */ \
{0 , 0 }, /* 0x01 */ \
{0 , 0 }, /* 0x02 */ \
{0 , 0 }, /* 0x03 */ \
{'a' , 'A' }, /* 0x04 */ \
{'b' , 'B' }, /* 0x05 */ \
{'c' , 'C' }, /* 0x06 */ \
{'d' , 'D' }, /* 0x07 */ \
{'e' , 'E' }, /* 0x08 */ \
{'f' , 'F' }, /* 0x09 */ \
{'g' , 'G' }, /* 0x0a */ \
{'h' , 'H' }, /* 0x0b */ \
{'i' , 'I' }, /* 0x0c */ \
{'j' , 'J' }, /* 0x0d */ \
{'k' , 'K' }, /* 0x0e */ \
{'l' , 'L' }, /* 0x0f */ \
{'m' , 'M' }, /* 0x10 */ \
{'n' , 'N' }, /* 0x11 */ \
{'o' , 'O' }, /* 0x12 */ \
{'p' , 'P' }, /* 0x13 */ \
{'q' , 'Q' }, /* 0x14 */ \
{'r' , 'R' }, /* 0x15 */ \
{'s' , 'S' }, /* 0x16 */ \
{'t' , 'T' }, /* 0x17 */ \
{'u' , 'U' }, /* 0x18 */ \
{'v' , 'V' }, /* 0x19 */ \
{'w' , 'W' }, /* 0x1a */ \
{'x' , 'X' }, /* 0x1b */ \
{'y' , 'Y' }, /* 0x1c */ \
{'z' , 'Z' }, /* 0x1d */ \
{'1' , '!' }, /* 0x1e */ \
{'2' , '"' }, /* 0x1f */ \
{'3' , '#' }, /* 0x20 */ \
{'4' , '$' }, /* 0x21 */ \
{'5' , '%' }, /* 0x22 */ \
{'6' , '&' }, /* 0x23 */ \
{'7' , '\'' }, /* 0x24 */ \
{'8' , '(' }, /* 0x25 */ \
{'9' , ')' }, /* 0x26 */ \
{'0' , 0 }, /* 0x27 */ \
{'\r' , '\r' }, /* 0x28 */ \
{'\x1b', '\x1b' }, /* 0x29 */ \
{'\b' , '\b' }, /* 0x2a */ \
{'\t' , '\t' }, /* 0x2b */ \
{' ' , ' ' }, /* 0x2c */ \
{'-' , '=' }, /* 0x2d */ \
{'^' , '~' }, /* 0x2e */ \
{'@' , '`' }, /* 0x2f */ \
{'[' , '{' }, /* 0x30 */ \
{0 , 0 }, /* 0x31 */ \
{']' , '}' }, /* 0x32 */ \
{';' , '+' }, /* 0x33 */ \
{':' , '*' }, /* 0x34 */ \
{0 , 0 }, /* 0x35 HANKAKU */ \
{',' , '<' }, /* 0x36 */ \
{'.' , '>' }, /* 0x37 */ \
{'/' , '?' }, /* 0x38 */ \
\
{0 , 0 }, /* 0x39 */ \
{0 , 0 }, /* 0x3a */ \
{0 , 0 }, /* 0x3b */ \
{0 , 0 }, /* 0x3c */ \
{0 , 0 }, /* 0x3d */ \
{0 , 0 }, /* 0x3e */ \
{0 , 0 }, /* 0x3f */ \
{0 , 0 }, /* 0x40 */ \
{0 , 0 }, /* 0x41 */ \
{0 , 0 }, /* 0x42 */ \
{0 , 0 }, /* 0x43 */ \
{0 , 0 }, /* 0x44 */ \
{0 , 0 }, /* 0x45 */ \
{0 , 0 }, /* 0x46 */ \
{0 , 0 }, /* 0x47 */ \
{0 , 0 }, /* 0x48 */ \
{0 , 0 }, /* 0x49 */ \
{0 , 0 }, /* 0x4a */ \
{0 , 0 }, /* 0x4b */ \
{0 , 0 }, /* 0x4c */ \
{0 , 0 }, /* 0x4d */ \
{0 , 0 }, /* 0x4e */ \
{0 , 0 }, /* 0x4f */ \
{0 , 0 }, /* 0x50 */ \
{0 , 0 }, /* 0x51 */ \
{0 , 0 }, /* 0x52 */ \
{0 , 0 }, /* 0x53 */ \
\
{'/' , '/' }, /* 0x54 */ \
{'*' , '*' }, /* 0x55 */ \
{'-' , '-' }, /* 0x56 */ \
{'+' , '+' }, /* 0x57 */ \
{'\r' , '\r' }, /* 0x58 */ \
{'1' , 0 }, /* 0x59 */ \
{'2' , 0 }, /* 0x5a */ \
{'3' , 0 }, /* 0x5b */ \
{'4' , 0 }, /* 0x5c */ \
{'5' , '5' }, /* 0x5d */ \
{'6' , 0 }, /* 0x5e */ \
{'7' , 0 }, /* 0x5f */ \
{'8' , 0 }, /* 0x60 */ \
{'9' , 0 }, /* 0x61 */ \
{'0' , 0 }, /* 0x62 */ \
{'.' , 0 }, /* 0x63 */ \
{0 , 0 }, /* 0x64 */ \
{0 , 0 }, /* 0x65 */ \
{0 , 0 }, /* 0x66 */ \
{'=' , '=' }, /* 0x67 */ \
#endif
I also added a new function called _onReceiveMIDI
(EspUsbHost.cpp) to process the messages from the keyboard:
// Add this
void EspUsbHost::_onReceiveMIDI(usb_transfer_t *transfer) {
static bool sustain_enabled = false;
static bool note_playing = false;
static uint8_t current_note = 0;
EspUsbHost *usbHost = (EspUsbHost *)transfer->context;
for (int i = 0; i < transfer->actual_num_bytes; i += 4) {
uint8_t status_byte = transfer->data_buffer[i + 1];
uint8_t param1 = transfer->data_buffer[i + 2]; // Note number
uint8_t param2 = transfer->data_buffer[i + 3]; // Velocity
uint8_t message_type = status_byte & 0xF0;
uint8_t midi_channel = status_byte & 0x0F;
// Sustain pedal
if (message_type == 0xB0 && param1 == 64) {
sustain_enabled = (param2 >= 64);
Serial.printf("Sustain %s\n", sustain_enabled ? "ON" : "OFF");
if (!sustain_enabled && note_playing) {
noTone(buzzer_pin);
Serial.printf("OFF sustain note: %d\n", current_note);
note_playing = false;
}
}
// NOTE ON
else if (message_type == 0x90 && param2 > 0) {
int frequency = 440 * pow(2, (param1 - 69) / 12.0);
tone(buzzer_pin, frequency);
current_note = param1;
note_playing = true;
Serial.printf("NOTE ON: Note %d, Freq %d Hz\n", param1, frequency);
}
// NOTE OFF
else if (message_type == 0x80 || (message_type == 0x90 && param2 == 0)) {
if (!sustain_enabled && param1 == current_note) {
noTone(buzzer_pin);
note_playing = false;
Serial.printf("NOTE OFF: Note %d\n", param1);
} else if (sustain_enabled) {
Serial.printf("NOTE OFF (sustain active): %d\n", param1);
}
}
}
}
As you can see, the buzzer_pin
value needs to be set at the beginning of the file (EspUsbHost.cpp):
#define buzzer_pin 6
Another change was made to the onConfig
function, so the complete function is:
// Change this
void EspUsbHost::onConfig(const uint8_t bDescriptorType, const uint8_t *p) {
switch (bDescriptorType) {
case USB_INTERFACE_DESC:
{
const usb_intf_desc_t *intf = (const usb_intf_desc_t *)p;
ESP_LOGI("EspUsbHost", "USB_INTERFACE_DESC(0x04)\n"
"# bInterfaceNumber = %d\n"
"# bNumEndpoints = %d\n"
"# bInterfaceClass = 0x%x\n"
"# bInterfaceSubClass = 0x%x\n",
intf->bInterfaceNumber,
intf->bNumEndpoints,
intf->bInterfaceClass,
intf->bInterfaceSubClass);
/* MIDI */
if (intf->bInterfaceClass == 0x01 && intf->bInterfaceSubClass == 0x03) {
ESP_LOGI("EspUsbHost", " MIDI detected: interface %d", intf->bInterfaceNumber);
esp_err_t err = usb_host_interface_claim(this->clientHandle, this->deviceHandle, intf->bInterfaceNumber, intf->bAlternateSetting);
if (err != ESP_OK) {
ESP_LOGI("EspUsbHost", "usb_host_interface_claim() err=%x", err);
} else {
this->usbInterface[this->usbInterfaceSize] = intf->bInterfaceNumber;
this->usbInterfaceSize++;
_bInterfaceNumber = intf->bInterfaceNumber;
_bInterfaceClass = intf->bInterfaceClass;
_bInterfaceSubClass = intf->bInterfaceSubClass;
}
}
}
break;
case USB_ENDPOINT_DESC:
{
const usb_ep_desc_t *ep_desc = (const usb_ep_desc_t *)p;
ESP_LOGI("EspUsbHost", "USB_ENDPOINT_DESC(0x05)\n"
"# bEndpointAddress = 0x%x\n"
"# bmAttributes = 0x%x\n"
"# wMaxPacketSize = %d\n",
ep_desc->bEndpointAddress,
ep_desc->bmAttributes,
ep_desc->wMaxPacketSize);
/* MIDI config */
if (_bInterfaceClass == 0x01 && _bInterfaceSubClass == 0x03) {
ESP_LOGI("EspUsbHost", "Endpoint MIDI %d (Addr: 0x%02x, Size: %d)",
USB_EP_DESC_GET_EP_NUM(ep_desc),
ep_desc->bEndpointAddress,
ep_desc->wMaxPacketSize);
if (ep_desc->bEndpointAddress & USB_B_ENDPOINT_ADDRESS_EP_DIR_MASK) {
esp_err_t err = usb_host_transfer_alloc(ep_desc->wMaxPacketSize + 1, 0, &this->usbTransfer[this->usbTransferSize]);
if (err != ESP_OK) {
this->usbTransfer[this->usbTransferSize] = NULL;
ESP_LOGI("EspUsbHost", "usb_host_transfer_alloc() err=%x", err);
return;
} else {
ESP_LOGI("EspUsbHost", "usb_host_transfer_alloc() ESP_OK data_buffer_size=%d", ep_desc->wMaxPacketSize + 1);
}
this->usbTransfer[this->usbTransferSize]->device_handle = this->deviceHandle;
this->usbTransfer[this->usbTransferSize]->bEndpointAddress = ep_desc->bEndpointAddress;
this->usbTransfer[this->usbTransferSize]->callback = _onReceiveMIDI;
this->usbTransfer[this->usbTransferSize]->context = this;
this->usbTransfer[this->usbTransferSize]->num_bytes = ep_desc->wMaxPacketSize;
interval = ep_desc->bInterval;
isReady = true;
this->usbTransferSize++;
}
}
}
break;
default:
{
ESP_LOGI("EspUsbHost", "USB unknown: 0x%02x", bDescriptorType);
}
}
}
And finally, here’s my main.cpp
:
#include <Arduino.h>
#include "EspUsbHost.h"
EspUsbHost usbHost;
void setup() {
Serial.begin(115200);
usbHost.begin();
}
void loop() {
usbHost.task();
}
This is the most basic version possible — but there’s a lot more that could be done.
In later experiments, I added LEDs, and even used the keyboard’s joystick to change pitch and vibrato in real time.
Of course, sending sound through just one digital pin is pretty limited. Ideally, you’d use an external DAC (digital-to-analog converter) and expand the library to handle more MIDI messages for better control.
The biggest limitation with this version is that notes below MIDI note 28 cause an error in the tone
function.
I’ll keep updating this project and adding the features I mentioned above.