Занимаясь музыкальным творчеством, я часто делаю аранжировки и записи на компьютере — используя кучу всяких VST плагинов и инструментов. Стыдно признаться — я никогда не понимал, как "накручивают" звуки в синтезаторах. Программирование позволило мне написать свой синтезатор, "пропустить через себя" процесс создания звука.


Я планирую несколько статей, в которых будет пошагово рассказано, как написать свой VST плагин/инструмент: программирование осциллятора, частотного фильтра, различных эффектов и модуляции параметров. Упор будет сделан на практику, объяснение программисту простым языком, как же все это работает. Теорию (суровые выводы и доказательства) обойдем стороной (естественно, будут ссылки на статьи и книги).


Обычно плагины пишутся на C++ (кроссплатформенность, возможность эффективно реализовать алгоритмы), но я решил выбрать более подходящий для меня язык — C#; сфокусироваться на изучении самого синтезатора, алгоритмов, а не технических деталей программирования. Для создания красивого интерфейса я использовал WPF. Возможность использования архитектуры .NET дала возможность библиотека-обертка VST. NET.


Ниже представлен обзорный ролик моего простого синтезатора, полученных интересных звучаний.



Предстоит нелегкий путь, ��сли вы готовы — добро пожаловать под кат.



Цикл статей


  1. Понимаем и пишем VSTi синтезатор на C# WPF
  2. ADSR-огибающая сигнала
  3. Частотный фильтр Баттервота
  4. Delay, Distortion и модуляция параметров

Оглавление


  1. Загадочный мир синтеза звука
  2. Звук в цифровом виде
  3. VST SDK
  4. WDL-OL и JUCE
  5. VST .NET
  6. Моя надстройка над VST .NET
  7. WPF UI
  8. UI-поток
  9. Обзор архитектуры синтезатора Syntage
  10. Настраиваем проект для создания плагина/инструмента
  11. Отладка кода
  12. Пишем простой осциллятор
  13. Список литературы


Загадочный мир синтеза звука


Я очень люблю музыку, слушаю разные стили, играю на различных инструментах, и, конечно, сочиняю и записываю аранжировки. Когда я начинал использовать эмуляторы синтезаторов в звукозаписывающих программах (да и сейчас) я всегда перебирал кучу пресетов, искал подходящее звучание.


Перебирая пресеты одного синтезатора можно встретить как "ожидаемый" звук электронного синтезатора из детства (музыка из мультика Летучий Корабль) так и имитацию ударных, звуков, шума, даже голоса! И все это делает один синтезатор, с одними и теми же ручками параметров. Это меня всегда удивляло, хотя я понимал: каждый звук — суть конкретная настройка всех ручек.


Недавно я решил наконец-таки разобраться, каким же образом создаётся (или, правильнее сказать, синтезируется) звук, как и почему нужно крутить ручки, как видоизменяется от эффектов сигнал (визуально и на слух). И конечно же, научиться (хотя бы понять основы) самому "накручивать" звук, копировать понравившиеся мне стили. Я решил последовать одной цитате:


"Скажи мне — и я забуду, покажи мне — и я запомню, дай мне сделать — и я пойму."
Конфуций


Конечно, все подряд делать не надо (куда столько велосипедов?), но сейчас я хочу получить знания и самое главное — поделиться ими с вами.


Цель: не углубляясь в теорию, создать простой синтезатор, сделав упор на объяснение процессов с точки зрения программирования, на практике.


В синтезаторе будут:



Все составляющие я планирую рассмотреть в нескольких статьях. В данной будет рассмотрено программирование осциллятора.


Программировать будем на C#; UI можно писать либо на WPF, либо на Windows Forms, либо вообще обойтись без графической оболочки. Плюс выбора WPF — красивая графика, которую достаточно быстро кодить, минус — только на Windows. Владельцы других ОС — не расстраивайтесь, всё-таки цель — понять работу синтезатора (а не запилить красивый UI), тем более, код, который я буду демонстрировать, можно быстро перенести, скажем, на С++.


В главах VST SDK и WDL-OL и JUCE я расскажу про концепцию VST, ее внутреннюю реализацию; про библиотеки-надстройки, которые хорошо подойдут для разработки серьезных плагинов. В главе VST .NET я расскажу про данную библиотеку, ее минусы, мою надстройку, программирование UI.


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


Исходный код написанного мной синтезатора доступен на GitHub'е.



Звук в цифровом виде


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


Любой звуковой файл в компьютере в несжатом формате представляет собой массив семплов. Любой плагин, в конечном счете, принимает и обрабатывает на входе массив семлов (в зависимости от точности это будут float или double числа, либо можно работать с целыми числами). Почему я сказал массив, а не одиночный семпл? Этим я хотел подчеркнуть что обрабатывается звук в целом: если вам нужно сделать эквализацию, вы не сможете оперировать одним лишь семплом без информации о других.


Хотя, конечно, есть задачи, которым не важно знать, что вы обрабатываете — они рассматривают конкретный семпл. Например, задача — поднять уровень громкости в 2 раза. Мы можем работать с каждым семплом по-отдельности, и нам не нужно знать про остальные.


Мы будем работать с семплом как с float-числом от -1 до 1. Обычно, чтобы не говорить "значение семпла", можно сказать "амплитуда". Если амплитуда каких-то семплов будет больше 1 или меньше -1, произойдет клиппинг, этого нужно избегать.



VST SDK


VST (Virtual Studio Technology) — это технология, позволяющая писать плагины для программ обработки звука. Сейчас существует большое множество плагинов, решающих различные задачи: синтезаторы, эффекты, анализаторы звука, виртуальные инструменты и так далее.


