В своей прошлой статье я рассматривал вопрос создания NuGet-пакета для .NET-библиотеки с платформозависимым API. После публикации пришло время перейти от слов к делу. А именно начать реализовывать нативный API для работы с MIDI-устройствами в macOS.
Взаимодействие с MIDI в macOS производится с помощью фреймворка CoreMIDI. К сожалению, документация к данному фреймворку крайне скудная, многие вещи приходится выяснять либо экспериментальным путём, либо подглядывать в других проектах (при этом всё равно проверяя, насколько алгоритмы там верны). В данной статье я расскажу обо всём, что узнал касаемо CoreMIDI в процессе реализации нативной прослойки для своей библиотеки. Все примеры кода будут приведены на языке C.
Стоит заметить, что сейчас в CoreMIDI есть ряд устаревших API. Новые функции введены в macOS 11.0 Big Sur и призваны обеспечить работу с MIDI 2.0. Но я по большей части буду рассматривать именно старый API, так как он:
существует давно, следовательно, есть огромная масса приложений, написанных с его использованием (кроме того, согласно статистике на сайте statcounter, доля пользователей Big Sur ничтожно мала (CSV-датасет с сырыми данными за июнь 2020 – июнь 2021 тут));
вопреки заявлениям Apple в документации (например, по функции MIDIInputPortCreate, колонка Availability справа), работает и в последних версиях macOS;
принципиально не отличается от нового, все современные API просто принимают аргументом тип протокола (MIDI 1.0/2.0) и/или функцию обратного вызова в виде блока.
Я буду стараться приводить ссылки на новые функции тоже, хотя их легко можно найти в документации по старым функциям. Разумеется, в будущем нужно будет перейти на новый API, хотя на мой взгляд сейчас это не выглядит сильно важным. MIDI 2.0 ещё далёк от того, чтобы прийти на широкий рынок MIDI-устройств, а новый API CoreMIDI очень молодой, может, что-то ещё поменяется или же найдутся баги.
На момент написания статьи у меня в библиотеке существует 360 юнит-тестов на проверку взаимодействия с MIDI-устройствами. Отладку я производил в версии macOS 10.14 Mojave, но также прогонял весь набор в 11.3 Big Sur и в 12.0 Monterey (Beta 3).
Устройства
Очень кратко структура MIDI-устройства в CoreMIDI описана на странице MIDI Services официальной документации, а визуально её можно представить так:
То есть, устройство состоит из компонентов (entities), а каждый компонент имеет произвольное количество точек ввода/вывода (endpoints), которые мы будем называть источниками (source) и приёмниками (destination). Приложение, задействующее CoreMIDI, использует источник, чтобы получать данные от устройства, а приёмник – чтобы отправлять данные на него.
Количество устройств в системе можно получить с помощью функции MIDIGetNumberOfDevices:
ItemCount devicesCount = MIDIGetNumberOfDevices();
а ссылку на конкретное устройство по индексу (от 0
до devicesCount - 1
) с помощью функции MIDIGetDevice. Например, чтобы получить ссылку на первое устройство:
MIDIDeviceRef deviceRef = MIDIGetDevice(0);
После получения ссылки на устройство можно получить его компоненты, используя аналогичные функции MIDIDeviceGetNumberOfEntities и MIDIDeviceGetEntity:
ItemCount entitiesCount = MIDIDeviceGetNumberOfEntities(deviceRef);
MIDIEntityRef firstEntityRef = MIDIDeviceGetEntity(deviceRef, 0);
И наконец, дабы получить источники и приёмники компонентов, используются функции MIDIEntityGetNumberOfSources, MIDIEntityGetSource, MIDIEntityGetNumberOfDestinations и MIDIEntityGetDestination:
ItemCount entitySourcesCount = MIDIEntityGetNumberOfSources(firstEntityRef);
MIDIEndpointRef firstEntitySourceRef = MIDIEntityGetSource(firstEntityRef, 0);
ItemCount entityDestinationsCount = MIDIEntityGetNumberOfDestinations(firstEntityRef);
MIDIEndpointRef firstEntityDetinationRef = MIDIEntityGetDestination(firstEntityRef, 0);
Можно также получать источники и приёмники безотносительно конкретного устройства и его компонента, используя функции MIDIGetNumberOfSources, MIDIGetSource, MIDIGetNumberOfDestinations и MIDIGetDestination:
ItemCount sourcesCount = MIDIGetNumberOfSources();
MIDIEndpointRef firstSourceRef = MIDIGetSource(0);
ItemCount destinationsCount = MIDIGetNumberOfDestinations();
MIDIEndpointRef firstDetinationRef = MIDIGetDestination(0);
Например, именно такой подход я и использую в DryWetMIDI, ибо мне нужно предоставлять унифицированный API для Windows и macOS. В Windows нет понятий, используемых в CoreMIDI. Там используются термины входное устройство и выходное устройство. Таких терминов я придерживаюсь и в библиотеке, а потому источники в CoreMIDI я считаю входными устройствами, а приёмники – выходными.
Мы теперь знаем, как получать ссылки на устройства, его компоненты и точки ввода/вывода. Но от этого мало толка, если мы не умеем получать информацию о каждом из объектов. Например, мы можем захотеть показать в нашем приложении список имеющихся в системе устройств с их именами. CoreMIDI предоставляет несколько функций для получения свойств MIDI-объектов (и установки некоторых из них). Перечень этих функций приведён на странице MIDI Object Properties в секции Property Accessors. Все доступные свойства приведены на той же странице и логически сгруппированы.
Например, чтобы найти источник с именем MIDI A, можно написать такой код:
ItemCount sourcesCount = MIDIGetNumberOfSources();
MIDIEndpointRef sourceRef;
for (int i = 0; i < sourcesCount; i++)
{
sourceRef = MIDIGetSource(i);
CFStringRef nameRef;
MIDIObjectGetStringProperty(sourceRef, kMIDIPropertyDisplayName, &nameRef);
if (CFStringCompare(nameRef, CFSTR("MIDI A"), 0) == kCFCompareEqualTo)
{
break;
}
}
Здесь мы не стали проверять результат вызова функции MIDIObjectGetStringProperty, а по-хорошему нужно. Вот только функция возвращает OSStatus
, что по сути просто 32-битное целое число со знаком. В CoreMIDI много функций возвращают OSStatus
, но в документации ни к одной из них не сказано, какие именно константы допустимы для конкретной функции. Максимум, что мы можем узнать, это весь список возможных ошибок в CoreMIDI, после чего с помощью хрустального шара прикинуть, какие из них к какой функции подходят. Я не буду далее показывать в каждом месте, что та или иная функция возвращает OSStatus
, это можно увидеть в документации.
Виртуальные устройства
Если вы хотите автоматизированно тестировать сценарии работы с MIDI-устройствами, вряд ли вы посчитаете хорошей затеей создавать робота, который будет при запуске тестов подключать физическую аппаратуру. А потому нужно уметь программно настраивать MIDI-окружение.
В Windows для того, чтобы поиметь виртуальные устройства в системе, нужно устанавливать сторонние решения вроде virtualMIDI. Центральным их элементом является драйвер режима ядра. Звучит не очень просто, а потому продукты являются либо платными, либо только для некоммерческого использования, либо без возможности распространять их, либо всё вместе.
К счастью, в macOS есть встроенная возможность создавать виртуальные источники и приёмники. Источник создаётся с помощью функции MIDISourceCreate (обратите внимание, что в CoreMIDI для создания пользовательских сущностей необходимо создавать клиент (MIDIClientCreate) и передавать его в соответствующие функции):
MIDIClientRef clientRef;
MIDIClientCreate(CFSTR("CLIENT"), NULL, NULL, &clientRef);
MIDIEndpointRef sourceRef;
MIDISourceCreate(clientRef, CFSTR("SRC"), &sourceRef);
Функция объявлена устаревшей, новая же – MIDISourceCreateWithProtocol – отличается лишь указанием протокола:
MIDISourceCreateWithProtocol(clientRef, CFSTR("SRC"), kMIDIProtocol_1_0, &sourceRef);
Приёмник создаётся функцией MIDIDestinationCreate:
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
// executed on new packet received
}
// ...
MIDIEndpointRef destinationRef;
MIDIDestinationCreate(clientRef, nameRef, ReadProc, NULL, &destinationRef);
Функция ReadProc
будет вызвана всякий раз, как в приёмник поступит новый пакет данных. Разумеется, есть и новый способ создания приёмника – MIDIDestinationCreateWithProtocol:
MIDIDestinationCreateWithProtocol(clientRef, nameRef, kMIDIProtocol_1_0, &destinationRef, ^(const MIDIEventList *evtlist, void *srcConnRefCon)
{
// executed on new event packet received
});
Видя, что в C нынче есть лямбда-функции (они же блоки), понимаю, что сильно отстал от жизни.
На практике полезнее создавать не просто источники и приёмники в вакууме, а связывать их в пары, образуя loopback-устройства. Такие устройства принимают данные через приёмник и отправляют их обратно через источник безо всякой обработки. Таким образом, появляется возможность проверить, а достиг ли на самом деле пакет данных устройства или нет. Если да, мы получим его обратно. Вот код простейшей программы, создающей loopback-устройства с заданными через аргументы именами:
#include <CoreFoundation/CoreFoundation.h>
#include <CoreMIDI/CoreMIDI.h>
#include <stdio.h>
typedef int LPBCREATE_RESULT;
#define LPBCREATE_OK 0
#define LPBCREATE_FAILEDCREATECLIENT 1
#define LPBCREATE_FAILEDCREATESOURCE 2
#define LPBCREATE_FAILEDCREATEDESTINATION 3
typedef struct
{
MIDIEndpointRef destRef;
MIDIEndpointRef srcRef;
char *portName;
} PortInfo;
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
PortInfo* portInfo = (PortInfo*)readProcRefCon;
printf("Data arrived on '%s'; notifying ports...", portInfo->portName);
OSStatus status = MIDIReceived(portInfo->srcRef, pktlist);
printf("%d\n", status);
}
int main(int argc, char *argv[])
{
printf("Creating client...\n");
MIDIClientRef clientRef;
OSStatus result = MIDIClientCreate(CFSTR("LoopbackClient"), NULL, NULL, &clientRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATECLIENT;
for (int i = 1; i < argc; i++)
{
printf("Creating port '%s'...", argv[i]);
PortInfo *portInfo = malloc(sizeof(PortInfo));
portInfo->portName = argv[i];
CFStringRef nameRef = CFStringCreateWithCString(NULL, argv[i], kCFStringEncodingUTF8);
result = MIDISourceCreate(clientRef, nameRef, &portInfo->srcRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATESOURCE;
result = MIDIDestinationCreate(clientRef, nameRef, ReadProc, portInfo, &portInfo->destRef);
if (result != noErr)
return LPBCREATE_FAILEDCREATEDESTINATION;
printf("OK\n");
}
printf("Waiting for data...\n");
getchar();
return LPBCREATE_OK;
}
Всякий раз, как какое-либо приложение отправит данные на приёмник нашего виртуального loopback-устройства, будет вызвана функция ReadProc
, внутри которой функция MIDIReceived оповестит все подключенные к источнику порты о том, что получен пакет данных.
Отправка и получение данных
Пришло время узнать, как же обмениваться данными с MIDI-устройствами. Отправка и получение данных происходят между портами (создаваемыми приложением) и точками ввода/вывода устройств (источники/приёмники):
Входной порт призван получать данные от источника. Создать его можно с помощью функции MIDIInputPortCreate (функция нового API – MIDIInputPortCreateWithProtocol):
MIDIPortRef inPortRef;
MIDIInputPortCreate(clientRef, CFSTR("PORT_NAME"), ReadProc, NULL, &inPortRef);
Вместо NULL
можно передать указатель на объект, который будет приходить через параметр readProcRefCon
в ReadProc
. Пример функции ReadProc
можно посмотреть в предыдущем разделе в коде виртуального loopback-устройства.
Однако просто создать входной порт недостаточно, его нужно соединить с источником (или даже несколькими). На картинке выше такие соединения показаны жирной сплошной линией, в противоположность штриховым между выходными портами и приёмниками, где связь не требуется, но об этом позже. Чтобы соединить входной порт с источником, нужно вызвать функцию MIDIPortConnectSource:
MIDIPortConnectSource(inPortRef, sourceRef, NULL);
Вместо NULL
опять же можно передать указатель на произвольный объект, который будет передан в ReadProc
через параметр srcConnRefCon
. Связать можно любое количество входных портов с любым количеством источников, не в пример Windows API, где владение устройством эксклюзивное (повторный вызов функции midiInOpen без предварительного вызова midiInClose приведёт к ошибке).
Теперь всякий раз, как от источника будут идти данные, они будут получены функцией ReadProc
. Мы также можем вызвать MIDIReceived
без фактической отправки данных источником. При этом на всех связанных с источником входных портах будет вызвана функция обратного вызова с данными, переданными в MIDIReceived
.
Разумеется, связь можно и разорвать, для чего служит функция MIDIPortDisconnectSource:
MIDIPortConnectSource(inPortRef, sourceRef);
Для отправки же данных от приложения к устройству необходимо создать выходной порт, используя функцию MIDIOutputPortCreate:
MIDIPortRef outPortRef;
MIDIOutputPortCreate(clientRef, CFSTR("PORT_NAME"), &outPortRef);
после чего с помощью функции MIDISend (или MIDISendEventList), собственно, отправить данные:
MIDISend(outPortRef, destinationRef, packetList);
Здесь мы отправляем пакет данных packetList
через выходной порт приложения outPortRef
на приёмник destinationRef
. Единственный неясный момент – а как же сформировать пакет для отправки? Как я уже упоминал выше, я в основном описываю старый API. И отправка пакета данных через MIDISend
– это устаревший подход. Я опишу подробно его, и скажу пару слов про новый.
Если мы изучим документацию по структуре MIDIPacketList (которая по неясным причинам не помечена устаревшей), то резюме будет таким:
есть поле numPackets, содержащее количество пакетов в списке;
поле packet объявлено как массив пакетов длины
1
, а пакет представляется структурой MIDIPacket, которая имеет:поле timeStamp со временем передачи данных на устройство;
поле length с длиной данных в пакете;
и, собственно, данные в поле data, объявленном как байтовый массив длины
256
.
Тут есть интересные моменты. Например, поле timeStamp
имеет разное значение при получении и при отправке. Из документации:
If receiving MIDI data, this property represents the time at which the events occurred. If sending MIDI data, it represents the time at which to play the events. A value of 0 means “now.”
Т.е. при отправке можно запланировать передачу данных на устройство, указав время в будущем. А можно указать 0
, и тогда данные будут переданы сразу. Но, указав 0
, мы этот 0
и увидим в пакете при получении его от источника. И выходит, что метка времени будет не совсем the time at which the events occurred. По мне, здесь перемудрили, и в Windows сделано проще – время события проставляется системой, и нет планирования отправки.
Теперь что касается формирования списка пакетов. Да, конечно, можно сделать по-простому:
MIDIPacket packet;
packet.timeStamp = 0;
packet.length = 3;
packet.data[0] = 0x90; // note on, channel 0
packet.data[1] = 0xA6; // note number
packet.data[2] = 0xB7; // velocity
MIDIPacketList packetList;
packetList.numPackets = 1;
packetList.packet[0] = packet;
Тут есть нюансы:
мы используем
256
байт (так объявлено полеdata
) для передачи всего лишь трёх;не выйдет передать одним пакетом больше данных (например, несколько сообщений нажатия ноты или же system exclusive данные).
На самом деле можно создавать пакеты переменной длины (да, в описании к полю data
написано variable-length stream, но что это по факту значит, не ясно). Откуда я это узнал? Конечно же, не из документации. Вот пример того, как можно записать 3333
сообщения note on, каждое длиной 3
:
ByteCount dataSize = 9999;
Byte buffer[dataSize + sizeof(MIDIPacketList)];
MIDIPacketList* packetList = (MIDIPacketList*)buffer;
MIDIPacket* packet = MIDIPacketListInit(packetList);
for (int i = 0; i < dataSize; i += 3)
{
data[i] = 0x90;
data[i+1] = 0xA6;
data[i+2] = 0xB7;
packet = MIDIPacketListAdd(packetList, sizeof(buffer), packet, 0, 3, &data[i]);
}
Здесь используется магия преобразования массива байтов (которые unsigned char
) в указатель на MIDIPacketList
. После чего функцией MIDIPacketListInit получаем указатель на первый пакет в списке. Этот указатель затем нужно передавать в последовательные вызовы MIDIPacketListAdd, дабы CoreMIDI сам разбил всё по пакетам, как считает нужным. Стоит понимать, что данная функция не всегда создаёт новый пакет, название её стоит читать как “добавить данные в список пакетов”.
В документации сказано, что максимальный размер списка пакетов 65536
. Однако, отослав данные, подготовленные кодом выше (9999
байт), и заглянув в отладчике в функцию ReadProc
(функция обратного вызова входного порта), заметим интересные моменты:
ReadProc
будет вызвана3
раза;numPackets
в переданном в функцию списке пакетов всегда будет1
;длина пакета при первом и втором вызове будет
4082
, а в третьем1835
.
То есть, данные, длина которых намного меньше максимальной, были разбиты на три списка пакетов, по одному пакету в каждом. Размер пакета при таком разбиении 4082
(в сумме длины трёх пакетов дадут искомые 9999
). Я, конечно же, не призываю ориентироваться на это поведение при написании своего кода, думаю, это лишь детали реализации, которые могут меняться. К слову, если любопытства ради попытаться передать пакет длиной больше 65536
, получим EXC_BAD_ACCESS, он же небезызвестный segmentation fault.
И, раз уж мы затронули получение данных, в общем случае код для работы с ними будет примерно таким:
void ReadProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon)
{
MIDIPacket* packet = &pktlist->packet[0];
for (int i = 0; i < pktlist->numPackets; i++)
{
// do something with packet's data
packet = MIDIPacketNext(packet);
}
}
Функция MIDIPacketNext возвращает указатель на следующий пакет в списке. По моим наблюдениям вызов функции в отсутствие следующего пакета вернёт какую-то ерунду, не NULL
. Стоит иметь это в виду и не ориентироваться на возвращаемое значение при принятии решения о завершении итерирования по данным.
Немного о том, как отправляются и получаются данные в новом API. По сути всё абсолютно то же самое. Например, простейший способ отправить данные такой:
MIDIEventPacket packet;
packet.timeStamp = 0;
packet.wordCount = 1;
packet.words[0] = (2 << 28) | (0x90 << 16) | (0x40 << 8) | 0x65;
MIDIEventList eventList;
eventList.protocol = kMIDIProtocol_1_0;
eventList.numPackets = 1;
eventList.packet[0] = packet;
MIDISendEventList(outPortRef, destinationRef, &eventList);
Фактически, отличия только в названии функций и указании протокола. Разумеется, тут нужно знать формат Universal MIDI Packet (UMP, спецификацию можно скачать с официального сайта). Вместо байтов нужно оперировать 32-битными числами (словами), вместо MIDIPacket
используется MIDIEventPacket, вместо MIDIPacketList
– MIDIEventList, вместо MIDISend
– MIDISendEventList. Логика понятна – активно используется слово event.
Я сильно не вникал в работу с новым API, поэтому не могу сказать, можно ли создавать MIDIEventList
динамически тем же способом, что и MIDIPacketList
, через приведение массива байтов (или же слов) к указателю на необходимую структуру.
Как я уже говорил, я сосредоточен на старом API. Но нужно быть уверенным, что он работает и в новых версиях macOS. Я проверял на 11.3 Big Sur и 12 Monterey (Beta 3). На Big Sur в целом всё здорово, хотя тот наш пример с 3333
событиями в списке пакетов не работает. Выдержка из отчёта о падении:
...
Exception Type: EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Note: EXC_CORPSE_NOTIFY
...
5 libc++abi.dylib 0x00007fff205fe307 std::__terminate(void (*)()) + 8
6 libc++abi.dylib 0x00007fff205fe2b8 std::terminate() + 56
7 com.apple.audio.midi.CoreMIDI 0x00007fff350177de void MIDI::EventList::traverse<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIEventList const&)::'lambda'(auto const&)>(MIDIEventList const&, auto&&) + 212
8 com.apple.audio.midi.CoreMIDI 0x00007fff35021ac9 caulk::inplace_function_detail::vtable<void, MIDI::EventList const&>::vtable<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIPacketList const&)::'lambda'(MIDI::EventList const&)>(caulk::inplace_function_detail::wrapper<MIDIIORingBufferWriter::copyPacketList(PacketHeader&, MIDIPacketList const&)::'lambda'(MIDI::EventList const&)>)::'lambda'(void*, MIDI::EventList const&)::__invoke(void*, MIDI::EventList const&) + 29
9 com.apple.audio.midi.CoreMIDI 0x00007fff34ff9b72 MIDI::PacketizerBase<MIDI::EventList>::begin_new_packet(unsigned long long, gsl::span<unsigned int const, -1l>) + 52
10 com.apple.audio.midi.CoreMIDI 0x00007fff34ffc7d8 MIDI::Packetizer::add(unsigned long long, MIDI::UniversalPacket const&) + 296
11 com.apple.audio.midi.CoreMIDI 0x00007fff34ffd03f void MIDI::LegacyPacketList::traverse<MIDI::MIDIPacketList_to_MIDIEventList(MIDIPacketList const&, caulk::inplace_function<void (MIDI::EventList const&), 48ul, 16ul>)::'lambda'(MIDIPacket const&)>(MIDIPacketList const&, MIDI::MIDIPacketList_to_MIDIEventList(MIDIPacketList const&, caulk::inplace_function<void (MIDI::EventList const&), 48ul, 16ul>)::'lambda'(MIDIPacket const&)&&) + 131
12 com.apple.audio.midi.CoreMIDI 0x00007fff35021960 void MIDIProcess::WriteOutput<MIDI::LegacyPacketList>(MIDIIOHeader&, MIDIProtocolID, MIDI::LegacyPacketList const&) + 166
13 com.apple.audio.midi.CoreMIDI 0x00007fff35021864 MIDISend + 313
Из стека вызовов можно узнать любопытный факт – вызов старого API транслируется в вызовы нового (MIDIPacketList_to_MIDIEventList
). Т.е. в новых версиях macOS CoreMIDI полностью использует новые функции для фактического общения с устройствами.
Сама ошибка мне не критична, ибо я в своей библиотеке придерживаюсь правила “одно событие – один пакет данных”. Поэтому ситуации с несколькими событиями в пакете у меня нет.
Что касается macOS Monterey, то тут обнаружился баг. Если попытаться отправить событие нажатия ноты (note on) с нулевой скоростью нажатия (velocity), то CoreMIDI преобразует событие в note off с velocity = 64. Так как мы уже знаем, что внутри используется новый API, ошибка присутствует при отправке события как через MIDISend
, так и через MIDISendEventList
. Я сообщил об ошибке в Apple, на момент написания этих строк обращению выставлен статус Potential fix identified - In macOS 12.
Заключение
В статье мы рассмотрели основные сценарии работы с CoreMIDI. Конечно, возможности фреймворка гораздо шире, и многое осталось “за кадром”. Однако статья даёт быстрый старт и может использоваться в качестве шпаргалки при разработке своего приложения, взаимодействующего с MIDI-устройствами.
В моих экспериментах мне сильно помог проект RtMidi, особенно в части понимания, как создавать список пакетов данных.
Нативные API (Windows/macOS) для библиотеки DryWetMIDI можно посмотреть в папке Resources/Native в репозитории проекта на GitHub.