Все посты серии:
Часть 1. Введение и настройка
Часть 2. Изучение кода
Часть 3. VST и AU
Часть 4. Цифровой дисторшн
Часть 5. Пресеты и GUI
Часть 6. Синтез сигналов
Часть 7. Получение MIDI сообщений
Часть 8. Виртуальная клавиатура
Часть 9. Огибающие
Часть 10. Доработка GUI
Часть 11. Фильтр
Сегодня мы сделаем резонансный фильтр. Разработка фильтров — это сложная область, над которой ломают голову множество DSP инженеров по всему миру. Мы не будем погружаться в ее дебри, а создадим простой фильтр нижних частот (Low-Pass), полосовой (Band-Pass) и фильтр высоких частот (High-Pass) на основе алгоритма Пола Келлета.
Начнем, как вы догадываетесь, с создания класса Filter
. Удалите #include <iostream>
из Filter.h (если есть) и вставьте такое объявление класса:
class Filter {
public:
enum FilterMode {
FILTER_MODE_LOWPASS = 0,
FILTER_MODE_HIGHPASS,
FILTER_MODE_BANDPASS,
kNumFilterModes
};
Filter() :
cutoff(0.99),
resonance(0.0),
mode(FILTER_MODE_LOWPASS),
buf0(0.0),
buf1(0.0)
{
calculateFeedbackAmount();
};
double process(double inputValue);
inline void setCutoff(double newCutoff) { cutoff = newCutoff; calculateFeedbackAmount(); };
inline void setResonance(double newResonance) { resonance = newResonance; calculateFeedbackAmount(); };
inline void setFilterMode(FilterMode newMode) { mode = newMode; }
private:
double cutoff;
double resonance;
FilterMode mode;
double feedbackAmount;
inline void calculateFeedbackAmount() { feedbackAmount = resonance + resonance/(1.0 - cutoff); }
double buf0;
double buf1;
};
В private
находятся такие параметры как частота среза cutoff
и резонанс resonance
. Mode
определяет текущий режим работы фильтра (Lowpass, Highpass, Bandpass). Переменные feedbackAmount
, buf0
и buf1
используются для алгоритма фильтрации, но об этом позже.
Конструктор просто инициализирует переменные и вычисляет уровень обратной связи (calculateFeedbackAmount()
). Функция process
вызывается каждый семпл для обработки сигнала. Так как feedbackAmount
зависит от cutoff
и resonance
, сеттеры должны вызывать calculateFeedbackAmount
каждый раз после обновления этих параметров.
Добавьте в Filter.cpp алгоритм фильтра:
// By Paul Kellett
// http://www.musicdsp.org/showone.php?id=29
double Filter::process(double inputValue) {
buf0 += cutoff * (inputValue - buf0);
buf1 += cutoff * (buf0 - buf1);
switch (mode) {
case FILTER_MODE_LOWPASS:
return buf1;
case FILTER_MODE_HIGHPASS:
return inputValue - buf0;
case FILTER_MODE_BANDPASS:
return buf0 - buf1;
default:
return 0.0;
}
}
Короткий, да? По сути, он представляет из себя последовательно подключенные НЧ фильтры первого порядка. «Первого порядка» грубо говоря означает, что амплитуда составляющих сигнала над частотой среза каждую октаву становится в два раза ниже (т. е. громкость падает на 6 дБ). Две строки с вычислениями buf0
и buf1
идентичны: это и есть два НЧ фильтра первого порядка. Первая на вход принимает inputValue
, вторая — buf0
(выход первого фильтра). Простое последовательное соединение. Два раза по 6 дБ/окт дает нам 12 дБ/окт. В switch
видно, что buf1
это выход НЧ фильтра. Если, например, вместо него возвращать buf0
, то звук будет падать не на 12, а на 6 дБ/окт, то есть в сигнале будет больше высоких частот.
С case
FILTER_MODE_HIGHPASS
все понятно по названию. buf0
это только низкочастотная составляющая фильтра первого порядка. Если из inputValue
вычесть buf0
, останутся высокочастотные составляющие. Можно вычесть и buf1
, чтобы срез был более крутым.
Из case
FILTER_MODE_BANDPASS
ясно, что buf0 - buf1
это полосовой фильтр. В buf0
содержится немного больше компонент над частотой среза, чем их содержится в buf1
. Вычитая, мы получаем разницу.
Вывод следующий: вычитая низкочастотный выход высшего порядка из низкочастотного выхода низшего порядка, получим полосный фильтр.
Вычисление buf1
зависит от своего предыдущего значения. Такая структура с обратной связью называется фильтром с бесконечной импульсной характеристикой (Infinite Impulse Response, сокращенно IIR). Прочитайте этот материал, чтобы получше разобраться с типами фильтров. Углубляться в это сейчас не стоит, так как математика в этой области не самая простая, и это не цель данного руководства.
Лирическое отступление
Фильтры являются одними из тех компонентов синтезатора, которые определяют не только характер, но и объективное качество звучания. Лучшие реализации фильтров, как правило, относительно затратны с точки зрения вычислений, а следовательно, разработчикам приходится выбирать между качеством отдельных компонентов и общей функциональностью. Именно поэтому в таких замечательных по гибкости и разнообразию эффектов инструментах, как NI Massive, нельзя добиться «теплого лампового звука». Его звук может быть навороченным и интересным, но как бы вы не пытались сымитировать простое классическое звучание, звук будет тусклый. Но в данном случае ламповое тепло не требуется, ведь фокус внимания слушателя смещен на динамику компонентов звука (синтезаторы типа Massive являются самыми популярными инструментами для создания дабстепа). В то же время такой «простой» инструмент, как, например, AAS Ultra Analog, не смотря на отсутствие множества разных огибающих, низкочастотных осцилляторов и прочих эффектов, имеет эстетически приятный звук, каждую ноту которого можно слушать как живой инструмент. Если вы хотите больше углубиться в тему разработки фильтров, есть хорошая, лаконичная и бесплатная книга, написанная Вадимом Завалишиным, много лет работающим над проектом NI Reaktor. Конечно, в дизайне инструментов применяются и другие хитрости помимо качественной имплементации фильтров — дрожание фазы и амплитуды, спектральное насыщение и т.д. Но вернемся к нашему плагину.
Использование фильтра
Давайте включать фильтр в синтезатор. Начнем с GUI. Удалите bg.png из проекта (“Move to trash” в Xcode), скачайте и закиньте в проект новые изображения:
- filtermode.png
- knob_small.png (Та же ручка, но размером 50 на50 пикселей)
- bg.png (Новый фон с пространством под элементы управления фильтром)
Дописываем ссылки и ID в resource.h:
// Unique IDs for each image resource.
#define BG_ID 101
#define WHITE_KEY_ID 102
#define BLACK_KEY_ID 103
#define WAVEFORM_ID 104
#define KNOB_ID 105
#define KNOB_SMALL_ID 106
#define FILTERMODE_ID 107
// Image resource locations for this plug.
#define BG_FN "resources/img/bg.png"
#define WHITE_KEY_FN "resources/img/whitekey.png"
#define BLACK_KEY_FN "resources/img/blackkey.png"
#define WAVEFORM_FN "resources/img/waveform.png"
#define KNOB_FN "resources/img/knob.png"
#define KNOB_SMALL_FN "resources/img/knob_small.png"
#define FILTERMODE_FN "resources/img/filtermode.png"
Редактируем Synthesis.rc:
#include "resource.h"
BG_ID PNG BG_FN
WHITE_KEY_ID PNG WHITE_KEY_FN
BLACK_KEY_ID PNG BLACK_KEY_FN
WAVEFORM_ID PNG WAVEFORM_FN
KNOB_ID PNG KNOB_FN
KNOB_SMALL_ID PNG KNOB_SMALL_FN
FILTERMODE_ID PNG FILTERMODE_FN
Не забудьте #include "Filter.h"
в Synthesis.h и добавьте в private
:
Filter mFilter;
Дополним EParams
в Synthesis.cpp:
enum EParams
{
mWaveform = 0,
mAttack,
mDecay,
mSustain,
mRelease,
mFilterMode,
mFilterCutoff,
mFilterResonance,
mFilterAttack,
mFilterDecay,
mFilterSustain,
mFilterRelease,
mFilterEnvelopeAmount,
kNumParams
};
Измените в конструкторе вертикальное положение переключателя форм волны:
pGraphics->AttachControl(new ISwitchControl(this, 24, 38, mWaveform, &waveformBitmap));
Ручки огибающей можно оставить как есть. Нужно добавить переключатель режимов фильтра (Lowpass, Highpass, Bandpass). Добавьте его перед AttachGraphics(pGraphics)
:
GetParam(mFilterMode)->InitEnum("Filter Mode", Filter::FILTER_MODE_LOWPASS, Filter::kNumFilterModes);
IBitmap filtermodeBitmap = pGraphics->LoadIBitmap(FILTERMODE_ID, FILTERMODE_FN, 3);
pGraphics->AttachControl(new ISwitchControl(this, 24, 123, mFilterMode, &filtermodeBitmap));
Нам понадобятся ручки для изменения частоты среза и резонанса. Добавьте их туда же. Будем использовать новую ручку knob_small.png:
// Knobs for filter cutoff and resonance
IBitmap smallKnobBitmap = pGraphics->LoadIBitmap(KNOB_SMALL_ID, KNOB_SMALL_FN, 64);
// Cutoff knob:
GetParam(mFilterCutoff)->InitDouble("Cutoff", 0.99, 0.01, 0.99, 0.001);
GetParam(mFilterCutoff)->SetShape(2);
pGraphics->AttachControl(new IKnobMultiControl(this, 5, 177, mFilterCutoff, &smallKnobBitmap));
// Resonance knob:
GetParam(mFilterResonance)->InitDouble("Resonance", 0.01, 0.01, 1.0, 0.001);
pGraphics->AttachControl(new IKnobMultiControl(this, 61, 177, mFilterResonance, &smallKnobBitmap));
Держите в уме, что значение среза не в коем случае не должно быть 1.0
, иначе внутри calculateFeedbackAmount
все поделится на ноль, буквально.
В ProcessDoubleReplacing
скормите сгенерированные семплы звука фильтру:
leftOutput[i] = rightOutput[i] = mFilter.process(mOscillator.nextSample() * mEnvelopeGenerator.nextSample() * velocity / 127.0);
Фильтр должен реагировать на смену стадий огибающей. Дополните switch
в функции Synthesis::OnParamChange
:
case mFilterCutoff:
mFilter.setCutoff(GetParam(paramIdx)->Value());
break;
case mFilterResonance:
mFilter.setResonance(GetParam(paramIdx)->Value());
break;
case mFilterMode:
mFilter.setFilterMode(static_cast<Filter::FilterMode>(GetParam(paramIdx)->Int()));
break;
Можно провести первые испытания. Изобразите что-нибудь и покрутите ручку в процессе игры.
Резонанс
Резонанс — это просто пик на частоте среза. Его можно создать, взяв полосную составляющую, умножив ее на некоторое значение и прибавив к исходному сигналу.
Измените первую строку в алгоритме фильтра:
buf0 += cutoff * (inputValue - buf0 + feedbackAmount * (buf0 - buf1));
Все, что мы сделали, это добавили выход полосного фильтра (buf0 – buf1
), умноженный на feedbackAmount
. В теле функции calculateFeedbackAmount
параметр feedbackAmount
пропорционален resonance
, т. е. пик будет тем громче, чем выше резонанс.
Запустите плагин, зажмите любую ноту и покрутите ручку частоты среза с разными значениями резонанса. Если выкрутить резонанс на максимум, возникнет автоколебание, которое можно использовать для создания интересных эффектов. Имея всего несколько параметров, мы уже можем извлекать целую палитру разнообразных звуков, в особенности с ВЧ и полосным фильтром.
От -12 дБ/окт до -24 дБ/окт
Вместо двух давайте запилим четыре фильтра в ряд! Это даст нам затухание 24 децибела на октаву. Добавьте пару строк перед switch
в Filter::process
:
buf2 += cutoff * (buf1 - buf2);
buf3 += cutoff * (buf2 – buf3);
Идея та же: на вход принимаем выход предыдущего фильтра, вычитаем из него предыдущее значение текущего фильтра, умножаем эту разницу на срез и прибавляем к предыдущему значению текущего фильтра. Это можно продолжать и дальше, но фильтр станет затратным по вычислениям и, возможно, нестабильным (обсудим это подробнее в одном из следующих постов). Не забудьте изменить и switch
:
switch (mode) {
case FILTER_MODE_LOWPASS:
return buf3;
case FILTER_MODE_HIGHPASS:
return inputValue - buf3;
case FILTER_MODE_BANDPASS:
return buf0 - buf3;
default:
return 0.0;
}
Здесь вместо buf1
используется buf3
– выход четырех последовательных фильтров первого порядка. Затухание компонент равно -24 дБ/окт. Мы еще не объявили buf2
и buf3
, так что давайте сделаем это в секции private
Filter.h:
double buf2;
double buf3;
И инициализируем их нулями, как и buf0
c buf1
:
Filter() :
// ...
buf0(0.0),
buf1(0.0),
buf2(0.0),
buf3(0.0)
// ...
Если запустить плагин и послушать, заметно, что фильтр стал резать круче: частоты над срезом заглушены сильнее. Это несколько ближе к звучанию аналоговых синтезаторов.
А что если мы будем изменять частоту среза во времени?
Огибающая фильтра
Самое интересное еще впереди :) Благодаря тому, что мы с самого начала старались делать наш дизайн хорошо структурированным, добавить вторую огибающую для фильтра очень просто.
Вообще-то нам не стоит непосредственно изменять cutoff
при помощи огибающей. Частота среза связана с ручкой интерфейса. Давайте добавим переменную cutoffMod
, которая будет меняться огибающей, и которую мы будем добавлять к cutoff
для вычисления суммарного среза. В хедере фильтра добавьте #include <cmath>
и допишите в private
переменную:
double cutoffMod;
Инициализируйте:
Filter() :
cutoff(0.99),
resonance(0.01),
cutoffMod(0.0),
// ...
Суммарный срез не должен выходить из области допустимых значений. Добавьте в private
строки:
inline double getCalculatedCutoff() const {
return fmax(fmin(cutoff + cutoffMod, 0.99), 0.01);
};
calculateFeedbackAmount
должна использовать этот вычисленный срез:
inline void calculateFeedbackAmount() {
feedbackAmount = resonance + resonance/(1.0 - getCalculatedCutoff());
}
И давайте добавим публичный сеттер для cutoffMod
. Так как feedbackAmount
зависит от вычисленного среза, сеттер и его должен обновлять:
inline void setCutoffMod(double newCutoffMod) {
cutoffMod = newCutoffMod;
calculateFeedbackAmount();
}
Логично, что и алгоритм фильтра надо немного подправить. Первые строки Filter::process
должны выглядеть так:
if (inputValue == 0.0) return inputValue;
double calculatedCutoff = getCalculatedCutoff();
buf0 += calculatedCutoff * (inputValue - buf0 + feedbackAmount * (buf0 - buf1));
buf1 += calculatedCutoff * (buf0 - buf1);
buf2 += calculatedCutoff * (buf1 - buf2);
buf3 += calculatedCutoff * (buf2 – buf3);
Благодаря первой строке фильтр не будет работать впустую, когда его вход молчит. Подобная проверка имеет смысл, когда обусловленный ею код заметно сложнее, чем сама проверка. Это как раз тот случай. Помимо этого и замены cutoff
на calculatedCutoff
все остается по-прежнему.
Теперь, когда срезом фильтра можно управлять извне (вызывая setCutoffMod
), в класс Synthesis
надо добавить огибающую фильтра, которая будет запускаться так же, как существующая огибающая осциллятора. Пользователь сможет менять то, насколько сильно эта огибающая будет влиять на cutoffMod
. Добавим новый параметр filterEnvelopeAmount
со значениями от -1
до +1
. Потом доработаем GUI.
В секцию private
Synthesis.h добавьте пару членов класса:
EnvelopeGenerator mFilterEnvelopeGenerator;
double filterEnvelopeAmount;
Мы хотим запускать оба генератора огибающих MIDI сообщениями, так что надо отредактировать функции onNoteOn
и onNoteOff
:
inline void onNoteOn(const int noteNumber, const int velocity) {
mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
mFilterEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
};
inline void onNoteOff(const int noteNumber, const int velocity) {
mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
mFilterEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
};
Смысл остался тот же. В теле ProcessDoubleReplacing
непосредственно перед вычислением семплов звука добавьте эту строку:
mFilter.setCutoffMod(mFilterEnvelopeGenerator.nextSample() * filterEnvelopeAmount);
Как видите, мы перемножаем следующее значение семпла огибающей фильтра nextSample
на filterEnvelopeAmount
и записываем результат в cutoffMod
. Надо не забыть инициализировать filterEnvelopeAmount
в конструкторе:
Synthesis::Synthesis(IPlugInstanceInfo instanceInfo) : IPLUG_CTOR(kNumParams, kNumPrograms, instanceInfo),
lastVirtualKeyboardNoteNumber(virtualKeyboardMinimumNoteNumber - 1),
filterEnvelopeAmount(0.0) {
// ...
}
И обязательно установить частоту семплирования в Synthesis::Reset
:
mFilterEnvelopeGenerator.setSampleRate(GetSampleRate());
Мы уже добавили параметры в EParams
, теперь их надо инициализировать и добавить ручки. Перед AttachGraphics
в конструкторе добавьте все это:
// Knobs for filter envelope
// Attack knob
GetParam(mFilterAttack)->InitDouble("Filter Env Attack", 0.01, 0.01, 10.0, 0.001);
GetParam(mFilterAttack)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 139, 178, mFilterAttack, &smallKnobBitmap));
// Decay knob:
GetParam(mFilterDecay)->InitDouble("Filter Env Decay", 0.5, 0.01, 15.0, 0.001);
GetParam(mFilterDecay)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 195, 178, mFilterDecay, &smallKnobBitmap));
// Sustain knob:
GetParam(mFilterSustain)->InitDouble("Filter Env Sustain", 0.1, 0.001, 1.0, 0.001);
GetParam(mFilterSustain)->SetShape(2);
pGraphics->AttachControl(new IKnobMultiControl(this, 251, 178, mFilterSustain, &smallKnobBitmap));
// Release knob:
GetParam(mFilterRelease)->InitDouble("Filter Env Release", 1.0, 0.001, 15.0, 0.001);
GetParam(mFilterRelease)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 307, 178, mFilterRelease, &smallKnobBitmap));
// Filter envelope amount knob:
GetParam(mFilterEnvelopeAmount)->InitDouble("Filter Env Amount", 0.0, -1.0, 1.0, 0.001);
pGraphics->AttachControl(new IKnobMultiControl(this, 363, 178, mFilterEnvelopeAmount, &smallKnobBitmap));
Все практически то же самое, но тут вместо большой ручки используем smallKnobBitmap
. Вдобавок к четырем ручкам для стадий огибающей фильтра мы сделали еще одну для регулировки количества воздействия огибающей на фильтр. Осталось только реагировать на то, как пользователь крутит ручки. В Synthesis::OnParamChange
добавьте в существующий switch
:
case mFilterAttack:
mFilterEnvelopeGenerator.setStageValue(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK, GetParam(paramIdx)->Value());
break;
case mFilterDecay:
mFilterEnvelopeGenerator.setStageValue(EnvelopeGenerator::ENVELOPE_STAGE_DECAY, GetParam(paramIdx)->Value());
break;
case mFilterSustain:
mFilterEnvelopeGenerator.setStageValue(EnvelopeGenerator::ENVELOPE_STAGE_SUSTAIN, GetParam(paramIdx)->Value());
break;
case mFilterRelease:
mFilterEnvelopeGenerator.setStageValue(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE, GetParam(paramIdx)->Value());
break;
case mFilterEnvelopeAmount:
filterEnvelopeAmount = GetParam(paramIdx)->Value();
break;
Кислота!
Запускайте и тестируйте новую функциональность! Попробуйте вот такие положения ручек и поиграйте на нижних нотах (где-нибудь в бассейне C1), будет забавно хлюпать:
С незначительными доработками мы использовали наш класс EnvelopeGenerator
для фильтра. Это делает синтезатор более гибким и расширяет палитру звуков. Мы уже приближаемся к завершению классического монофонического синтезатора!
Исходники можно скачать отсюда.
В следующий раз мы добавим низкочастотный осциллятор :)
Автор: 1eqinfinity