Чтобы создавать VST плагины, компания Steinberg (некоторые ее знают по программе Cubase) выпустила VST SDK, написанный на C++. Помимо технологии (или, как еще говорят, "формата плагинов") VST, есть и другие — RTAS, AAX, тысячи их. Я выбрал VST, из-за большей известности, большого количества плагинов и инструментов (хотя, большинство известных плагинов поставляется в разных форматах).


На данный момент актуальная версия VST SDK 3.6.6, хотя многие продолжают использовать версию 2.4. Исторически складывается, что сложно найти DAW без поддержки версии 2.4, и не все поддерживают версию 3.0 и выше.


VST SDK можно скачать с официального сайта.
В дальнейшем мы будем работать с библиотекой VST.NET, которая является оберткой для VST 2.4.


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


Сейчас я кратко изложу принципы VST SDK 2.4, для общего понимания работы плагина и его взаимодействия с DAW.


В Windows VST плагин версии 2.4 представляется как динамическая DLL библиотека.
Хостом мы будем называть программу, которая загружает нашу DLL. Обычно это либо программа редактирования музыки (DAW), либо ��ростая оболочка, чтобы запускать плагин независимо от других программ (например, очень часто в виртуальных инструментах с .dll плагином поставляется .exe файл, чтобы загружать плагин как отдельную программу — пианино, синтезатор).


Дальнейшие функции, перечисления и структуры вы можете найти в скачанном VST SDK в исходниках из папки "VST3 SDK\pluginterfaces\vst2.x".


Библиотека должна экспортировать функцию со следующей сигнатурой:


EXPORT void* VSTPluginMain(audioMasterCallback hostCallback)

Функция принимает указатель на коллбэк, чтобы плагин мог получать необходимую ему информацию от хоста.


VstIntPtr (VSTCALLBACK *audioMasterCallback) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt)

Все делается на достаточно "низком" уровне — чтобы хост понял, что от него хотят, нужно передавать номер команды через параметр opcode. Перечисление всех опкодов хардкорные C-кодеры могут найти в перечислении AudioMasterOpcodesX. Остальные параметры используются аналогичным образом.


VSTPluginMain должна вернуть указатель на структуру AEffect, которая, по-сути, и является нашим плагином: она содержит информацию о плагине и указатели на функции, которые будет вызывать хост.


Основные поля структуры AEffect:


  • Информация о плагине. Название, версия, число параметров, число программ и пресетов (читай далее), тип плагина и прочее.


  • Фунции для запроса и установки значений параметров.


  • Функции смены пресетов/программ.


  • Фунция обработки массива семплов


    void (VSTCALLBACK *AEffectProcessProc) (AEffect* effect, float** inputs, float** outputs, VstInt32 sampleFrames)

    float** — это массив каналов, каждый канал содержит одинаковое количество семплов (количество семплов в массиве зависит от звукового драйвера и его настроек). В основном встречаются плагины, обрабатывающие моно и стерео.


  • Супер-функция, подобна audioMasterCallback.


    VstIntPtr (VSTCALLBACK *AEffectDispatcherProc) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt)

    Вызывается хостом, по параметру opcode определяется необходимое действие (список AEffectOpcodes). Используется, чтобы узнать дополнительную информацию о параметрах, сообщать плагину об изменениях в хосте (изменение частоты дискредитации), для взаимодействия с UI плагина.



При работе с плагином было бы очень удобно, чтобы юзер мог сохранить все настроенные ручки и переключатели. А еще круче, чтобы была их автоматизация! Например, вы можете захотеть сделать знаменитый эффект rise up — тогда вам нужно менять параметр cutoff (частота среза) эквалайзера во времени.


Чтобы хост управлял параметрами вашего плагина, в AEffect есть соответствующие функции: хост может запросить общее количество параметров, узнать или установить значение конкретного параметра, узнать название параметра, его описание, получить отображаемое значение.


Хосту все равно, какая логика у параметров в плагине. Задача хоста — сохранять, загружать, автоматизировать параметры. Хосту очень удобно воспринимать параметр, как float-число от 0 до 1 — а уж плагин пусть как хочет, так его и толкует (так и сделали большинство DAW, неофициально).


Пресеты (в терминах VST SDK — programs/программы) это коллекция конкретных значений всех параметров плагина. Хост может менять/переключать/выбирать номера пресетов, узнавать их названия, аналогично с параметрами. Банки — коллекция пресетов. Банки логически существуют только в DAW, в VST SDK есть только пресеты и программы.


Поняв идею структуры AEffect можно набросать и скомпилировать простой DLL-плагинчик.
А мы пойдем дальше, на уровень выше.



WDL-OL и JUCE


Чем плоха разработка на голом VST SDK?


  • Писать всю рутину с нуля самому?.. По-любому, кто-то уже это сделал!
  • Структуры, коллбэки… а хочется чего-то более высокоуровневого
  • Хочется кроссплатформенность, чтобы код был один
  • А что насчет UI, которое легко разрабатывать!?

На сцену выходит WDL-OL. Это C++ библиотека для создания кроссплатформенных плагинов. Поддерживаются форматы VST, VST3, Audiounit, RTAS, AAX. Удобство библиотеки состоит в том, что (при правильной настройке проекта) вы пишете один код, а при компилировании получаете свой плагин в разных форматах.


Как работать с WDL-OL хорошо описано в Martin Finke's Blog "Music & Programming", даже есть хабр статьи-переводы на русский.


WDL-OL решает, по крайней мере, первые три пункта минусов разработки на VST SDK. Все, что вам нужно — корректно настроить проект (первая статья из блога), и отнаследоваться от класса IPlug.


class MySuperPuperPlugin : public IPlug
{
public:

    explicit MyFirstPlugin(IPlugItanceInfo instanceInfo);
    virtual ~MyFirstPlugin() override;

    void ProcessDoubleReplacing(double** inputs, double** outputs, int nFrames) override;
};

Теперь с чистой совестью можно реализовать функцию ProcessDoubleReplacing, которая, по сути и является "ядром" плагина. Все заботы взял на себя класс IPlug. Если его изучать, можно быстро понять, что (в формате VST) он является оберткой структуры AEffect. Коллбэки от хоста и функции для хоста превратились в удобные виртуальные функции, с понятными названиями и адекватными списками параметров.


В WDL-OL уже есть средства для создания UI. Но как по мне, все это делается с большой болью: UI собирается в коде, все ресурсы нужно описывать в .rc файле и так далее.


Помимо WDL-OL я так же узнал про библиотеку JUCE. JUCE похожа на WDL-OL, решает все заявленные минусы разработки на VST SDK. Помимо всего прочего, она уже имеет в своем составе и UI-редактор, и кучу классов для работы с аудио данными. Я лично ее не использовал, поэтому советую прочитать о ней, хотя бы, на вики.


Если вы хотите писать серьезный плагин, тут я бы уже всерьез задумался над использованием библиотек WDL-OL или JUCE. Всю рутину они сделают за вас, а у вас же остается вся мощь языка C++ для реализации эффективных алгоритмов и кроссплатформенность — что не маловажно в мире большого количества DAW.



VST .NET


Чем же мне не угодили WDL-OL и JUCE?


  1. Моя задача — понять как программируется синтезатор, обработка аудио, эффекты, а не как собрать плагин под максимальное количество форматов и ��латформ. "Техническое программирование" здесь отходит на второй план (конечно, это не повод писать плохой код и не использовать ООП).
  2. Я разбалован языком C#. Опять же, этот язык, в отличие от того же C++, позволяет не думать о некоторых технических моментах.
  3. Мне нравится технология WPF в плане ее визуальных возможностей.

Страничка библиотеки — vstnet.codeplex.com, там есть исходники, бинарники, документация. Как я понял, библиотека находится в стадии почти доделал и забил заморозки (не реализованы некоторые редко используемые функции, пару лет нет изменений репозитория).


Библиотека состоит из трех ключевых сборок:


  1. Jacobi.Vst.Core.dll — содержит интерфейсы, определяющие поведения хоста и плагина, вспомогательные классы аудио, событий, MIDI. Большая часть является оберткой нативных структур, дефайнов и перечислений из VST SDK.
  2. Jacobi.Vst.Framework.dll — содержит базовые классы плагинов, реализующие интерфейсы из Jacobi.Vst.Core, позволяющие ускорить разработку плагинов и не писать все с нуля; классы для более высокоуровневого взаимодействия "хост-плагин", различные менеджеры параметров и программ, MIDI-сообщений, работы с UI.
  3. Jacobi.Vst.Interop.dll — Managed C++ обертка над VST SDK, которая позволяет соединить хост с загруженной .NET сборкой (вашим плагином).

Как можно делать .NET сборки, если хост ожидает простую динамическую DLL? А вот как: на самом деле хост грузит не вашу сборку, а скомпилированную DLL Jacobi.Vst.Interop, которая уже в свою очередь грузит ваш плагин в рамках .NET.


Используется следующая хитрость: допустим, вы разрабатываете свой плагин, и на выходе получаете .NET-сборку MyPlugin.dll. Нужно сделать так, чтобы хост вместо вашей MyPlugin.dll загрузил Jacobi.Vst.Interop.dll, а она загрузила ваш плагин. Вопрос, а как Jacobi.Vst.Interop.dll узнает откуда грузить вашу либу? Вариантов решения много. Разработчик выбрал вариант называть либу-обертку одинаковым именем с вашей либой, а затем искать .NET-сборку как "мое_имя.vstdll".


Работает все это следующим образом


  1. Вы скомпилировали и получили MyPlugin.dll
  2. Переименовываем MyPlugin.dll в MyPlugin.vstdll
  3. Копируем рядом Jacobi.Vst.Interop.dll
  4. Переименовываем Jacobi.Vst.Interop.dll на MyPlugin.dll
  5. Теперь хост будет грузить MyPlugin.dll (т.е. Jacobi.Vst.Interop обертку) а она, зная что ее имя "MyPlugin", загрузит вашу сборку MyPlugin.vstdll.

При загрузке вашей либы необходимо, чтобы в ней был класс, реализующий интерфейс IVstPluginCommandStub:


public interface IVstPluginCommandStub : IVstPluginCommands24
{
    VstPluginInfo GetPluginInfo(IVstHostCommandStub hostCmdStub);
    Configuration PluginConfiguration { get; set; }
}

VstPluginInfo содержит базовую о плагине — версия, уникальный ID плагина, число параметров и программ, число обрабатываемых каналов. PluginConfiguration нужна для вызывающей либы-обертки Jacobi.Vst.Interop.


В свою очередь, IVstPluginCommandStub реализует интерфейс IVstPluginCommands24, который содержит методы, вызываемые хостом: обработка массива (буфера) семплов, работа с параметрами, программами (пресетами), MIDI-сообщениями и так далее.


Jacobi.Vst.Framework содержит готовый удобный класс StdPluginCommandStub, реализующий IVstPluginCommandStub. Все что нужно сделать — отнаследоваться от StdPluginCommandStub и реализовать метод CreatePluginInstance(), который будет возвращать объект (instance) вашего класса-плагина, реализующего IVstPlugin.


public class PluginCommandStub : StdPluginCommandStub
{
    protected override IVstPlugin CreatePluginInstance()
    {
        return new MyPluginController();
    }
}

Опять же, есть готовый удобный класс VstPluginWithInterfaceManagerBase:


public abstract class VstPluginWithInterfaceManagerBase : PluginInterfaceManagerBase, IVstPlugin, IExtensible, IDisposable
{
    protected VstPluginWithInterfaceManagerBase(string name, VstProductInfo productInfo, VstPluginCategory category,
        VstPluginCapabilities capabilities, int initialDelay, int pluginID);

    public VstPluginCapabilities Capabilities { get; }
    public VstPluginCategory Category { get; }
    public IVstHost Host { get; }
    public int InitialDelay { get; }
    public string Name { get; }
    public int PluginID { get; }
    public VstProductInfo ProductInfo { get; }

    public event EventHandler Opened;

    public virtual void Open(IVstHost host);
    public virtual void Resume();
    public virtual void Suspend();
    protected override void Dispose(bool disposing);
    protected virtual void OnOpened();
}

Если смотреть исходный код библиотеки, можно увидеть интерфейсы, описывающие компоненты плагина, для работы с аудио, параметрами, MIDI и т.д. :


IVstPluginAudioProcessor
IVstPluginParameters
IVstPluginPrograms
IVstHostAutomation
IVstMidiProcessor

Класс VstPluginWithInterfaceManagerBase содержит виртуальные методы, возвращающие эти интерфейсы:


protected virtual IVstPluginAudioPrecisionProcessor CreateAudioPrecisionProcessor(IVstPluginAudioPrecisionProcessor instance);
protected virtual IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance);
protected virtual IVstPluginBypass CreateBypass(IVstPluginBypass instance);
protected virtual IVstPluginConnections CreateConnections(IVstPluginConnections instance);
protected virtual IVstPluginEditor CreateEditor(IVstPluginEditor instance);
protected virtual IVstMidiProcessor CreateMidiProcessor(IVstMidiProcessor instance);
protected virtual IVstPluginMidiPrograms CreateMidiPrograms(IVstPluginMidiPrograms instance);
protected virtual IVstPluginMidiSource CreateMidiSource(IVstPluginMidiSource instance);
protected virtual IVstPluginParameters CreateParameters(IVstPluginParameters instance);
protected virtual IVstPluginPersistence CreatePersistence(IVstPluginPersistence instance);
protected virtual IVstPluginProcess CreateProcess(IVstPluginProcess instance);
protected virtual IVstPluginPrograms CreatePrograms(IVstPluginPrograms instance);

Эти методы и нужно перегружать, чтобы реализовывать свою логику в кастомных классах-компонентах. Например, вы хотите обрабатывать семплы, тогда вам нужно написать класс, реализующий IVstPluginAudioProcessor, и вернуть его в методе CreateAudioProcessor.


public class MyPlugin : VstPluginWithInterfaceManagerBase
{
    ...
    protected override IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance)
    {
        return new MyAudioProcessor();
    }
    ...
}
...
public class MyAudioProcessor : VstPluginAudioProcessorBase // используем готовый класс из либы
{
    public override void Process(VstAudioBuffer[] inChannels, VstAudioBuffer[] outChannels)
    {
        // обработка семплов
    }
}

Используя различные готовые классы-компоненты можно сосредоточиться на программировании логики плагина. Хотя, вам никто не мешает реализовывать все самому, как хочется, основываясь только на интерфейсах из Jacobi.Vst.Core.


Для тех, кто уже кодит — предлагаю вам пример просто плагина, который понижает громкость на 6 дБ (для этого нужно умножить семпл на 0.5, почему — читай в статье про звук).


Пример просто плагина
using Jacobi.Vst.Core;
using Jacobi.Vst.Framework;
using Jacobi.Vst.Framework.Plugin;

namespace Plugin
{
    public class PluginCommandStub : StdPluginCommandStub
    {
        protected override IVstPlugin CreatePluginInstance()
        {
            return new MyPlugin();
        }
    }

    public class MyPlugin : VstPluginWithInterfaceManagerBase
    {
        public MyPlugin() : base(
            "MyPlugin",
            new VstProductInfo("MyPlugin", "My Company", 1000),
            VstPluginCategory.Effect,
            VstPluginCapabilities.None,
            0,
            new FourCharacterCode("TEST").ToInt32())
        {
        }

        protected override IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance)
        {
            return new AudioProcessor();
        }
    }

    public class AudioProcessor : VstPluginAudioProcessorBase
    {
        public AudioProcessor() : base(2, 2, 0) // плагин будет обрабатывать стерео
        {
        }

        public override void Process(VstAudioBuffer[] inChannels, VstAudioBuffer[] outChannels)
        {
            for (int i = 0; i < inChannels.Length; ++i)
            {
                var inChannel = inChannels[i];
                var outChannel = outChannels[i];
                for (int j = 0; j < inChannel.SampleCount; ++j)
                {
                    outChannel[j] = 0.5f * inChannel[j];
                }
            }
        }
    }
}


Моя надстройка над VST .NET


При программировании синта я столкнулся с некоторыми проблемами при использовании классов из Jacobi.Vst.Framework. Основная проблема заключалась в использовании параметров и их автоматизации.


Во первых, мне не понравилась реализация событий изменения значения; во вторых, обнаружились баги при тестировании плагина в FL Studio и Cubase. FL Studio воспринимает все параметры как float-числа от 0 до 1, даже не используя специальную функцию из VST SDK с опкодом effGetParameterProperties (функция вызывается у плагина чтобы получить дополнительную информацию о параметре). В WDL-OL реализация закомментирована с пометкой:


could implement effGetParameterProperties to group parameters, but can't find a host that supports it


Хотя, конечно же, в Cubase эта функция вызывается (Cubase — продукт компании Steinberg, которая и выпустила VST SDK).


В VST .NET этот коллбэк реализован в виде функции GetParameterProperties, возвращающей объект класса VstParameterProperties. Все равно, Cubase некорректно воспринимал и автоматизировал мои параметры.


