Програмування&Музика: розуміємо і пишемо VSTi синтезатор на C# WPF. Частина 1

Займаючись музичною творчістю, я часто роблю аранжування і запис на комп'ютері, використовуючи купу всяких VST плагінів і інструментів. Соромно зізнатися — я ніколи не розумів, як "накручують" звуки в синтезаторах. Програмування дозволило мені написати свій синтезатор, "пропустити через себе" процес створення звуку.
Я планую кілька статей, в яких буде докладно розказано, як написати свій VST плагін/інструмент: програмування осцилятора, частотного фільтра, різних ефектів і модуляції параметрів. Упор буде зроблений на практику, пояснення програмісту простою мовою, як же все це працює. Теорію (суворі висновки і докази) обійдемо стороною (природно, будуть посилання на статті та книги).
Зазвичай плагіни пишуться на C++ (кросплатформеність, можливість ефективно реалізувати алгоритми), але я вирішив обрати більш підходящий для мене мова — C#; сфокусуватися на вивченні самого синтезатора, алгоритмів, а не технічних деталей програмування. Для створення гарного інтерфейсу я використовував WPF. Можливість використання архітектури .NET дала можливість бібліотека-обгортка VST. NET.
Нижче представлений оглядовий ролик мого простого синтезатора, отриманих цікавих звучань.

нелегкий шлях, якщо ви готові — ласкаво просимо під кат.

Зміст
Загадковий світ синтезу звуку
Звук в цифровому вигляді
VST SDK
WDL-OL і JUCE
VST .NET
Моя надбудова над VST .NET
WPF UI
UI-потік
Огляд архітектури синтезатора Syntage
Налаштовуємо проект для створення плагіна/інструменту
Налагодження коду
Пишемо простий осцилятор
Список літератури

Загадковий світ синтезу звуку
Я дуже люблю музику, слухаю різні стилі, граю на різних інструментах, і, звичайно, складаю та записую аранжування. Коли я починав використовувати емулятори синтезаторів в звукозаписних програмах (та й зараз) я завжди перебирав купу пресетів, шукав потрібне звучання.
Перебираючи пресети одного синтезатора можна зустріти як "очікуваний" звук електронного синтезатора з дитинства (музика з мультика Летючий Корабель) так і імітацію ударних, звуків, шуму, навіть голоси! І все це робить один синтезатор, з одними і тими ж ручками параметрів. Це мене завжди дивувало, хоча я розумів: кожен звук — суть конкретна настройка всіх ручок.
Нещодавно я вирішив нарешті-таки розібратися, яким же чином створюється (або, правильніше сказати, синтезується) звук, як і чому потрібно крутити ручки, як видозмінюється від ефектів сигнал (візуально і на слух). І звичайно ж, навчитися (хоча б зрозуміти основи) самому "накручувати" звук, копіювати сподобалися мені стилі. Я вирішив піти одній цитаті:
"Скажи мені — і я забуду, покажи мені — і я запам'ятаю, дай мені зробити, і я зрозумію."
Конфуцій
Звичайно, все підряд робити не треба (куди стільки велосипедів?), але зараз я хочу отримати знання і найголовніше — поділитися ними з вами.
Мета: не заглиблюючись в теорію, створити простий синтезатор, зробивши наголос на пояснення процесів з точки зору програмування, на практиці.
У синтезаторі будуть:
Всі складові я планую розглянути в декількох статтях. В даній буде розглянуто програмування осцилятора.
будемо Програмувати на 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 і вище (наприклад Reaper взагалі з версією 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 Застосунком(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.5 f * 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. В папку "Libs/" завантажити і скопіювати наступні бібліотеки:
    1.1 Jacobi.Vst.Core.dll, Jacobi.Vst.Framework.dll, Jacobi.Vst.Interop з папки "CLR4\x86\Release" лінк
    1.2 CannedBytes.Midi.Message.dll з папки "_Packages\Code\Release" лінк
    1.3 NAudio.dll лінк
  3. Зібрати солюшн в Visual Studio в Debug.
  4. Щоб запустити сінт зі студії, потрібно використовувати проект SimplyHost.
  5. Файли плагіна і залежні бібліотеки в папці "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 → Властивості → 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.5 f) ? 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. Модульні аналогові синтезатори (велика хабр-стаття зачіпає питання синтезу звуку, огляду аналогових синтезаторів і їх складових).
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.