Pull to refresh

Создание аудиоплагинов, часть 16

Reading time8 min
Views8.7K
Все посты серии:
Часть 1. Введение и настройка
Часть 2. Изучение кода
Часть 3. VST и AU
Часть 4. Цифровой дисторшн
Часть 5. Пресеты и GUI
Часть 6. Синтез сигналов
Часть 7. Получение MIDI сообщений
Часть 8. Виртуальная клавиатура
Часть 9. Огибающие
Часть 10. Доработка GUI
Часть 11. Фильтр
Часть 12. Низкочастотный осциллятор
Часть 13. Редизайн
Часть 14. Полифония 1
Часть 15. Полифония 2
Часть 16. Антиалиасинг



Чтобы наш SpaceBass звучал еще лучше, нужно создать осциллятор, в котором было бы меньше алиасинга. Это опциональное улучшение. Без него синтезатор будет работать как и раньше, но с ним звук на верхних октавах будет значительно лучше.

Анализ спектра



Я бы хотел показать вам очень хороший бесплатный плагин: Voxengo SPAN. Его можно повесить на дорожку, и он будет показывать спектр проходящего через него сигнала. На данном этапе рановато писать собственные тестовые процедуры с FFT, так что SPAN будет незаменимым инструментом для сравнения результатов различных алгоритмов для осцилляторов. Скачивайте и устанавливайте. Запускайте SpaceBass в REAPER и сделайте следующее:

  • Ручку Mix поверните до упора налево, чтобы был слышен только первый осциллятор
  • Выберите для него меандр (квадратную форму волны)
  • Частоту среза поставьте на максимум, резонанс на минимум
  • LFO фильтра на минимум, ручка огибающей примерно посередине


С такими настройками слышна сырая форма волны первого осциллятора. Теперь повесьте SPAN на эту же дорожку после синтезатора. Зажмите высокую ноту (я использовал Ля шестой октавы) и посмотрите на спектрограмму в SPAN, должно выглядеть примерно так:



Читать ее очень просто: по оси x — частота в герцах, в этом плагине отображаются частоты от 66 Гц до 20 кГц. Шкала логарифмическая, т.е. расстояние между октавами всегда одинаковое — между До первой октавы и До второй столько же, сколько между До седьмой и восьмой. Соотношение частот соседних октав — два к одному. В то время как гармоники сигнала — это умноженная (или поделенная) на разные целые числа основная частота. А значит, гармоники распределяются по оси x неравномерно.
По оси y — амплитуда в децибелах. Таким образом очень легко определить, какая частота какую амплитуду имеет в данный момент.
В зависимости от настроек, на вашей и моей спектрограммах могут отличаться некоторые моменты, но одно ясно точно: что-то не так. Мы ожидали увидеть спектрограмму меандра — ряд пиков, амплитуда которых падает с ростом частоты, а между пиками — ничего. И уж точно мы не ожидали увидеть какие-либо спектральные составляющие ниже основной частоты (в левой части графика). Как вы помните, такая ерунда это и есть алиасинг.

Что с ним делать? Существуют разные подходы к решению. Самое аккуратное решение — синтезировать члены ряда Фурье для аппроксимации меандра. По сути, это накладывание друг на друга синусов с правильно подобранными амплитудами, начиная с фундаментальной частоты и выше, по одному синусу на гармонику. Но синтез гармоник необходимо будет прекратить при достижении частоты Найквиста. Этот подход даст идеальную полосно-ограниченную (англ. bandlimited) форму волны, все спектральные компоненты которой находятся строго в пределах между основной частотой и частотой Найквиста.
Естественно, есть одна проблема. Этот метод мог бы подойти для самых высоких октав, где фундаментальная (основная) частота настолько высока, что до частоты Найквиста остается мало гармоник. Но для нижних октав гармоник, мягко говоря, много: при частоте 44.1 кГц меандр с основной частотой 100 Гц будет иметь 219 гармоник до Найквиста, а значит в сумме надо будет вычислять sin() 220 раз каждый семпл. В полифонической модели это число еще умножается на количество играемых нот. С одной стороны, сложить синусы для каждой ноты нужно только один раз для каждой ноты. Но это так, если у нас нет никакой модуляции тона. Как только она есть, частота может меняться каждый семпл, так что нужно проделывать очень много работы.

BLIPы и BLEPы



Есть и другие подходы к синтезу. Самые примечательные:


Последние два подхода основаны на том, что алиасинг возникает только из-за резких перепадов в форме волны. С теми формами волны, которые мы синтезируем, единственной проблемой являются эти резкие перепады. Можем ли мы сточить их, как деревяшку наждачкой? Простое скругление эквивалентно легкой низкочастотной фильтрации, а это не то, что нам надо. Нам нужно ничего не фильтровать до частоты Найквиста, и ничего не иметь выше нее, как будто это просто наложенные синусы.
Синтез меандра из синусов выглядит так:



Синим цветом обозначены накладываемые друг на друга синусы, красным — получаемый полосно-ограниченный меандр. Как видите, это не просто закругленные углы. У этой формы волны есть характерные колебания, «рябь».
Если упростить, методы BLEP сначала генерируют форму волны, как мы это делали раньше, а затем накладывает эту рябь. Это устраняет (или сильно подавляет) алиасинг.

Если вы сходили по ссылкам сверху, вы уже догадываетесь, что метод PolyBLEP — самый простой. Его мы и используем!

Класс PolyBLEPOscillator



PolyBLEPOscillator это Oscillator, так что мы будем публично наследовать от последнего.

Создайте новый класс PolyBLEPOscillator в нашем проекте. Если вы не читали предыдущих статей, скачайте готовый проект и начинайте с этого момента.

Так выглядит определение класса:

#include "Oscillator.h"

class PolyBLEPOscillator: public Oscillator {
public:
    PolyBLEPOscillator() : lastOutput(0.0) { updateIncrement(); };
    double nextSample();
private:
    double poly_blep(double t);
    double lastOutput;
};


Мы наследуем публичность от Oscillator. Чтобы изменить способ синтеза, мы определяем новую член-функцию nextSample. Еще мы добавляем новую private функцию poly_blep, которая будет генерировать колебания на перепадах меандра. lastOutput хранит последнее сгенерированное значение (это понадобится только для треугольной формы волны).
Добавьте имплементацию poly_blep в PolyBLEPOscillator.cpp:

// PolyBLEP by Tale
// (slightly modified)
// http://www.kvraudio.com/forum/viewtopic.php?t=375517
double PolyBLEPOscillator::poly_blep(double t)
{
    double dt = mPhaseIncrement / twoPI;
    // 0 <= t < 1
    if (t < dt) {
        t /= dt;
        return t+t - t*t - 1.0;
    }
    // -1 < t < 0
    else if (t > 1.0 - dt) {
        t = (t - 1.0) / dt;
        return t*t + t+t + 1.0;
    }
    // 0 otherwise
    else return 0.0;
}


Это может выглядеть немного наворочено, но, по сути, функция почти всегда возвращает 0.0 за исключением тех случаев, когда мы близки к перепаду. Первый if для случая, когда мы в начале периода, а else if для того, когда мы почти в самом конце. Это и есть поведение пилы, потому что в ней только один перепад, между двумя периодами.

Перед тем, как имплементируем nextSample, надо кое-что поменять в классе осциллятора. Сделайте функцию nextSample в Oscillator.h виртуальной:

virtual double nextSample();


Это означает, что мы можем поменять поведение функции nextSample в нашем подклассе. Использование virtual в коде с критичным временем выполнения — это не самое лучшее решение. Можно использовать шаблоны (и избегать дубликации кода), но я хочу оставить объяснение на простом уровне и не отвлекаться от темы синтеза.
Измените private: на protected:. Так мы сможем получать доступ к таким параметрам как mPhase из функций-членов PolyBLEPOscillator.
Как я уже говорил, мы используем наши формы волн с алиасингом из класса Oscillator и наложим на них poly_blep. На данный момент nextSample вычисляет форму волны и осуществляет приращение фазы. Нам необходимо разделить эти несвязанные вещи.
Добавьте следующую protected функцию-член:

double naiveWaveformForMode(OscillatorMode mode);


Эта функция будет вычислять формы волн с алиасингом. Naive здесь означает, что форма волны генерируется простым и некорректным способом. Давайте напишем ее в Oscillator.cpp (можно просто скопировать, т.к. она практически идентична Oscillator::nextSample)

double Oscillator::naiveWaveformForMode(OscillatorMode mode) {
    double value;
    switch (mode) {
        case OSCILLATOR_MODE_SINE:
            value = sin(mPhase);
            break;
        case OSCILLATOR_MODE_SAW:
            value = (2.0 * mPhase / twoPI) - 1.0;
            break;
        case OSCILLATOR_MODE_SQUARE:
            if (mPhase < mPI) {
                value = 1.0;
            } else {
                value = -1.0;
            }
            break;
        case OSCILLATOR_MODE_TRIANGLE:
            value = -1.0 + (2.0 * mPhase / twoPI);
            value = 2.0 * (fabs(value) - 0.5);
            break;
        default:
            break;
    }
    return value;
}


Отличия от Oscillator::nextSample в следующем:
  • Форма волны выбирается в зависимости от параметра mode, переданного извне (вместо mOscillatorMode)
  • Пила теперь восходящая, а не нисходящая


Т.к. эта функция содержит весь код из Oscillator::nextSample, заменим тело nextSample на это:

double Oscillator::nextSample() {
    double value = naiveWaveformForMode(mOscillatorMode);
    mPhase += mPhaseIncrement;
    while (mPhase >= twoPI) {
        mPhase -= twoPI;
    }
    return value;
}


Здесь просто вызывается naiveWaveformForMode для вычисления формы волны и приращивается mPhase.

Генерация методом PolyBLEP



Вернемся к PolyBLEPOscillator.cpp и напишем nextSample. Начнем так:

double PolyBLEPOscillator::nextSample() {
    double value = 0.0;
    double t = mPhase / twoPI;

    if (mOscillatorMode == OSCILLATOR_MODE_SINE) {
        value = naiveWaveformForMode(OSCILLATOR_MODE_SINE);
    } else if (mOscillatorMode == OSCILLATOR_MODE_SAW) {
        value = naiveWaveformForMode(OSCILLATOR_MODE_SAW);
        value -= poly_blep(t);
    }


Переменная t необходима для работы функции poly_blep. Это текущее значение фазы, поделенное на twoPI, так что оно всегда между 0 и 1. Первый if разделяет формы волны. Для синуса антиалиасинг не нужен, так как у него есть только одна, первая гармоника — сама его основная частота. Для пилы мы сначала получаем простую форму волны от осциллятора, а затем накладываем на нее pily_blep — вот и все!
Треугольник создадим так: сначала возьмем меандр, а затем проинтегрируем его. Так как мы работаем с дискретными значениями, интеграция означает просто суммирование значений. Если прикинуть, меандр начинается со сплошных единиц, так что их суммирование даст линейное приращение. После полупериода идут сплошные минус единицы, их интегрирование даст линейный спад. Треугольник представляет из себя именно это: линейный рост и линейный спад.
Держа это в уме, напишем код сразу и для меандра, и для треугольника:

    else {
        value = naiveWaveformForMode(OSCILLATOR_MODE_SQUARE);
        value += poly_blep(t);
        value -= poly_blep(fmod(t + 0.5, 1.0));


И снова мы начинаем с простой волны с алиасингом. Но в этот раз мы накладываем два PolyBLEPа. Один для начала периода, другой смещен на 0.5 периода, т.к. у меандра два перепада. У пилы только один перепад.
Не хватает только треугольника. Допишите в конец блока else:

        if (mOscillatorMode == OSCILLATOR_MODE_TRIANGLE) {
            // Leaky integrator: y[n] = A * x[n] + (1 - A) * y[n-1]
            value = mPhaseIncrement * value + (1 - mPhaseIncrement) * lastOutput;
            lastOutput = value;
        }


Ранее я написал, что мы будем интегрировать меандр. Это не совсем точно. Если просто интегрировать, это приведет к огромным выходным значениям, то есть у нас получится просто чудовищный перегруз. Вместо этого нужно использовать квазиинтегратор (leaky integrator). Он суммирует новое значение со старым, но умноженным на значение немного меньше единицы. Таким образом значения не зашкаливают.
Давайте допишем приращение фазы (тут все по-старому):

    }

    mPhase += mPhaseIncrement;
    while (mPhase >= twoPI) {
        mPhase -= twoPI;
    }
    return value;
}


Вот так просто было создать PolyBLEPOscillator!

Использование нового осциллятора



Чтобы использовать наш новый блестящий PolyBLEPOscillator надо только поменять пару строк в Voice.h. Замените #include "Oscillator.h" на #include "PolyBLEPOscillator.h".
В секции private превратите mOscillatorOne и mOscillatorTwo в объекты класса PolyBLEPOscillator:

PolyBLEPOscillator mOscillatorOne;
PolyBLEPOscillator mOscillatorTwo;


Вот и все! Запускайте наш плагин и давайте посмотрим на спектр. Как видите, эффект алиасинга очень заметно устраняется. Скриншоты до/после для сравнения:

Пила:
image
image

Меандр:
image
image

Треугольник:
image
image

А как же LFO?



Мы все еще используем старый Oscillator для LFO. Стоит ли переключиться на PolyBLEPOscillator? На самом деле в LFO резкие границы очень желательны, при помощи них можно получать интересные эффекты. А алиасинг нас не очень волнует, т.к. основная частота обычно низкая, меньше 30 Гц. Каждая следующая гармоника имеет амплитуду ниже предыдущей, так что частоты выше Найквиста имеют очень маленькую амплитуду.

Итог



Мы сгененировали меандр и пилу без алиасинга при помощи форм волны с алиасингом, на которые наложили PolyBLEP. Треугольник сгенерировали при помощи квазиинтегратора и меандра без алиасинга. Теперь можно счастливо играть на нашем синтюке на самых высоких октавах и не бояться, что будут возникать негармоничные частоты!

Код можно скачать отсюда.

Спасибо, что читали! :)

Оригинал поста.
Tags:
Hubs:
Total votes 37: ↑36 and ↓1+35
Comments4

Articles