В ��ачале я внес правки саму библиотеку, написал автору, чтобы он дал разрешение выложить исходники в репозиторий, либо сам создал репозиторий на GitHub'е. Но внятного ответа я так и не получил, поэтому решил сделать надстройку над либой — Syntage.Framework.dll.


Помимо этого, в надстройке реализованы удобные классы для работы с UI, если вы хотите использовать WPF.


Самое время скачать исходный код моего синтезатора и скомпилировать его.


Компилирование кода
  1. Склонировать/скачать репозиторий.
  2. Собрать солюшн в Visual Studio в Debug.
  3. Чтобы запустить синт из студии, нужно использовать проект SimplyHost.
  4. Файлы плагина и зависимые библиотеки будут в папке "out\vst\":


Правила использования моей надстройки просты: вместо StdPluginCommandStub юзаем SyntagePluginCommandStub, а свой плагин наследуем от SyntagePlugin.



WPF UI


В VST плагине не обязательно должен быть графический интерфейс. Я видел много плагинов без UI (одни из них — mda). Большинство DAW (по крайней мере, Cubase и FL Studio) предоставят вам возможность управлять параметрами из сгенерированного ими UI.



Автосгенерированный UI для моего синтезатора в FL Studio


Чтобы ваш плагин был с UI, во-первых, у вас должен быть класс, реализующий IVstPluginEditor; во-вторых, нужно вернуть его инстанс в перегруженной функции CreateEditor вашего класса плагина (наследник SyntagePlugin).


Я написал класс PluginWpfUI<T>, который непосредственно владеет WPF-окном. Здесь T — это тип вашего UserControl, являющийся "главной формой" UI. PluginWpfUI<T> имеет 3 виртуальных метода, которые вы можете перегружать для реализации своей логики:


  • public virtual void Open(IntPtr hWnd) — вызывается при каждом открытии UI плагина
  • public virtual void Close() — вызывается при каждом закрытии UI плагина
  • public virtual void ProcessIdle() — вызывается несколько раз в секунду из UI-потока, для обработки кастомной логики (базовая реализация пустая)

В своем синтезаторе Syntage я написал пару контролов — слайдер, крутилка (knob), клавиатура пианино — если вы хотите, можете их скопировать и использовать.



UI-поток (thread)


Я тестировал синтезатор в FL Studio и Cubase 5 и уверен, что, в других DAW будет тоже самое: UI плагина обрабатывается отдельным потоком. А это значит, что логики аудио и UI обрабатывается в независимых потоках. Это влечет все проблемы, или, последствия такого подхода: доступ к данным из разных потоков, критические данные, доступ к UI из другого потока...


Для облегчения решения проблем я написал класс UIThread, который, по сути, является очередью команд. Если вы в какой-то момент хотите что-то сообщить/поменять/сделать в UI, а текущий код работает не в UI-потоке, то вы можете поставить на выполнение в очередь необходимую функцию:


UIThread.Instance.InvokeUIAction(() => Control.Oscilloscope.Update());

Здесь в очередь команд помещается анонимный метод, обновляющий нужные данные. При вызове ProcessIdle все накопившиеся в очереди команды будут выполнены.


UIThread не решает всех проблем. При программировании осциллографа необходимо было обновлять UI по массиву семплов, который обрабатывался в другом потоке. Пришлось использовать мьютексы.



Обзор архитектуры синтезатора Syntage


При написании синтезатора активно использовалось ООП; предлагаю вам познакомиться с получившейся архитектурой и использовать мой код. Вы можете сделать все по-своему, но в этих статьях придется терпеть мое видение)



Класс PluginCommandStub нужен только чтобы создать и вернуть объект класса PluginController. PluginController предоставляет информацию о плагине, так же создает и владеет следующими компонентами:


  • AudioProcessor — класс со всей логикой обработки аудио данных
  • MidiListener — класс для обработки MIDI-сообщений (из Syntage.Framework)
  • PluginUI (наследник PluginWpfUI<View>) — класс, управляющий графическим интерфейсом синтезатора, главная форма — это UserControl "View".


Чтобы обрабатывать аудиоданные есть интерфейсы IAudioChannel и IAudioStream. IAudioChannel предоставляет прямой доступ к массиву/буферу семплов (double[] Samples). IAudioStream содержит массив каналов.


Представленные интерфейсы содержат удобные методы обработки всех семплов и каналов "скопом": микширование каналов и потоков, применение метода к каждому семплу в отдельности и так далее.


Для интерфейсов IAudioChannel и IAudioStream написаны реализации AudioChannel и AudioStream. Здесь важно запомнить следующую вещь: нельзя хранить ссылки на AudioStream и AudioChannel, если они являются внешними данными в функции. Суть в том, что размеры буферов могут меняться по ходу работы плагина, буферы постоянно переиспользуются — не выгодно постоянно перевыделять и копировать память. Если вам необходимо сохранить буфер для дальнейшего использования (уж не знаю, зачем) — копируйте его в свой буфер.


IAudioStreamProvider является владельцем аудиопотоков, можно попросить создать поток функцией CreateAudioStream и вернуть поток для его удаления функцией ReleaseAudioStream.


В каждый момент времени длина (длина массива семплов) всех аудиопотоков и каналов одинакова, технически она определяется хостом. В коде ее можно получить либо у самого IAudioChannel или IAudioStream (свойство Length), так же у "хозяина" IAudioStreamProvider (свойство CurrentStreamLenght).



Класс AudioProcessor является "ядром" синтезатора — в нем-то и происходит синтез звука. Класс является наследником SyntageAudioProcessor, который, в свою очередь, реализует следующие интерфейсы:


  • VstPluginAudioProcessorBase — чтобы обрабатывать буфер семплов (метод Process)
  • IVstPluginBypass — чтобы отключать логику синтезатора, если плагин находится в режиме Bypass
  • IAudioStreamProvider — чтобы предоставлять аудиопотоки для генераторов

Синтез звука проходит длинную цепочку обработки: создание простой волны в осцилляторах, ��икширование звука с разных осцилляторов, последовательная обработка в эффектах. Логика создания и обработки звука была разделена на классы-компоненты для AudioProcessor. Каждый компонент является наследником класса SyntageAudioProcessorComponentWithParameters<T> — содержит ссылку на AudioProcessor и возможность создавать параметры.

В синтезаторе представлены следующие компоненты:


  • Input — обрабатывает сообщения о нажатии нот (MIDI-сообщения и нажатия из UI)
  • Oscillator — осциллятор
  • ADSR — огибающая сигнала
  • ButterworthFilter — фильтр частот
  • Distortion — эффект дисторшн
  • Delay — эффект эхо/дилей
  • Clip — ограничивает значение всех семплов от -1 до 1.
  • LFO — модулирование параметров (обычно в синтезаторах модуляция осуществляется с использованием Low Frequency Oscillator — генератора низких частот)
  • Master — мастер-обработка (финальная обработка) сигнала. В данном случае содержит ручку главной громкости.
  • Oscillograph — осциллограф
  • Routing — содержит в себе цепочку логики обработки звука

Все этапы создания звука вы можете найти в функции Routing.Process и на следующей схеме:



Звук одновременно создается на двух одинаковых осцилляторах (юзер может по-разному настроить их параметры). Для каждого осциллятора его звук проходит через огибающую. Два звука смешиваются в один, он проходит через фильтр частот, идет в эффект дисторшн, дилэй и клип. В мастере регулируется результирующая громкость звука. После мастера звук больше не модифицируется, но передается в осциллограф и блок LFO-модуляции (нужно для их внутренней логики).


Далее будет рассмотрено программирование логики класса Oscillator, а в следующих статьях будут рассмотрены другие классы-компоненты.


Чтобы использовать параметры, можно использовать абстрактный класс Parameter<T>, либо готовые реализации: EnumParameter, IntegerParameter, RealParameter и другие. Здесь важно понимать, что у параметра есть текущее значение Value типа T, и float-значение RealValue — отображающее обычное значение в отрезок [0,1] (нужно для работы с UI и хостом).



Настраиваем проект для создания плагина/инструмента


Наконец-то! Сейчас мы будем создавать плагин. Кодим мы на C#, и работаем в Visual Studio.
Создаем обычную .NET Class Library, и импортируем ссылки на Jacobi.Vst.Core.dll и Jacobi.Vst.Framework.dll, Syntage.Framework.dll.


Настроим копирование и переименование файлов при успешной компиляции проекта (зачем это нужно было написано в главе VST .NET).


Предлагаю вам использовать следующий скрипт (его нужно прописать в Project → Properties → Build Events → Post-build event command line, выполнение скрипта поставьте на On successful build):


if not exist "$(TargetDir)\vst\" mkdir "$(TargetDir)\vst\"
copy "$(TargetDir)$(TargetFileName)" "$(TargetDir)\vst\$(TargetName).net.vstdll"
copy "$(TargetDir)Syntage.Framework.dll" "$(TargetDir)\vst\Syntage.Framework.dll"
copy "$(TargetDir)Jacobi.Vst.Interop.dll" "$(TargetDir)\vst\$(TargetName).dll"
copy "$(TargetDir)Jacobi.Vst.Core.dll" "$(TargetDir)\vst\Jacobi.Vst.Core.dll"
copy "$(TargetDir)Jacobi.Vst.Framework.dll" "$(TargetDir)\vst\Jacobi.Vst.Framework.dll"


Отладка кода


В файле моего проекта Syntage вы найдете сборку SimplyHost. Это простой хост, который на старте загружает плагин с расширением ".vstdll" (файл ищется рядом с .exe или в дочерних папках). Рекомендую вам скопировать его к себе в проект — тогда вы без проблем сразу сможете отлаживать свой плагин.


Вы так же можете использовать другие хосты для отладки, но сделать это будет уже сложнее. Когда я тестировал синтезатор, я использовал две DAW: FL Studio 12 и Cubase 5. Если в FL Studio загрузить плагин, можно из Visual Studio приконнектиться к процессу FL Studio (Debug → Attach To Process). Это не всегда работает, нужно быть очень внимательным: загружаемая .dll должна соответствовать вашему коду в студии (пересоберите проект перед отладкой); коннектиться к процессу можно только после загрузки вашего плагина в DAW.




Пишем простой осциллятор


Я надеюсь, что вы прочитали главу "Обзор архитектуры синтезатора Syntage" — я буду объяснять все в терминах своей архитектуры.


Самый простой звук — это чистый тон (синусоидальный сигнал, синус) определенной частоты. В природе вы вряд ли сможете услышать чистый тон. В жизни же можно услышать чистые тона в какой-нибудь электронике (и то, уверенности мало). Фурье сказал, что любой звук можно представить как одновременное звучание тонов разной частоты и громкости. Окраска звука характеризуется тембром — грубо говоря, описанием соотношения тонов в этом звуке (спектром).


Мы пойдем схожим путем — будем генерировать простой сигнал, а затем воздействовать на него и менять с помощью эффектов.


Какие выбрать "простые" сигналы? Очевидно, сигналы, спектр которых известен и хорошо изучен, которые легко обрабатывать. Возьмем четыре знаменитые типа сигналов:



Периоды четырех типов сигналов: синус, треугольник, импульс/квадрат, пила.


Чтобы синтезировать звуки, вы должны четко представлять себе исходное звучание этих простых сигналов.


