- PVSM.RU - https://www.pvsm.ru -
Все посты серии:
Часть 1. Введение и настройка [1]
Часть 2. Изучение кода [2]
Часть 3. VST и AU [3]
Часть 4. Цифровой дисторшн [4]
Часть 5. Пресеты и GUI [5]
Часть 6. Синтез сигналов [6]
Часть 7. Получение MIDI сообщений [7]
Часть 8. Виртуальная клавиатура [8]
Часть 9. Огибающие [9]
Звук интересен тогда, когда в нем происходят какие-то изменения. Давайте сделаем генератор огибающей (envelope), которая будет менять громкость звука.
Если вам незнакома аббревиатура ADSR (Attack, Decay, Sustain, Release), прочитайте эту статейку [10] на Википедии прежде чем продолжить.
По сути, генератор будет представлять собой конечный автомат [11] с состояниями Off, Attack, Decay, Sustain и Release. Это такой заумный способ сказать, что в любой момент времени генератор будет находиться только в одном из этих пяти возможных состояний. В терминологии огибающих эти состояния называются стадиями (stages). Переход от одной стадии к другой будет осуществляться вызовом функции члена enterStage
.
Пара ключевых моментов, касающихся стадий:
enterStage
для переходаДля каждого семпла сигнала генератор будет выдавать значение типа double
между нулем и единицей. Полученное от генератора огибающей текущее значение мы будем перемножать с выходным сигналом осциллятора. Таким образом уровень сигнала будет определяться огибающей. Мы сможем делать разнообразные штуки со звуком: он будет медленно выплывать, резко спадать или быть ровным, как бревно.
Вообще ADSR – это первое приближение к более продвинутому моделированию звука. На многих современных софтовых и железных синтезаторах можно найти огибающие с большим количеством стадий. Все они, по сути, являются просто расширением той модели, которую мы сейчас создадим. Количество состояний таких конечных автоматов просто немного больше пяти.
Создайте новый класс EnvelopeGenerator
и добавьте во все таргеты (на Mac) и проекты (на Windows). Между #define
и #endif
в EnvelopeGenerator.h вставьте объявление класса:
#include <cmath>
class EnvelopeGenerator {
public:
enum EnvelopeStage {
ENVELOPE_STAGE_OFF = 0,
ENVELOPE_STAGE_ATTACK,
ENVELOPE_STAGE_DECAY,
ENVELOPE_STAGE_SUSTAIN,
ENVELOPE_STAGE_RELEASE,
kNumEnvelopeStages
};
void enterStage(EnvelopeStage newStage);
double nextSample();
void setSampleRate(double newSampleRate);
inline EnvelopeStage getCurrentStage() const { return currentStage; };
const double minimumLevel;
EnvelopeGenerator() :
minimumLevel(0.0001),
currentStage(ENVELOPE_STAGE_OFF),
currentLevel(minimumLevel),
multiplier(1.0),
sampleRate(44100.0),
currentSampleIndex(0),
nextStageSampleIndex(0) {
stageValue[ENVELOPE_STAGE_OFF] = 0.0;
stageValue[ENVELOPE_STAGE_ATTACK] = 0.01;
stageValue[ENVELOPE_STAGE_DECAY] = 0.5;
stageValue[ENVELOPE_STAGE_SUSTAIN] = 0.1;
stageValue[ENVELOPE_STAGE_RELEASE] = 1.0;
};
private:
EnvelopeStage currentStage;
double currentLevel;
double multiplier;
double sampleRate;
double stageValue[kNumEnvelopeStages];
void calculateMultiplier(double startLevel, double endLevel, unsigned long long lengthInSamples);
unsigned long long currentSampleIndex;
unsigned long long nextStageSampleIndex;
};
Для начала мы создаем enum
со всеми стадиями. kNumEnvelopeStages
в конце говорит нам, сколько всего стадий имеется. Обратите внимание, что этот enum
находится в границах пространства имен класса огибающей, и не будет доступен извне, в глобальном пространстве имен.
Мы рассмотрим функции члены подробнее, когда будем их имплементировать. Минимальный уровень minimumLevel
необходим, т. к. вычисления уровня сигнала не будут работать с нулем. Мы инициализируем эту переменную с очень маленьким значением 0.001
.
В списке инициализации огибающая по умолчанию находится на стадии OFF
. Массив stageValue
тоже инициализируется с предустановленными значениями: сотая секунды атаки, полсекунды спада, тихий уровень и секунда на полное затухание.
В секции private currentStage
обозначает, в какой стадии генератор сейчас находится. сurrentLevel
это значение громкости огибающей в данный семпл времени. multiplyer
обеспечивает экспоненциальное затухание, как будет видно дальше.
На стадиях ATTACK, DECAY и RELEASE генератор должен отслеживать свое положение во времени, чтобы в нужный момент перейти в следующую стадию. Для этого будем использовать переменную currentSampleIndex
. В EnvelopeGenerator.cpp добавьте вот такую функцию:
double EnvelopeGenerator::nextSample() {
if (currentStage != ENVELOPE_STAGE_OFF &&
currentStage != ENVELOPE_STAGE_SUSTAIN) {
if (currentSampleIndex == nextStageSampleIndex) {
EnvelopeStage newStage = static_cast<EnvelopeStage>(
(currentStage + 1) % kNumEnvelopeStages
);
enterStage(newStage);
}
currentLevel *= multiplier;
currentSampleIndex++;
}
return currentLevel;
}
Если генератор на стадиях ATTACK, DECAY или RELEASE и currentSampleIndex
достигает значения nextStageSampleIndex
, мы переходим к следующему элементу в enum EnvelopeStage
. Благодаря делению с остатком (%
) после ENVELOPE_STAGE_RELEASE
генератор сразу перейдет в ENVELOPE_STAGE_OFF
, что нам и нужно. Этот переход осуществляется вызовом enterStage
.
Затем мы вычисляем новое значение уровня currentLevel
и обновляем currentSampleIndex
, чтобы следить за положением генератора во времени. Эти вычисления не выполняются на стадиях OFF и SUSTAIN, т. к. на них уровень не должен меняться. То же самое и с currentSampleIndex
: эти две стадии не ограничены по времени.
В ATTACK, DECAY и RELEASE генератор переходит между двумя значениями за заданное время. Человеческое ухо воспринимает громкость [12] в логарифмическом масштабе. Следовательно, чтобы изменение громкости воспринималось на слух линейным, оно должно происходить экспоненциально.
Есть разные способы построить экспоненциальную кривую между двумя точками. Первое, что приходит на ум — каждый семпл вызывать относительно тяжелую функцию exp
из библиотеки <cmath>
. Однако, можно поступить умнее: имея два значения уровня и значение времени, можно вычислить некоторый множитель, на который мы будем умножать текущее значение огибающей.
Вот как выглядит такая функция (она основана на быстром алгоритме экспоненциальной огибающей [13] Кристиана Шонебека):
void EnvelopeGenerator::calculateMultiplier(double startLevel,
double endLevel,
unsigned long long lengthInSamples) {
multiplier = 1.0 + (log(endLevel) - log(startLevel)) / (lengthInSamples);
}
На данный момент не обязательно вникать в детали, если что. Главное понимать, что функция принимает уровни сигнала startLevel
и endLevel
и время перехода lengthInSamples
для вычисления значения multiplier
, которое будет немного больше или меньше единицы. Это значение мы перемножаем с currentLevel
. Функция log()
вычисляет натуральный логарифм.
Пора приступить к enterStage
:
void EnvelopeGenerator::enterStage(EnvelopeStage newStage) {
currentStage = newStage;
currentSampleIndex = 0;
if (currentStage == ENVELOPE_STAGE_OFF ||
currentStage == ENVELOPE_STAGE_SUSTAIN) {
nextStageSampleIndex = 0;
} else {
nextStageSampleIndex = stageValue[currentStage] * sampleRate;
}
switch (newStage) {
case ENVELOPE_STAGE_OFF:
currentLevel = 0.0;
multiplier = 1.0;
break;
case ENVELOPE_STAGE_ATTACK:
currentLevel = minimumLevel;
calculateMultiplier(currentLevel,
1.0,
nextStageSampleIndex);
break;
case ENVELOPE_STAGE_DECAY:
currentLevel = 1.0;
calculateMultiplier(currentLevel,
fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel),
nextStageSampleIndex);
break;
case ENVELOPE_STAGE_SUSTAIN:
currentLevel = stageValue[ENVELOPE_STAGE_SUSTAIN];
multiplier = 1.0;
break;
case ENVELOPE_STAGE_RELEASE:
// We could go from ATTACK/DECAY to RELEASE,
// so we're not changing currentLevel here.
calculateMultiplier(currentLevel,
minimumLevel,
nextStageSampleIndex);
break;
default:
break;
}
}
После обновления значения currentStage
мы обнуляем счетчик времени currentSampleIndex
. Затем мы вычисляем продолжительность новой стадии в семплах. Как мы знаем, вычисления параметров нужны только на стадиях атаки, спада и затухания. stageValue[currentStage]
имеет тип double
(длительность стадии в секундах), соответственно, это значение надо умножить на частоту семплирования, чтобы получить длительность в семплах. Switch
разветвляет алгоритм на несколько вариантов. На OFF мы устанавливаем уровень равным нулю и множитель единице (последнее не обязательно, но так выглядит логичнее). ATTACK мы начинаем с minimumLevel
, вычисляем multiplier
для наращивания currentLevel
до 1.0
. На стадии DECAY уровень падает с текущего значения до stageValue[ENVELOPE_STAGE_SUSTAIN]
, но fmax
гарантирует, что уровень не упадет до нуля. RELEASE опускает currentLevel
, каким бы он ни был, до minimumLevel
. Как написано в комменте, на входе в RELEASE мы не меняем currentLevel
, т. к. в эту стадию генератор может перейти не только из SUSTAIN, но и из ATTACK или DECAY. Как мы уже поняли, SUSTAIN отличается от остальных стадий тем, что stageValue[ENVELOPE_STAGE_SUSTAIN]
содержит не длительность стадии, а уровень сигнала. Так что мы просто задаем этот уровень равным currentLevel
.
Давайте напишем простую функцию для вычисления частоты семплирования:
void EnvelopeGenerator::setSampleRate(double newSampleRate) {
sampleRate = newSampleRate;
}
В секцию private
класса Synthesis
(в файле заголовка) добавим новый член:
EnvelopeGenerator mEnvelopeGenerator;
Не забудьте #include "EnvelopeGenerator.h"
перед объявлением класса.
Теперь, в функции ProcessDoubleReplacing
Synthesis.cpp замените вычисление leftOutput[i]
следующим кодом:
// leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * velocity / 127.0;
if (mEnvelopeGenerator.getCurrentStage() == EnvelopeGenerator::ENVELOPE_STAGE_OFF) {
mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
}
if (mEnvelopeGenerator.getCurrentStage() == EnvelopeGenerator::ENVELOPE_STAGE_SUSTAIN) {
mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
}
leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * mEnvelopeGenerator.nextSample() * velocity / 127.0;
Это исключительно для тестирования. Два условных оператора запустят неограниченный по времени луп: Из OFF в ATTACK, из SUSTAIN в RELEASE. Каждый семпл осциллятора умножается на соответственное значение огибающей.
При установке частоты семплирования mEnvelopeGenerator
должен быть в курсе происходящего. Добавьте в Synthesis::Reset()
:
mEnvelopeGenerator.setSampleRate(GetSampleRate());
И вновь мы готовы протестировать наш синтюк. Запускайте плагин и зажмите что-нибудь на виртуальной клавиатуре — генератор будет бегать по кругу через все стадии! Отпад.
Зацикленные стадии это интересно, может быть это даже пригодится позже (например, при создании степпера — более сложной огибающей, состоящей из одинаковых частей), но пока что мы хотим придерживаться классического подхода: нажимаем клавишу — генератор входит в ATTACK, отпускаем — в RELEASE. mMIDIReceiver
умеет обращаться с сообщениями note on/off, а значит, надо как-то подключить его к генератору.
Самым простым способом сделать это было бы добавить #include EnvelopeGenerator.h
в MIDIReceiver.h. Затем, из Synthesis.h можно было бы передать ссылку на объект класса генератор mEnvelopeGenerator
объекту mMIDIReceiver
, чтобы он мог достучаться до генератора. Потом MIDIReceiver
вызывал бы enterStage
при получении note on/off.
Идея плохая, т. к. MIDIReceiver
становится зависимым от EnvelopeGenerator
. Мы же стремимся к разделению элементов дизайна, ведь возможно мы захотим использовать MIDIReceiver
в ином контексте, где будет отсутствовать EnvelopeGenerator
.
Лучше использовать сигналы [14] и слоты [15]. Этот подход унаследован из фреймворка Qt. Его можно использовать чтобы, например, подключить друг к другу кнопку и текстовое поле без того, чтобы они знали друг о друге. Когда на кнопку жмут, она посылает сигнал. Этот сигнал может приниматься слотом текстового поля, например setText()
, чтобы при нажатии на кнопку менялся текст поля. Ключевой момент в том, что кнопке не важно, подключены ли к ней текстовые поля, и сколько их. Все, кто подключен, получают сигнал. Мы можем использовать этот подход для компонентов нашего плагина (Oscillator
, EnvelopeGenerator
, MIDIReceiver
). Соединения будут осуществляться вне компонентов, т. е. в классе Synthesis
.
Но мы не будем использовать целый Qt для одной этой функциональности. Вместо этого воспользуемся библиотекой Signals [16] Патрика Хогана. Скачайте и разархивируйте. Переименуйте Signal.h в GallantSignal.h, чтобы избежать конфликта имен. Перетягивайте Delegate.h и GallantSignal.h в проект (только так, чтобы они скопировались, используя “Copy items into destination group's folder”) и добавьте их во все таргеты в Xcode. В VisualStudio нужно скопировать эти два файла в папку проекта, и из нее перетянуть во все проекты в Solution Explorer.
Мы хотим, чтобы MIDIReceiver
посылал сигнал, когда жмут на клавишу и когда ее отпускают. Перед определением класса в MIDIReceiver.h добавьте следующее:
#include "GallantSignal.h"
using Gallant::Signal2;
Signal2
это сигнал, передающий два параметра. Есть несколько — от Signal0
до Signal8
, так что можно выбрать подходящий в зависимости от задачи.
В секцию public
допишите:
Signal2< int, int > noteOn;
Signal2< int, int > noteOff;
Как видите, оба сигнала будут передавать две переменной типа int
. Теперь в MIDIReceiver.cpp надо отредактировать функцию advance
:
// A key pressed later overrides any previously pressed key:
if (noteNumber != mLastNoteNumber) {
mLastNoteNumber = noteNumber;
mLastFrequency = noteNumberToFrequency(mLastNoteNumber);
mLastVelocity = velocity;
// Emit a "note on" signal:
noteOn(noteNumber, velocity);
}
// If the last note was released, nothing should play:
if (noteNumber == mLastNoteNumber) {
mLastNoteNumber = -1;
noteOff(noteNumber, mLastVelocity);
}
Первый аргумент — номер ноты, второй — громкость. Теперь мы не устанавливаем mLastFrequency
равным -1
, когда отпускатся клавиша: значение частоты пригодится на стадии RELEASE. То же с mLastVelocity
: если ее обнулить, громкость упадет мнговенно.
Обратите внимание, что код гладко работает, не смотря на то, что мы еще не соединяли слоты и сигналы. Это наглядная демонстрация практичности и элегантности такого подхода. Компоненты независимы друг от друга.
Теперь надо подключить mEnvelopeGenerator
к двум сигналам. Можно было бы добавить функции члены onNoteOn
и onNoteOff
в класс EnvelopeGenerator
и подключить именно их. Это вариант, но тогда мы сваливаем в кучу EnvelopeGenerator
и концепт ноты. На мой взгляд, генератору огибающих не нужно знать о нотах. Также мы не можем подключить сигналы к enterStage
из-за нестыковки в аргументах. Давайте лучше добавим функции члены в private
секцию класса Synthesis
:
inline void onNoteOn(const int noteNumber, const int velocity)
{ mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK); };
inline void onNoteOff(const int noteNumber, const int velocity)
{ mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE); };
Передаваемые аргументы соответствуют именам функций.
Чтобы подключить сигналы, допишите в конструктор Synthesis.cpp пару строк:
mMIDIReceiver.noteOn.Connect(this, &Synthesis::onNoteOn);
mMIDIReceiver.noteOff.Connect(this, &Synthesis::onNoteOff);
Первый аргумент — это указатель на объект класса, второй — на функцию член.
Теперь можно удалить зацикленность генератора в ProcessDoubleReplacing
. Удалите два условных оператора if
, которые мы добавили чуть раньше, но оставьте строку с генерацией семплов.
Запускайте. Теперь каждый раз при нажатии новой клавиши огибающая перезапускается и удерживает громкость сигнала пока клавиша держится нажатой. Если отпустить клавишу на стадии DECAY, то генератор должен перейти сразу в RELEASE, плавно заглушая сигнал с текущего значения до нуля. Поиграйтесь с разными stageValues
и послушайте звук.
Было бы удобно, если эти параметры можно было менять ручками интерфейса, прямо в процессе игры. Для этого нам нужно допилить GUI до чего-то подобного:
Этим и займемся в следующий раз!
Код проекта на данном этапе можно скачать тут [17].
Автор: 1eqinfinity
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/63234
Ссылки в тексте:
[1] Часть 1. Введение и настройка: http://habrahabr.ru/post/224911/
[2] Часть 2. Изучение кода: http://habrahabr.ru/post/225019/
[3] Часть 3. VST и AU: http://habrahabr.ru/post/225457/
[4] Часть 4. Цифровой дисторшн: http://habrahabr.ru/post/225751/
[5] Часть 5. Пресеты и GUI: http://habrahabr.ru/post/225755/
[6] Часть 6. Синтез сигналов: http://habrahabr.ru/post/226439/
[7] Часть 7. Получение MIDI сообщений: http://habrahabr.ru/post/226573/
[8] Часть 8. Виртуальная клавиатура: http://habrahabr.ru/post/226823/
[9] Часть 9. Огибающие: http://habrahabr.ru/post/227475/
[10] эту статейку: http://ru.wikipedia.org/wiki/ADSR-%D0%BE%D0%B3%D0%B8%D0%B1%D0%B0%D1%8E%D1%89%D0%B0%D1%8F
[11] конечный автомат: http://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%BD%D0%B5%D1%87%D0%BD%D1%8B%D0%B9_%D0%B0%D0%B2%D1%82%D0%BE%D0%BC%D0%B0%D1%82
[12] воспринимает громкость: http://en.wikibooks.org/wiki/Engineering_Acoustics/The_Human_Ear_and_Sound_Perception
[13] быстром алгоритме экспоненциальной огибающей: http://www.musicdsp.org/showone.php?id=189
[14] сигналы: http://qt-project.org/doc/qt-5/signalsandslots.html
[15] слоты: http://ru.wikipedia.org/wiki/%D0%A1%D0%B8%D0%B3%D0%BD%D0%B0%D0%BB%D1%8B_%D0%B8_%D1%81%D0%BB%D0%BE%D1%82%D1%8B
[16] Signals: https://github.com/pbhogan/Signals
[17] тут: http://martin-finke.de/blog/articles/audio-plugins-011-envelopes/source.zip
Нажмите здесь для печати.