Как стать автором
Обновить

Анатомия LV2-плагина

Время на прочтение11 мин
Количество просмотров2.3K

Введение

LV2 - это открытый стандарт для создания плагинов звуковых эффектов. Считается, что он предназначен прежде всего для ОС Linux, хотя не существует никаких ограничений, мешающих использовать его на других системах. До этого в линуксе уже существовало два подобных стандарта - LADSPA и DSSI. Первый из них предназначался в основном для обработки звуковых сигналов и практически не мог работать с MIDI-данными. Второй же наоборот, задумывался как стандарт для виртуальных синтезаторов.

Само название LV2 - это сокращение от LADSPA version 2, он является новой, улучшенной версией стандарта. В отличие от своих предшественников, он позволяет обрабатывать аудиоданные, midi-потоки, создавать любой пользовательский интерфейс и обмениваться любыми данными с приложением-хостом. Стандарт также поддерживает механизм расширений. Благодаря этому LV2 может предложить ряд дополнительных возможностей: набор «заводских» предустановок, сохранение состояния, логирование. В теории, пользователь может создавать свои собственные дополнения. Подробная документация с примерами располагается по адресу http://lv2plug.in

Организация

Наверняка многие знакомы с популярным стандартом VST. В его случае плагин и связанные ресурсы, как правило, содержатся внутри одной динамической библиотеки (DLL-файла). В стандарте LV2 почти всегда используется больше одного файла. Стандарт использует понятие бандл (bundle). Мне так и не удалось узнать, существует ли какой-нибудь русскоязычный эквивалент для этого термина. Бандл представляет собой каталог в файловой системе, в который помещаются все файлы, относящиеся к этому плагину. Согласно определению из документации: «LV2 Bundle - это каталог, содержащий файл manifest.ttl на верхнем уровне». Именовать каталоги принято так, чтобы их название совпадало с названием плагина, например amsynth.lv2 или triceratops.lv2, но допускаются любые названия. Пути расположения бандлов указываются в системной переменной LV2_PATH (либо задаются непосредственно в настройках приложения-хоста). В одном бандле могут располагаться сразу несколько плагинов.

Каждый плагин обязан иметь свой уникальный URI. Он требуется для однозначного опознания плагина в системе, независимо от того, по какому пути установлены его файлы. URI не обязан быть реально существующим адресом, он лишь служит в качестве глобального идентификатора. Но всё же предпочтительней, чтобы он указывал на страницу проекта, к которому относится этот плагин. Существют ограничения на выбор URI: запрещается использовать чужие адреса без согласия владельца; указанный URI должен быть валидным адресом. Для целей тестирования зарезервирован http://example.org/. Список всех установленных в системе плагинов можно получить при помощи утилиты lv2ls.

Файл manifest.ttl является обязательным элементом каждого бандла, он содержит список плагинов и ресурсов, а также ссылки на другие файлы с описаниями. Когда приложение-хост пытается получить список доступных плагинов, оно первым делом ищет и читает эти файлы (поэтому рекомендуется делать их минимально возможного размера). Как и все прочие файлы настроек, manifest.ttl является текстовым файлом в формате Turtle. Описание плагина принято выносить в отдельный ttl-файл, на который будет ссылаться manifest.ttl (Это делается для уменьшения размера манифеста). Сам плагин представляет собой динамическую библиотеку, предоставляющую определённый набор методов согласно спецификации LV2.

Интересной особенностью является пользовательский интерфейс (UI). Он может располагается как в одной библиотеке с плагином, так и в отдельной (такой вариант считается предпочтительным) или же находиться в совершенно другом бандле. Интерфесов может быть несколько, например для различных операционных систем. Его может не быть вовсе. В этом случае приложение-хост должно самостоятельно сгенерировать элементы управления на основе описания портов. При этом UI и обработка звука выполняются в разных процессах, изолированных друг от друга. Это даёт бОльшую гибкость при разработке, но затрудняет коммуникацию между двумя этими частями.

Порты

Всё общение плагина с приложением-хостом осуществляется через порты. Каждый порт имеет направление передачи (ввод или вывод) и тип. Типы бывают:

  • AudioPort — аудио данные. Один порт создаёт один канал ввода-вывода. Передача осуществляется в виде потока сэмплов в формате float.

  • ControlPort — управление плагином, этот тип работает только на ввод. Принимают данные от элементов интерфейса — это может быть ваш UI или созданные хостом ползунки.

  • EventPort — управляющие команды (как правило используется для передачи MIDI-данных)

  • CVPort — этот порт имитирует управляющее напряжение (Control Voltage). В «железных» синтезаторах оно используется для объединения различных различных блоков: осцилляторов (VCO), фильтров (VCF), усилителей (VCA)

