[Из песочницы] Ableton не нужон: подключаем Ableton Push 2 к VCV Rack

qkvm8jnnid3kb_8kybxztptlbfu.png


Создание музыки, в последнее время, проходит примерно тот же путь что фотография 10 лет назад: у каждого появляется своя зеркалка и аккаунт в инстаграме. Музыкальная индустрия этому очень рада, ведь такой интерес приносит много денег. Каждый день появляются новые VST плагины и аналоговые девайсы, стремительно растёт количество тематических курсов, блоги, посвящённые музыкальному продакшену выходят в топ youtube. На этом фоне очень интересно выглядят open-source проекты, позволяющие любому желающему попробовать себя в роли продюсера, не тратя на это кучу средств. Одним из таких проектов является VCV Rack, ставящий целью сделать самый дорогой класс музыкальных инструментов — аналоговые модульные синтезаторы — доступными каждому. Недавно, когда у меня возникла идея трека, а под рукой оказался только ноутбук на linux я захотел сделать трек целиком в VCV. А по ходу дела появилось желание управлять синтезаторами с помощью midi контроллера так, как с использованием доступных модулей не получалось. В итоге я решил написать плагин для подключения Ableton Push 2 к VCV Rack. О том что из этого получилось, и как писать свои модули для VCV поговорим в этой статье.

API VCV Rack


Каждый модуль в VCV состоит из двух частей — звуковой и графической. Звуковая часть наследует от класса Module метод process, вызывающийся для каждого семпла, то есть с частотой дискретизации. Кстати, частота дискретизации в VCV может варьироваться от стандартных 44.1 кГц до аж 768 кГц, что позволяет более точно эмулировать модульные синтезаторы при наличии достаточной вычислительной мощности.

За графику отвечают объекты типа ModuleWidget, которые наследуют от базовой структуры метод draw. В VCV используется библиотека векторной графики nanovg. Отрисовка внутри метода draw может происходить как внутри границ модуля (лишнее отрезается движком), так и например во фреймбуфер, чем мы ещё воспользуемся.

Итак, что же нужно, чтобы написать свой модуль для VCV?

Настройка окружения и установка Rack SDK


Первый шаг хорошо описан в документации и совершенно не вызывает трудностей (по-крайней мере под linux), так что не будем на нём останавливаться.

Генерируем шаблон плагина


Для этого в Rack SDK есть скрипт helper.py. Ему нужно сказать createplugin, после чего указать название плагина и, опционально, информацию о нём.

/helper.py createplugin MyPlugin


Когда шаблон плагина создан, его можно скомпилировать и установить командой

RACK_DIR= make install


Рисуем фронтенд модуля


Каждый плагин может содержать несколько модулей, и для каждого из модулей нужно нарисовать главную панель. Для этого документация VCV предлагает нам использовать Inkscape или любой другой векторный редактор. Так как модули в VCV крепятся на виртуальный Eurorack-стенд, их высота всегда равна 128,5 мм, а ширина должна быть кратна 5,08 мм.
Основные элементы интерфейса, такие как CV/Gate гнёзда, кнопки и лампочки можно разметить в векторном виде. Для этого нужно нарисовать на их месте круги соответствующих цветов (подробнее тут), чтобы потом helper.py сгенерил по этой разметке код. Лично мне показалось что это не очень удобная фича и проще размещать элементы напрямую из кода. Когда картинка и разметка готовы, нужно снова запустить helper.py чтобы создать шаблон модуля и связать его с передней панелью.

helper.py createmodule MyModule res/MyModule.svg src/MyModule.cpp


Подключаем кнопки и крутилки


ozwqz_ss4sqqdci_q7dxbarbfve.png

Не считая дисплея, Ableton Push 2 видится компьютером как обычный USB-MIDI девайс, что позволяет легко наладить связь между ним и VCV Rack. Для этого создадим внутри класса модуля входную midi очередь и выходной midi порт.

Код инициализации
struct AbletonPush2 : Module {
    midi::Output midiOutput;
    midi::InputQueue midiInput;

    bool inputConnected;
    bool outputConnected;
}


Попробуем найти Ableton Push среди подключенных midi девайсов и подключиться к нему. Так как модуль предназначен для работы исключительно с Ableton Push, нам не нужно обременять юзера выбором девайса и можно просто найти его по названию.

Код подключения к контроллеру
void connectPush() {
    auto in_devs = midiInput.getDeviceIds();
    for (int i = 0; i < in_devs.size(); i++){
        if (midiInput.getDeviceName(in_devs[i]).find("Ableton") != std::string::npos) {
            midiInput.setDeviceId(in_devs[i]);
            inputConnected = true;
            break;
        }
    }
		
    auto out_devs = midiOutput.getDeviceIds();
    for (int i = 0; i < out_devs.size(); i++){
        if (midiOutput.getDeviceName(out_devs[i]).find("Ableton") != std::string::npos) {
            midiOutput.setDeviceId(out_devs[i]);
            outputConnected = true;
            break;
        }
    }
}