Синус имеет глухое и тихое звучание, остальные же — "острое" и громкое. Это связано с тем, что, в отличие от синуса, другие сигналы содержат большое количество других тонов (гармоник) в спектре.


Наш генерируемый сигнал будет характеризоваться двумя параметрами: типом волны и частотой.
На графике изображены периоды нужных нам волн. Заметьте, что все волны представлены в интервале от 0 до 1. Это очень удобно, так как позволяет одинаково запрограммировать расчет значений. Такой подход позволяет задать произвольную форму сигнала, я даже видел синтезаторы, где можно вручную его нарисовать.


По представленным картинкам напишем вспомогательный класс WaveGenerator, с методом GetTableSample, который будет возвращать значение амплитуды сигнала в зависимости от типа волны и времени (время должно быть в пределах от 0 до 1).


Добавим так же в тип волны белый шум — он полезен в синтезе нестандартных звуков. Белый шум характеризуется тем, что спектральные составляющие равномерно распределены по всему диапазону частот. Функция NextDouble стандартного класса Random имеет равномерное распределение — таким образом, мы можем считать, что каждый сгенерированный семпл относится к некоторой гармонике. Соответственно, мы будем выбирать гармоники равномерно, получая белый шум. Нужно лишь сделать отображение результата функции из интервала [0,1] в интервал минимального и максимального значения амплитуды [-1,1].


public static class WaveGenerator
{
    public enum EOscillatorType
    {
        Sine,
        Triangle,
        Square,
        Saw,
        Noise
    }

    private static readonly Random _random = new Random();

    public static double GetTableSample(EOscillatorType oscillatorType, double t)
    {
        switch (oscillatorType)
        {
            case EOscillatorType.Sine:
                return Math.Sin(DSPFunctions.Pi2 * t);

            case EOscillatorType.Triangle:
                if (t < 0.25) return 4 * t;
                if (t < 0.75) return 2 - 4 * t;
                return 4 * (t - 1);

            case EOscillatorType.Square:
                return (t < 0.5f) ? 1 : -1;

            case EOscillatorType.Saw:
                return 2 * t - 1;

            case EOscillatorType.Noise:
                return _random.NextDouble() * 2 - 1;

            default:
                throw new ArgumentOutOfRangeException();
        }
    }
}

Теперь, пишем класс Oscillator, который будет наследником SyntageAudioProcessorComponentWithParameters<AudioProcessor>. В осцилляторе рождается звук, поэтому класс будет реализовывать интерфейс IGenerator, а именно функцию


IAudioStream Generate();

Необходимо запросить у IAudioStreamProvider (для нас это будет родительский AudioProcessor) аудиопоток, и в каждом вызове функции Generate заполнять его сгенерированными семплами.


Пока что у нашего осциллятора будет два параметра:


  • Тип волны — WaveGenerator.EOscillatorType, используем класс EnumParameter из Syntage.Framework
  • Частота сигнала — слышимый диапазон от 20 до 20000 Гц, используем класс FrequencyParameter из Syntage.Framework

Оформим все вышесказанное:


public class Oscillator : SyntageAudioProcessorComponentWithParameters<AudioProcessor>, IGenerator
{
    private readonly IAudioStream _stream; // поток, куда будем генерировать семплы
    private double _time;

    public EnumParameter<WaveGenerator.EOscillatorType> OscillatorType { get; private set; }
    public RealParameter Frequency { get; private set; }

    public Oscillator(AudioProcessor audioProcessor) :
        base(audioProcessor)
    {
        _stream = Processor.CreateAudioStream(); // запрашиваем поток
    }

    public override IEnumerable<Parameter> CreateParameters(string parameterPrefix)
    {
        OscillatorType = new EnumParameter<WaveGenerator.EOscillatorType>(parameterPrefix + "Osc", "Oscillator Type", "Osc", false);
        Frequency = new FrequencyParameter(parameterPrefix + "Frq", "Oscillator Frequency", "Hz");

        return new List<Parameter> { OscillatorType, Frequency };
    }

    public IAudioStream Generate()
    {
        _stream.Clear(); // очищаем все, что было раньше

        GenerateToneToStream(); // самое интересное

        return _stream;
    }
}

Осталось написать функцию GenerateToneToStream.


Каждый раз когда мы будем генерировать семплы сигнала, мы должны помнить о двух значениях:


  • длина текущего буфера
  • частота дискретизации

Оба параметра могут меняться во время работы плагина, поэтому не советую каким-либо образом их кешировать. Каждый вызов функции Generate() на вход плагину подается буфер конечной длины (длина определяется хостом, по времени она достаточно короткая) — звук генерируется "порциями". Мы должны запоминать, сколько времени прошло с момента начала генерирования волны, чтобы звук был "непрерывным". Пока что звук будем генерировать с момента старта плагина. Синхронизировать звук с нажатием клавиши будем в следующей статье.


Семплы генерируются в цикле от 0 до [длина текущего буфера].


Частота дискретизации — число семплов в секунду. Время, которое проходит от начала одного семпла до другого равно timeDelta = 1/SampleRate. При частоте дискретизации 44100 Гц это очень маленькое время — 0.00002267573 секунды.


Теперь мы можем знать, сколько времени в секундах прошло с момента старта до текущего семпла — заведем переменную _time и будем прибавлять к ней timeDelta каждую итерацию цикла.


Чтобы воспользоваться функцией WaveGenerator.GetTableSample нужно знать относительное время от 0 до 1, гд�� 1 — период волны. Зная нужную частоты волны, мы знаем и ее период — значение, обратное частоте.


Нужное относительное время мы можем получить как дробную часть деления прошедшего времени на период волны.


Пример: мы генерируем синус со знаменитой частотой 440 Гц. Из частоты находим период синуса: 1/440 = 0.00227272727 секунды.
Частота дискретизации 44100 Гц.
Рассчитаем 44150-й семпл, если на нулевом семпле время равнялось нулю.
На 44150-м семпле прошло 44150/44100 = 1.00113378685 секунд.
Смотрим, сколько это в периодах — 1.00113378685/0.00227272727 = 440.498866743.
Отбрасываем целую часть — 0.498866743. Именно это значение и нужно передать в функцию WaveGenerator.GetTableSample.


Если записать все символьно, получим:



Оформим выкладки в виде отдельной функции WaveGenerator.GenerateNextSample и запишем итоговую функцию GenerateToneToStream.


public static double GenerateNextSample(EOscillatorType oscillatorType, double frequency, double time)
{
    var ph = time * frequency;
    ph -= (int)ph; // реализация frac вычитанием целой части

    return GetTableSample(oscillatorType, ph);
}
...
private void GenerateToneToStream()
{
    var count = Processor.CurrentStreamLenght; // сколько семплов нужо сгенерировать
    double timeDelta = 1.0 / Processor.SampleRate; // столько времени разделяет два соседних семпла

    // кешируем ссылки на каналы, чтобы было меньше обращений в цикле
    var leftChannel = _stream.Channels[0];
    var rightChannel = _stream.Channels[1];

    for (int i = 0; i < count; ++i)
    {
        // Frequency и OscillatorType лучше не кешировать - это параметры плагина и
        // они могут меняться
        var frequency = DSPFunctions.GetNoteFrequency(Frequency.Value);
        var sample = WaveGenerator.GenerateNextSample(OscillatorType.Value, frequency, _time);

        leftChannel.Samples[i] = sample;
        rightChannel.Samples[i] = sample;

        _time += timeDelta;
    }
}

Обычно, в параметры осциллятора добавляют следующие:


  • Громкость
  • Подстройка (Fine) — изменение частоты генерируемой волны в большую или меньшую сторону. Можно получить эффект, похожий на wah-wah если модулировать этот параметр. Если генераторов много и они смешиваются, можно делать расстройку генераторов друг относительно друга.
  • Панировка/Стерео (Pan/Panning/Stereo) отношение громкостей сигнала в левом и правом ухе.

Данные параметры есть в реализованном мною синтезаторе — вы можете самостоятельно их реализовать.


Осталось реализовать классы AudioProcessor (будет создавать осциллятор и вызывать у него метод Generate) и PluginController (создает AudioProcessor).
Посмотрите реализацию данных классов в моем коде Syntage. На текущем этапе AudioProcessor нужен, чтобы:


  • Создать осциллятор
  • Заполнить параметры (вызвать функцию CreateParameters)
  • В функции обработки буфера семплов вызывать метод Generte у осциллятора

Простая реализация перечисленных классов, для ленивых
public class PluginCommandStub : SyntagePluginCommandStub<PluginController>
{
    protected override IVstPlugin CreatePluginInstance()
    {
        return new PluginController();
    }
}
...
public class PluginController : SyntagePlugin
{
    public AudioProcessor AudioProcessor { get; }

    public PluginController() : base(
        "MyPlugin",
        new VstProductInfo("MyPlugin", "TestCompany", 1000),
        VstPluginCategory.Synth,
        VstPluginCapabilities.None,
        0,
        new FourCharacterCode("TEST").ToInt32())
    {
        AudioProcessor = new AudioProcessor(this);

        ParametersManager.SetParameters(AudioProcessor.CreateParameters());
        ParametersManager.CreateAndSetDefaultProgram();
    }

    protected override IVstPluginAudioProcessor CreateAudioProcessor(IVstPluginAudioProcessor instance)
    {
        return AudioProcessor;
    }
}
...
public class AudioProcessor : SyntageAudioProcessor
{
    private readonly AudioStream _mainStream;

    public readonly PluginController PluginController;

    public Oscillator Oscillator { get; }

    public AudioProcessor(PluginController pluginController) :
        base(0, 2, 0) // у нас синт, на вход он не принимает данные, а только генерирует стерео-сигнал
    {
        _mainStream = (AudioStream)CreateAudioStream();

        PluginController = pluginController;

        Oscillator = new Oscillator(this);
    }

    public override IEnumerable<Parameter> CreateParameters()
    {
        var parameters = new List<Parameter>();

        parameters.AddRange(Oscillator.CreateParameters("O"));

        return parameters;
    }

    public override void Process(VstAudioBuffer[] inChannels, VstAudioBuffer[] outChannels)
    {
        base.Process(inChannels, outChannels);

        // генерируем семплы
        var stream = Oscillator.Generate();

        // копируем полученный stream в _mainStream
        _mainStream.Mix(stream, 1, _mainStream, 0);

        // отправляем результат
        _mainStream.WriteToVstOut(outChannels);
    }
}

В следующей статье я расскажу как написать ADSR-огибающую.


Удачи в программировании!


P.S. В заголовке я писал что занимаюсь музыкой — если кому то интересно, можете послушать мою музыку, и в частности записанный diy-альбом.



Список литературы


  1. Теория звука. Что нужно знать о звуке, чтобы с ним работать. Опыт Яндекс.Музыки.
  2. Марпл-мл. С. Л. Цифровой спектральный анализ и его приложения.
  3. Айфичер Э., Джервис Б. — Цифровая обработка сигналов. Практический подход.
  4. Martin Finke's Blog "Music & Programming" цикл статей по созданию синта от и до на C++, используя библиотеку WDL-OL.
  5. Хабр-переводы Martin Finke's Blog
  6. Модульные аналоговые синтезаторы (большая хабр-статья затрагивающая вопросы синтеза звука, обзора аналоговых синтезаторов и их составляющих).