Занимаясь музыкальным творчеством, я часто делаю аранжировки и записи на компьютере — используя кучу всяких VST плагинов и инструментов. Стыдно признаться — я никогда не понимал, как "накручивают" звуки в синтезаторах. Программирование позволило мне написать свой синтезатор, "пропустить через себя" процесс создания звука.
Я планирую несколько статей, в которых будет пошагово рассказано, как написать свой VST плагин/инструмент: программирование осциллятора, частотного фильтра, различных эффектов и модуляции параметров. Упор будет сделан на практику, объяснение программисту простым языком, как же все это работает. Теорию (суровые выводы и доказательства) обойдем стороной (естественно, будут ссылки на статьи и книги).
Обычно плагины пишутся на C++ (кроссплатформенность, возможность эффективно реализовать алгоритмы), но я решил выбрать более подходящий для меня язык — C#; сфокусироваться на изучении самого синтезатора, алгоритмов, а не технических деталей программирования. Для создания красивого интерфейса я использовал WPF. Возможность использования архитектуры .NET дала возможность библиотека-обертка VST. NET.
Ниже представлен обзорный ролик моего простого синтезатора, полученных интересных звучаний.
Предстоит нелегкий путь, ��сли вы готовы — добро пожаловать под кат.
Цикл статей
- Понимаем и пишем VSTi синтезатор на C# WPF
- ADSR-огибающая сигнала
- Частотный фильтр Баттервота
- Delay, Distortion и модуляция параметров
Оглавление
- Загадочный мир синтеза звука
- Звук в цифровом виде
- VST SDK
- WDL-OL и JUCE
- VST .NET
- Моя надстройка над VST .NET
- WPF UI
- UI-поток
- Обзор архитектуры синтезатора Syntage
- Настраиваем проект для создания плагина/инструмента
- Отладка кода
- Пишем простой осциллятор
- Список литературы
Загадочный мир синтеза звука
Я очень люблю музыку, слушаю разные стили, играю на различных инструментах, и, конечно, сочиняю и записываю аранжировки. Когда я начинал использовать эмуляторы синтезаторов в звукозаписывающих программах (да и сейчас) я всегда перебирал кучу пресетов, искал подходящее звучание.
Перебирая пресеты одного синтезатора можно встретить как "ожидаемый" звук электронного синтезатора из детства (музыка из мультика Летучий Корабль) так и имитацию ударных, звуков, шума, даже голоса! И все это делает один синтезатор, с одними и теми же ручками параметров. Это меня всегда удивляло, хотя я понимал: каждый звук — суть конкретная настройка всех ручек.
Недавно я решил наконец-таки разобраться, каким же образом создаётся (или, правильнее сказать, синтезируется) звук, как и почему нужно крутить ручки, как видоизменяется от эффектов сигнал (визуально и на слух). И конечно же, научиться (хотя бы понять основы) самому "накручивать" звук, копировать понравившиеся мне стили. Я решил последовать одной цитате:
"Скажи мне — и я забуду, покажи мне — и я запомню, дай мне сделать — и я пойму."
Конфуций
Конечно, все подряд делать не надо (куда столько велосипедов?), но сейчас я хочу получить знания и самое главное — поделиться ими с вами.
Цель: не углубляясь в теорию, создать простой синтезатор, сделав упор на объяснение процессов с точки зрения программирования, на практике.
В синтезаторе будут:
- генератор волны (осциллятор)
- ADSR огибающая сигнала
- фильтр частот
- эхо/дилей
- модуляция параметров
Все составляющие я планирую рассмотреть в нескольких статьях. В данной будет рассмотрено программирование осциллятора.
Программировать будем на 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?
- Моя задача — понять как программируется синтезатор, обработка аудио, эффекты, а не как собрать плагин под максимальное количество форматов и ��латформ. "Техническое программирование" здесь отходит на второй план (конечно, это не повод писать плохой код и не использовать ООП).
- Я разбалован языком C#. Опять же, этот язык, в отличие от того же C++, позволяет не думать о некоторых технических моментах.
- Мне нравится технология WPF в плане ее визуальных возможностей.
Страничка библиотеки — vstnet.codeplex.com, там есть исходники, бинарники, документация. Как я понял, библиотека находится в стадии почти доделал и забил заморозки (не реализованы некоторые редко используемые функции, пару лет нет изменений репозитория).
Библиотека состоит из трех ключевых сборок:
- Jacobi.Vst.Core.dll — содержит интерфейсы, определяющие поведения хоста и плагина, вспомогательные классы аудио, событий, MIDI. Большая часть является оберткой нативных структур, дефайнов и перечислений из VST SDK.
- Jacobi.Vst.Framework.dll — содержит базовые классы плагинов, реализующие интерфейсы из Jacobi.Vst.Core, позволяющие ускорить разработку плагинов и не писать все с нуля; классы для более высокоуровневого взаимодействия "хост-плагин", различные менеджеры параметров и программ, MIDI-сообщений, работы с UI.
- 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".
Работает все это следующим образом
- Вы скомпилировали и получили MyPlugin.dll
- Переименовываем MyPlugin.dll в MyPlugin.vstdll
- Копируем рядом Jacobi.Vst.Interop.dll
- Переименовываем Jacobi.Vst.Interop.dll на MyPlugin.dll
- Теперь хост будет грузить 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.
Самое время скачать исходный код моего синтезатора и скомпилировать его.
- Склонировать/скачать репозиторий.
- Собрать солюшн в Visual Studio в Debug.
- Чтобы запустить синт из студии, нужно использовать проект SimplyHost.
- Файлы плагина и зависимые библиотеки будут в папке "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-альбом.
Список литературы
- Теория звука. Что нужно знать о звуке, чтобы с ним работать. Опыт Яндекс.Музыки.
- Марпл-мл. С. Л. Цифровой спектральный анализ и его приложения.
- Айфичер Э., Джервис Б. — Цифровая обработка сигналов. Практический подход.
- Martin Finke's Blog "Music & Programming" цикл статей по созданию синта от и до на C++, используя библиотеку WDL-OL.
- Хабр-переводы Martin Finke's Blog
- Модульные аналоговые синтезаторы (большая хабр-статья затрагивающая вопросы синтеза звука, обзора аналоговых синтезаторов и их составляющих).