Теперь в методе process можно периодически проверять, подключен ли контроллер и спрашивать midi очередь, не пришло ли нам каких-нибудь сообщений. Тут стоит сказать о том что и как вообще кодируется в стандарте midi. По сути есть два главных типа сообщений, это Note ON/OFF, передающие номер ноты и силу нажатия и CC — Command Control, передающие численное значение некоторого изменяемого параметра. Подробнее про midi можно посмотреть здесь.

Код опроса MIDI очереди
void process(const ProcessArgs &args) override {
    if (sampleCounter > args.sampleRate / updateFrequency) {

        if ((!inputConnected) && (!outputConnected)) {
            connectPush();
        }

	midi::Message msg;
	while (midiInput.shift(&msg)) {
	    processMidi(msg);
	}
    }
}


Теперь хочется научить пэды контроллера светиться разными цветами, чтобы было удобнее в них ориентироваться. Для этого нам надо будет посылать ему соответствующие midi команды. Рассмотрим, например, пэд с номером 36 (это самый нижний левый пэд). Если на него нажать, контроллер пошлёт команду 0×90 (note on), следом за ней номер ноты (36) и число от 0 до 127, означающее силу нажатия. А когда, наоборот, компьютер посылает контроллеру ту же самую команду 0×90 и тот же самый номер ноты 36, то третье число будет означать цвет, которым должен светиться нижний левый пэд. Номера кнопок и пэдов представлены на рисунке выше. У Push довольно много возможностей для работы с цветом, палитрами, анимацией и другими параметрами подсветки. Я не стал вникать в подробности и просто вывел все возможные цвета дефолтной палитры на пэды и выбрал понравившиеся. Но в общем случае, преобразование midi команды в значения ШИМ на светодиодах, согласно документации, выглядит так:

8oatzqvwy_gwqvgu4cmjgjpbknm.png

Код управления подсветкой
void lightOn(int note, int color){
    midi::Message msg;
    msg.setNote(note);
    msg.setValue(color);
    msg.setChannel(1);
    msg.setStatus(0x9);
    midiOutput.sendMessage(msg);
}

void lightOff(int note){
    midi::Message msg;
    msg.setNote(note);
    msg.setValue(0);
    msg.setChannel(1);
    msg.setStatus(0x8);
    midiOutput.sendMessage(msg);
}


Подключаем дисплей


osylkvou5ba8_aoa3rwhst94uiu.png

Для подключения дисплея придётся поработать с USB. Сам по себе контроллер не умеет ничего рисовать и не знает ничего о графике. Всё что он хочет — это чтобы не реже чем раз в 2 секунды ему присылали кадр размером 160×960 пикселей. Весь рендеринг происходит на стороне компьютера. Для начала подключим и откроем USB девайс, как это описано в документации:

Код подключения дисплея
#ifdef _MSC_VER
#define _CRT_SECURE_NO_WARNINGS
#endif

#include 

#ifdef _WIN32

// see following link for a discussion of the
// warning suppression:
// http://sourceforge.net/mailarchive/forum.php?
// thread_name=50F6011C.2020000%40akeo.ie&forum_name=libusbx-devel

// Disable: warning C4200: nonstandard extension used:
// zero-sized array in struct/union
#pragma warning(disable:4200)

#include 
#endif

#ifdef __linux__
#include 
#else
#include "libusb.h"
#endif

#define ABLETON_VENDOR_ID 0x2982
#define PUSH2_PRODUCT_ID  0x1967

static libusb_device_handle* open_push2_device()
{
  int result;

  if ((result = libusb_init(NULL)) < 0)
  {
    printf("error: [%d] could not initilialize usblib\n", result);
    return NULL;
  }

  libusb_set_debug(NULL, LIBUSB_LOG_LEVEL_ERROR);

  libusb_device** devices;
  ssize_t count;
  count = libusb_get_device_list(NULL, &devices);
  if (count < 0)
  {
    printf("error: [%ld] could not get usb device list\n", count);
    return NULL;
  }

  libusb_device* device;
  libusb_device_handle* device_handle = NULL;

  char ErrorMsg[128];

  // set message in case we get to the end of the list w/o finding a device
  sprintf(ErrorMsg, "error: Ableton Push 2 device not found\n");

  for (int i = 0; (device = devices[i]) != NULL; i++)
  {
    struct libusb_device_descriptor descriptor;
    if ((result = libusb_get_device_descriptor(device, &descriptor)) < 0)
    {
      sprintf(ErrorMsg,
        "error: [%d] could not get usb device descriptor\n", result);
      continue;
    }

    if (descriptor.bDeviceClass == LIBUSB_CLASS_PER_INTERFACE
      && descriptor.idVendor == ABLETON_VENDOR_ID
      && descriptor.idProduct == PUSH2_PRODUCT_ID)
    {
      if ((result = libusb_open(device, &device_handle)) < 0)
      {
        sprintf(ErrorMsg,
          "error: [%d] could not open Ableton Push 2 device\n", result);
      }
      else if ((result = libusb_claim_interface(device_handle, 0)) < 0)
      {
        sprintf(ErrorMsg,
          "error: [%d] could not claim interface 0 of Push 2 device\n", result);
        libusb_close(device_handle);
        device_handle = NULL;
      }
      else
      {
        break; // successfully opened
      }
    }
  }

  if (device_handle == NULL)
  {
    printf(ErrorMsg);
  }

  libusb_free_device_list(devices, 1);
  return device_handle;
}

static void close_push2_device(libusb_device_handle* device_handle)
{
  libusb_release_interface(device_handle, 0);
  libusb_close(device_handle);
}


Чтобы передать кадр, нужно сначала послать 16-байтовый заголовок, а потом 160 строк из 960 16-битных чисел каждая. При этом, согласно документации, строки следует передавать не по 1920 байт, а пакетами по 2048 байт, дополняя нулями.

Код передачи кадра
struct Push2Display : FramebufferWidget {

    unsigned char frame_header[16] = {
          0xFF, 0xCC, 0xAA, 0x88,
          0x00, 0x00, 0x00, 0x00,
          0x00, 0x00, 0x00, 0x00,
          0x00, 0x00, 0x00, 0x00 
    };

    libusb_device_handle* device_handle;

    unsigned char* image;

    void sendDisplay(unsigned char * image) {
        int actual_length;
     
        libusb_bulk_transfer(device_handle, PUSH2_BULK_EP_OUT, frame_header, sizeof(frame_header), &actual_length, TRANSFER_TIMEOUT);
        for (int i = 0; i < 160; i++)
            libusb_bulk_transfer(device_handle, PUSH2_BULK_EP_OUT, &ℑ[(159 - i)*1920], 2048, &actual_length, TRANSFER_TIMEOUT);
    }
}


Теперь осталось только отрисовать и записать в буфер кадр. Для этого используем класс FramebufferWidget, реализующий метод drawFramebuffer. VCV Rack из коробки использует библиотеку nanovg, так что графику здесь писать довольно легко. Узнаем текущий графический контекст из глобальной переменной APP и сохраним его состояние. Дальше создадим пустой кадр размером 160×960 и отрисуем его методом draw. После этого скопируем фреймбуфер в массив, который будет отправлен по USB, и вернём состояние контекста. В конце выставим флаг dirty, чтобы на следующей итерации отрисовки движок VCV не забыл обновить наш виджет.

Код отрисовки кадра
void drawFramebuffer() override {

    NVGcontext* vg = APP->window->vg;

    if (display_connected) {
        nvgSave(vg);
	glViewport(0, 0, 960, 160);
        glClearColor(0, 0, 0, 0);
        glClear(GL_COLOR_BUFFER_BIT|GL_STENCIL_BUFFER_BIT);

	nvgBeginFrame(vg, 960,  160, 1);
        draw(vg);
        nvgEndFrame(vg);

        glReadPixels(0, 0, 960, 160, GL_RGB, GL_UNSIGNED_SHORT_5_6_5, image); 

        for (int i = 0; i < 80*960; i ++){
            image[i * 4] ^= 0xE7;
	    image[i * 4 + 1] ^= 0xF3;
            image[i * 4 + 2] ^= 0xE7;
	    image[i * 4 + 3] ^= 0xFF;
        }
	    	
	sendDisplay(image);

	nvgRestore(vg);
    }

    dirty = true;
}


Логика взаимодействия и маппинг параметров


Для моей задачи мне хотелось уметь переключать паттерны, записанные в секвенсеры, и при этом контролировать определённый набор параметров, свой для каждой группы паттернов. Под маппингом я понимаю связывание параметра одного модуля с параметром другого модуля или midi контроллера. Я нашёл крайне мало информации о том как маппинг сделать красиво, поэтому большую часть кода, реализующего его взял из встроенного в VCV модуля MIDI Map. Вкратце, для каждой связки параметров там создаётся специальный объект ParamHandle, который через костыли сообщает движку что с чем связано.

Заключение


Вот такой модуль у меня получился в итоге. Помимо стандартного преобразования midi в CV, он позволяет группировать пэды, присваивать им цвета и связывать произвольные параметры модулей с энкодерами Push, отображая их названия и значения на дисплее контроллера. На видео ниже можно посмотреть его обзор, а на этом видео увидеть в действии.


Полный код доступен вот здесь.

Протестировать его удалось только под Linux (Ubuntu 18.04), на MacOS не подключился дисплей из-за специфики libusb и возникли странные задержки в midi интерфейсе. Не смотря на это, VCV Rack оставил очень хорошее впечатление и как фреймворк и как modular DAW, я надеюсь что VCV будет развиваться и дальше, а эта статья поможет ещё кому-то написать для него свои модули.

© Habrahabr.ru