Все порты с их параметрами должны быть описаны в ttl-файле. При описании порта указывается два обозначения — порядковый номер (index) и символическое имя(symbol), которые должны быть уникальными. Помимо этого существует множество других параметров. Например, названия на разных языках, диапазон допустимых значений и тд.

Описания портов также могут использоваться для формирования пользовательского интерфейса, в том случае, если он не был создан в виде библиотеки. Для каждого порта с типом ControlPort должно быть создано по одному элементу управления. Внешний вид этих элементов целиком зависит от используемой программы-хоста. Как правило, это ползунок с диапазоном значений, указанным в настройках порта.

Атомы

Атом в LV2 — это универсальный контейнер для передачи данных. Он может содержать как примитивные типы (целые числа, числа с плавающей запятой, строки), так и структурированные данные, такие как списки и объекты. Объект в данном случае является аналогом ассоциативного массива. Атом является простой структурой данных, к нему применимы низкоуровневые методы работы с памятью (например, копирование при помощи метода memcpy()). Но пользоваться такими методами не стоит - для работы с атомами существуют специальные библиотеки Utilities и Forge. Использовать атомы следует везде, где требуется хранение или передача двоичных данных.

Жизненный цикл плагина

Каждый LV2-плагин должен выполнять в процессе загрузки и работы ряд предписанных спецификацией действий. Для каждого из них создаётся статическая функция в библиотеке, все вместе они составляют дескриптор плагина (структура LV2_Descriptor).

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

  • connect_port() - привязывает порт к буферу. Обычно вызывается столько раз, сколько внешних портов у плагина. В качестве параметров передаются номер порта и указатель на буфер типа void *. Полученный указатель должен сохраняться, однако обращение к нему может производится только в рамках функции run().

  • activate() - инициализация и подготовка плагина к запуску. На практике это означает, что должны быть очищены все переменные внутреннего состояния, кроме тех, которые получены в функции connect_port().

  • run() - здесь происходит вся обработка аудиоданных. При вызове функция получает количество сэмплов, которым нужно обменяться с портами. Поскольку она должна выполняться в реальном времени, то здесь недопустимо использовать различные таймеры, блокировки и прочие времязатратные операции.

  • deactivate() - противоположность функции activate(). Указывает, что функция run() не будет вызываться до следующего вызова activate(). В большинстве простых плагинов никаких действий производить не требуется.

  • cleanup() - деструктор плагина. В этой функции должна освобождаться вся выделенная память.

  • extension_data() - вызывается при использовании дополнительных расширений, реализованных на стороне плагина. В качестве аргумента получает строку с URI расширения, возвращает ссылку на исполняемую функцию.

Пример простого плагина

Для демонстрации создадим простейший плагин, который будет получать на вход midi-команды, а на выходе выдавать звук с фиксированной частотой. Назовём его example и присвоим тестовый URI http://example.org

Прежде всего, создаётся файл manifest.ttl, который будет точкой входа для приложений-хостов.

@prefix lv2:  <http://lv2plug.in/ns/lv2core#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<http://example.org>
        a lv2:Plugin, lv2:InstrumentPlugin ;
        lv2:binary <example.so> ;
        rdfs:seeAlso <example.ttl> .

В первых двух строках определяются префиксы, которые затем будут использоваться в описании. Далее идёт выбранный URI и краткое описание плагина. В строке 5 указывается группа, которой принадлежит плагин (полный список групп можно найти на странице документации https://lv2plug.in/ns/lv2core/lv2core.html). Две последних строки указывают на бинарный файл, который будет загружаться хостом и расширенный файл описания example.ttl, который и содержит основные данные.

Второй файл немного больше по размеру и выглядит так:

@prefix atom: <http://lv2plug.in/ns/ext/atom#> .
@prefix doap: <http://usefulinc.com/ns/doap#> .
@prefix lv2:  <http://lv2plug.in/ns/lv2core#> .
@prefix midi: <http://lv2plug.in/ns/ext/midi#> .
@prefix rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix urid: <http://lv2plug.in/ns/ext/urid#> .

<http://example.org>
        a lv2:Plugin, lv2:InstrumentPlugin ;
        doap:name "Example" ;
        lv2:requiredFeature urid:map ;
        lv2:port [
                a lv2:InputPort, atom:AtomPort ;
                atom:bufferType atom:Sequence ;
                atom:supports atom:Sequence, midi:MidiEvent ;
                lv2:index 0 ;
                lv2:symbol "in_midi" ;
                lv2:name "Midi input" ;
        ], [
                a lv2:AudioPort, lv2:OutputPort ;
                lv2:index 1 ;
                lv2:symbol "out" ;
                lv2:name "Out"
        ] .

Также, как и в предыдущем случае, в начале файла указываются префиксы. Затем идут тип и название плагина. Строка lv2:requiredFeature означает, что мы запрашиваем у хоста некое расширение (существует также параметр optionalFeature). Его наличие проверяется по довольно запутанной, на мой взгляд, схеме. Найдя в конфигурации строку requiredFeature хост должен передать сведения об этом расширении в функцию instantiate(). Либо не передавать, если он его не поддерживает. Проверка наличия/отсутствия возлагается на сам плагин. (Хотя казалось бы, хост мог бы прекратить загрузку ещё при чтении конфигурации, если обнаружит, что требуемое расширение не найдено).

Особое внимание следует обратить на строку 13, где указываются порты плагина. Всего их два — входной для midi и выходной для звука (на это указывают типы lv2:InputPort и lv2:OutputPort). Название lv2:AudioPort объясняет само себя, atom:AtomPort означает, что на вход будут подаваться данные типа Atom (к примеру, тип ControlPort тоже будет принимать данные на входе, но они не будут являться атомами).

Как уже упоминалось, каждому порту должны присваиваться уникальные индекс и имя. Они указываются в параметрах lv2:index и lv2:symbol. Индекс требуется для того, чтобы связывать порт с буфером данных во время вызова функции connect_port(), символьное имя используется главным образом как программный идентификатор. Чтобы указать «человекочитаемое» название следует использовать параметр lv2:name. В отличие от symbol оно не содержит ограничений на использование спецзнаков. Кроме того их может быть несколько, для разных языков.

Исходный код разделемой библтотеки написан на языке Си и выглядит так:

#include <math.h>
#include <stdlib.h>
#include <stdbool.h>

#include <lv2/lv2plug.in/ns/lv2core/lv2.h>
#include <lv2/lv2plug.in/ns/ext/atom/atom.h>
#include <lv2/lv2plug.in/ns/ext/atom/util.h>
#include <lv2/lv2plug.in/ns/ext/urid/urid.h>
#include <lv2/lv2plug.in/ns/ext/midi/midi.h>

#define MURI "http://example.org"

enum Ports {
    IN_MIDI,
    OUT
};

typedef struct {
    LV2_Atom_Sequence *midiPort;
    float *outPort;
    int rate;
    bool soundOn;
    int currentSample;
    LV2_URID midiEvent;
} Plugin;


static LV2_Handle
instantiate(const LV2_Descriptor* descriptor,
            double rate,
            const char* bundle_path,
            const LV2_Feature* const* features) {

    Plugin *self = (Plugin *) malloc(sizeof(Plugin));
    self->rate = rate;
    self->currentSample = 0;
    self->soundOn = false;

    LV2_URID_Map* map = NULL;
    for (int i = 0; features[i]; ++i) {
        if (!strcmp(features[i]->URI, LV2_URID__map)) {
            map = (LV2_URID_Map*)features[i]->data;
        }
    }

    if (map == NULL) {
        return NULL;
    }
    self->midiEvent = map->map(map->handle, LV2_MIDI__MidiEvent);

    return (LV2_Handle)self;
}

static void connect_port(LV2_Handle instance,
             uint32_t port,
             void* data) {

    Plugin *self = (Plugin *) instance;
    switch (port) {
        case IN_MIDI:
            self->midiPort = (LV2_Atom_Sequence*) data;
            break;
        case OUT:
            self->outPort = (float*) data;
            break;
    }
}

void processEvent(LV2_Atom_Event *event, Plugin *self) {
    if (event->body.type != self->midiEvent) {
        return;
    }

    const uint8_t* const msg = LV2_ATOM_BODY(&(event->body));
    LV2_Midi_Message_Type type = lv2_midi_message_type(msg);

    switch(type) {
        case LV2_MIDI_MSG_NOTE_ON:
            self->soundOn = true;
            break;
        case LV2_MIDI_MSG_NOTE_OFF:
            self->soundOn = false;
            break;
    }
}

static void run(LV2_Handle instance, uint32_t sample_count) {
    Plugin *self = (Plugin *) instance;

    LV2_ATOM_SEQUENCE_FOREACH(self->midiPort, event) {
        processEvent(event, self);
    }

    for (uint32_t i = 0; i < sample_count; i++) {
        if (self->soundOn) {
            self->outPort[i] = sinf(2 * M_PI * 440.0 * self->currentSample / self->rate);
        } else {
            self->outPort[i] = 0.0;
        }
        self->currentSample++;
    }
}

static void cleanup(LV2_Handle instance) {
    free(instance);
}

static const LV2_Descriptor descriptor = {
    MURI,
    instantiate,
    connect_port,
    NULL,
    run,
    NULL,
    cleanup,
    NULL
};

LV2_SYMBOL_EXPORT
const LV2_Descriptor*
lv2_descriptor(uint32_t index) {
    switch (index) {
        case 0:
            return &descriptor;
        default:
            return NULL;
    }
}

Здесь, прежде всего, нужно обратить внимание на стуктуру LV2_Descriptor и функцию lv2_descriptor(). Структура содержит в себе URI плагина и указатели на все функции, которые были описаны в разделе «Жизненный цикл плагина». В том случае, если мы не хотим использовать какую-то из функций, то вместо указателя на неё ставим NULL. Сама структура возвращается из функции lv2_descriptor() - это первое, что вызывает хост при загрузке плагина. При этом в качестве аргумента передаётся индекс запрашиваемого дескриптора. То есть, в одной библиотеке может содержаться множество отдельных плагинов.

Далее, взгляните на структуру Plugin. Дело в том, что LV2 не имеет механизма для сохранения внутреннего состояния плагина и нам придётся делать это самим. Причём для этого не обязательно использовать структуру — тип LV2_Handle это тот же void *, то есть мы можем здесь использовать объект или какой-либо примитивный тип. Эта структура будет создаваться и заполняться при инициализации плагина — внутри функции instatntiate(). В качестве аргументов она получает такие параметры, как путь к бандлу и частоту дискретизации аудио. Эта частота понадобится нам для генерации синусоиды, поэтому сохраним её в структуре. Также здесь используется расширение map, которое преобразует текстовые URI в числа. В дальнейшем потребуется выбирать midi-сообщения из потока входных событий. Поэтому преобразовываем тип LV2_MIDI__MidiEvent к числу и сохраняем в структуре для дальнейшего использования.

После того, как инициализация завершится, присоединяются порты. Функция connect_port будет вызываться столько раз, сколько портов было указано в ttl-файле описания. При каждом вызове передаётся номер порта (берётся из описания) и указатель на буфер, связанный с ним. Наша задача сохранить эти указатели в структуре Plugin.

Когда все приготовления окончены, в дело вступает функция run, которая будет вызываться бесконечно на протяжении всей работы плагина. Основной интерес представляет параметр sample_count — количество сэмплов, которые мы должны записать в порт вывода (или можем прочитать, если наш плагин не только генерирует, но и обрабатывает звук со входа). Здесь мы сначала проверяем входящие midi-сообщения, используя макрос LV2_ATOM_TUPLE_FOREACH. Он выступает в качестве итератора для последовательности атомов, передавая извлечённые данные внутрь своего тела.

Основная обработка события происходит внутри функции processEvent(). Прежде всего проверяется, что полученное событие действительно midi-команда. Вот здесь и используется то значение, которое было получено с помощью расширения map во время инициализации. В типе LV2_Atom_Event нас прежде всего интересует полезная нагрузка, которую можно получить при помощи макроса LV2_ATOM_BODY. Поскольку никакого специального типа для midi в стандарте не предусмотрено, сообщение сохранится в виде «сырого» массива байтов. Из него уже извлекается тип пришедшего сообщения. В зависимости от того, была ли нажата или отпущена клавиша проставляется значение для переменной soundOn в структуре Plugin.

Самый главный участок, который и формирует звук расположен внутри цикла в функции run(). Состояние переменной soundOn указывает, что будет записываться в выходной порт: синусоида либо нули. (На самом деле, использование currentSample в для сохранения текущей позиции неправильно. Рано или поздно он переполнится и в синусоиде возникнут разрывы. Но для демонстрации сойдёт и так).

Ссылки

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии2

Публикации

Истории

Работа

Программист С
31 вакансия

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург