Музыкальный D: синтезатор в 100 строк
Моя статья 2018 года, изначально написанная для блога LightHouse Software. Приведенный код актуален и сегодня, статья служит неплохим вводным руководством к dlib.audio.
Библиотека dlib предоставляет базовые инструменты для работы с аудиоданными, которые позволяют написать синтезатор с сохранением полученных звуков в WAV. В этой статье я покажу, как с их помощью сгенерировать знаменитую мелодию «Popcorn» Гершона Кингсли, используя для этого всего три функции, умещающиеся в 100 строк кода.
В блоге LHS уже публиковалась статья на эту тему, однако в ней не рассмотрен собственно синтез звука, воспроизведение нот осуществляется через системный динамик. Я же решил сгенерировать собственный сигнал с частотной модуляцией и ADSR-огибающей, которые делают его похожим на звучание музыкального инструмента.
Цифровой звук, как известно, представляет собой ряд амплитуд звуковой волны – или, вернее, амплитуд напряжения переменного тока, из которого получается звук в динамиках – закодированных через равные промежутки времени (импульсно-кодовая модуляция, PCM). При помощи dlib.audio вы можете создать в памяти звук (объект Sound) и модифицировать его амплитуды, а затем сохранить в файл WAV (или воспроизвести каким-нибудь звуковым API, но эта задача выходит за рамки возможностей dlib). При этом используются три важнейших параметра:
- Частота дискретизации (Sound.sampleRate) – количество закодированных амплитуд (сэмплов) в секунду. Чем выше это значение, тем больший диапазон звуковых частот можно закодировать. Обычно используется частота 44100 Гц.
- Битовая глубина (Sound.bitDepth) – количество бит на сэмпл. Чем выше это значение, тем точнее звуковые данные передают градации громкости. dlib поддерживает глубины 8 и 16 бит. 8-битная глубина достаточна для представления некоторых простых синтезированных звуков, но для голоса и музыки обычно используются 16 бит. Обе глубины могут быть со знаком и беззнаковыми – для лучшей совместимости с другими библиотеками. Принципиальной разницы между этими форматами нет, но сэмпл со знаком будет обрабатываться быстрее за счет отсутствия нормализации.
- Количество каналов (Sound.channels) – dlib может работать с любым количеством каналов, однако в WAV можно сохранить только одноканальные и двухканальные звуки. Многоканальные сэмплы хранятся в памяти последовательно, подобно пикселям изображения – таким образом, любой звук представляет собой всего один массив данных.
Чтобы можно было абстрагировать алгоритмы синтеза и обработки звука от битовой глубины, в dlib.audio применяется обработка сэмплов в числах с плавающей запятой. Иными словами, сэмплы считываются и записываются не в 8- и 16-битных int’ах, а во float’ах. Помимо удобства, это повышает точность вычислений благодаря отсутствию потерь информации в промежуточных данных – в целочисленное представление кодируется только результат вычисления. Диапазон информации во float-сэмплах составляет от -1.0 до 1.0. При конвертировании в беззнаковое целочисленное представление производится нормализация этого значения, поэтому лучше хранить сэмплы в формате со знаком.
API dlib.audio очень близок по духу к dlib.image. Чтение и запись сэмплов осуществляется при помощи оператора квадратных скобок с двумя индексами – первый индекс обозначает номер канала (начиная с 0), второй – индекс сэмпла. Это абстрактный параметр, зависящий от частоты дискретизации – можно его назвать X-координатой сэмпла. Чтобы получить индекс из значения времени, нужно умножить время в секундах на частоту дискретизации, а затем округлить до ближайшего целого. Но обычно эффективнее работать не во времени, а сразу в шкале частот, предварительно вычислив интервал дискретизации (1.0 / sound.sampleRate) – таким образом, для произвольного индекса сэмпла можно легко получить соответствующее ему время, умножив на интервал. Имея время, вы можете вычислить амплитуду при помощи функции-осциллятора. Самыми популярными осцилляторами являются синусоида (sine wave), прямоугольная волна (square wave) и пилообразная волна (sawtooth wave).
Функцию, заполняющую объект Sound синусоидальным сигналом, вы можете найти в модуле dlib.audio.synth. Выглядит она следующим образом:
void sineWave(Sound sound, uint channel, float freq)
{
float samplePeriod = 1.0f / cast(float)sound.sampleRate;
foreach(i; 0..sound.size)
{
sound[channel, i] = sin(freq * i * samplePeriod * 2.0f * PI);
}
}
Пользователь может контролировать частоту полученного сигнала. Чтобы сгенерировать амплитуду, нужно получить число колебаний (частота, помноженная на время – freq * i * samplePeriod) и перевести его в фазу (одно колебание – приращение фазы – соответствует 2π радиан).
Пример использования:
auto snd = new GenericSound(1.0f, 44100, 1, SampleFormat.S16);
sineWave(snd, 0, 425);
saveWAV(snd, "output.wav");
Этот код создаст одноканальный звук длительностью в 1 секунду с частотой дискретизации 44100 Гц и 16 битами со знаком на сэмпл, заполнив его синусоидой частоты 425 Гц.
График:
Сама по себе синусоида звучит довольно скучно и «немузыкально». Обычно в синтезаторах ее пропускают через серию фильтров, которых существует очень много – для этой статьи я выбрал частотную модуляцию, которая заключается в изменении частоты одного сигнала (называемого несущим) другим сигналом (модулирующим):
void fmSynth(Sound snd, uint ch, float carrierFreq, float modulatorFreq, float startTime, float duration, float volume)
{
float samplePeriod = 1.0f / cast(float)snd.sampleRate;
uint startSample = cast(uint)(startTime * snd.sampleRate);
uint numSamples = cast(uint)(duration * snd.sampleRate);
foreach(i; startSample..(startSample + numSamples))
{
float time = samplePeriod * (i - startSample);
float modulator = sin(modulatorFreq * time * 2.0f * PI);
float carrier = sin((carrierFreq + modulator) * time * 2.0f * PI);
float src = snd[ch, i];
snd[ch, i] = src + carrier * volume;
}
}
Функция fmSynth добавляет в заданный объект Sound синусоидальный сигнал частоты carrierFreq, модулированной синусоидой частоты modulatorFreq. Время начала и длительность сигнала задаются параметрами startTime и duration, пиковая громкость – параметром volume.
Наиболее гармоничные сочетания звуков получаются, если отношение модулирующей частоты к несущей равно целому числу:
fmSynth(snd, 0, 50, 50 * 10, 0.0, 1.0, 0.5);
График:
Увеличенный фрагмент:
Звук получился отдаленно похожим на духовой инструмент, но есть серьезный недостаток – он обрывается резко, чего в реальности обычно не бывает. Для плавного изменения громкости в синтезаторах используют ADSR-огибающую:
float adsr(float attackTime, float decayTime, float sustainTime, float sustain, float releaseTime, float t)
{
if (t < attackTime)
return lerp(0.0f, 1.0f, t / attackTime);
else if (t < decayTime)
return lerp(1.0f, sustain, (t - attackTime) / (decayTime - attackTime));
else if (t < sustainTime)
return sustain;
else if (t < releaseTime)
return lerp(sustain, 0.0f, (t - sustainTime) / (releaseTime - sustainTime));
else
return 0.0f;
}
Смысл этой функции понятен из кода – она позволяет управлять изменением громкости в четыре стадии: атака (attack), спад (decay), поддержка (sustain), затухание (release). Атака – время, за которое громкость повышается от нуля до пикового значения. Спад – время, за которое громкость падает от пикового до значения поддержки. Затухание – время, за которое громкость падает от значения поддержки до нуля. Все значения времени задаются в абстрактных единицах в диапазоне 0..1 (по сути, в «пространстве длительности» ноты).
С использованием ADSR наша функция fmSynth будет выглядеть следующим образом:
void fmSynth(Sound snd, uint ch, float carrierFreq, float modulatorFreq, float startTime, float duration, float volume)
{
float samplePeriod = 1.0f / cast(float)snd.sampleRate;
uint startSample = cast(uint)(startTime * snd.sampleRate);
uint numSamples = cast(uint)(duration * snd.sampleRate);
float envelopePeriod = 1.0f / cast(float)numSamples;
float at = 0.15f;
float dt = 0.25f;
float st = 0.5f;
float rt = 0.75f;
float s = 0.66f;
foreach(i; startSample..(startSample + numSamples))
{
float time = samplePeriod * (i - startSample);
float envelopeTime = envelopePeriod * (i - startSample);
float envelope = adsr(at, dt, st, s, rt, envelopeTime);
float modulator = sin(modulatorFreq * time * 2.0f * PI);
float carrier = sin((carrierFreq + modulator) * time * 2.0f * PI);
float src = snd[ch, i];
snd[ch, i] = src + carrier * envelope * volume;
}
}
Параметры ADSR вшиты в функцию, но в реальном синтезаторе их лучше сделать настраиваемыми.
График:
Теперь мы можем воспроизводить звук любой частоты в любом месте дорожки – осталось реализовать способ записи нот и их интерпретацию, и получится простейшая музыкальная машина, или секвенсор. Я решил использовать последовательности обозначений вида C/4/3/2, где буква означает ноту, первое число – номер октавы, второе – позицию ноты, третье – длительность ноты. Позиция и длительность задаются в 1/16 ноты – то есть, в примере выше позиция 3 соответствует времени, равному трем шестнадцатым нотам от начала дорожки, а длительность 2 – восьмой ноте.
Функция, считывающая ноты из строки и записывающая их в объект звука, выглядит следующим образом:
void recordScore(Sound snd, string score, float bpm, float volume)
{
const float[9][string] noteTable = [
"C": [16, 33, 65, 131, 262, 523, 1047, 2093, 4186],
"C#": [17, 35, 69, 139, 278, 554, 1109, 2218, 4435],
"D": [18, 37, 73, 147, 294, 587, 1175, 2349, 4699],
"D#": [20, 39, 78, 156, 311, 622, 1245, 2489, 4978],
"E": [21, 41, 82, 165, 330, 659, 1319, 2637, 5274],
"F": [22, 44, 87, 175, 349, 699, 1397, 2794, 5588],
"F#": [23, 46, 93, 185, 370, 740, 1475, 2960, 5920],
"G": [25, 49, 98, 196, 392, 784, 1568, 3136, 6272],
"G#": [26, 52, 104, 208, 415, 831, 1661, 3322, 6645],
"A": [28, 55, 110, 220, 440, 880, 1760, 3520, 7040],
"A#": [29, 58, 117, 233, 466, 932, 1865, 3729, 7459],
"B": [31, 62, 124, 247, 494, 988, 1976, 3951, 7902]
];
float quarterNote = 60.0f / bpm;
float sixteenthNote = quarterNote / 4.0f;
foreach(t, note; score.split)
{
string pitch;
uint octave, position, duration;
formattedRead(note, "%s/%s/%s/%s",
&pitch, &octave, &position, &duration);
float freq = noteTable[pitch][octave];
fmSynth(snd, 0, freq, freq * 10,
sixteenthNote * position,
sixteenthNote * duration, volume);
}
}
Параметр bpm определяет скорость композиции (количество бит, или четвертных нот, в минуту). Для «Popcorn» (см. ниже) я использовал скорость 120. Параметр volume определяет громкость композиции.
Стандартные частоты, соответствующие нотам (таблица noteTable), я взял отсюда: https://peabody.sapp.org/class/st2/lab/notehz/.
Теперь записываем ноты мелодии и воспроизводим их:
auto snd = new GenericSound(8.0f, 44100, 1, SampleFormat.S16);
string popcorn =
"C/4/0/1 A#/3/2/1 C/4/4/1 G/3/6/1 D#/3/8/1 G/3/10/1 C/3/12/1
C/4/16/1 A#/3/18/1 C/4/20/1 G/3/22/1 D#/3/24/1 G/3/26/1 C/3/28/1
C/4/32/1 D/4/34/1 D#/4/36/1 D/4/38/1 D#/4/40/1 C/4/42/1 D/4/44/1
C/4/46/1 D/4/48/1 A#/3/50/1 C/4/52/1 A#/3/54/1 C/4/56/1 G#/3/58/1 C/4/60/1";
recordScore(snd, popcorn, 120, 0.8);
saveWAV(snd, "popcorn.wav");
Кстати, поскольку позиции задаются явно, можно записать несколько нот на одной позиции и получить аккорд.
Полный исходник программы:
import std.math;
import std.string;
import std.format;
import dlib.audio;
import dlib.math;
float adsr(float attackTime, float decayTime, float sustainTime, float sustain, float releaseTime, float t)
{
if (t < attackTime)
return lerp(0.0f, 1.0f, t / attackTime);
else if (t < decayTime)
return lerp(1.0f, sustain, (t - attackTime) / (decayTime - attackTime));
else if (t < sustainTime)
return sustain;
else if (t < releaseTime)
return lerp(sustain, 0.0f, (t - sustainTime) / (releaseTime - sustainTime));
else
return 0.0f;
}
void fmSynth(Sound snd, uint ch, float carrierFreq, float modulatorFreq, float startTime, float duration, float volume)
{
float samplePeriod = 1.0f / cast(float)snd.sampleRate;
uint startSample = cast(uint)(startTime * snd.sampleRate);
uint numSamples = cast(uint)(duration * snd.sampleRate);
float envelopePeriod = 1.0f / cast(float)numSamples;
float at = 0.15f;
float dt = 0.25f;
float st = 0.5f;
float rt = 0.75f;
float s = 0.66f;
foreach(i; startSample..(startSample + numSamples))
{
float time = samplePeriod * (i - startSample);
float envelopeTime = envelopePeriod * (i - startSample);
float envelope = adsr(at, dt, st, s, rt, envelopeTime);
float modulator = sin(modulatorFreq * time * 2.0f * PI);
float carrier = sin((carrierFreq + modulator) * time * 2.0f * PI);
float src = snd[ch, i];
snd[ch, i] = src + carrier * envelope * volume;
}
}
void recordScore(Sound snd, string score, float bpm, float volume)
{
const float[9][string] noteTable = [
"C": [16, 33, 65, 131, 262, 523, 1047, 2093, 4186],
"C#": [17, 35, 69, 139, 278, 554, 1109, 2218, 4435],
"D": [18, 37, 73, 147, 294, 587, 1175, 2349, 4699],
"D#": [20, 39, 78, 156, 311, 622, 1245, 2489, 4978],
"E": [21, 41, 82, 165, 330, 659, 1319, 2637, 5274],
"F": [22, 44, 87, 175, 349, 699, 1397, 2794, 5588],
"F#": [23, 46, 93, 185, 370, 740, 1475, 2960, 5920],
"G": [25, 49, 98, 196, 392, 784, 1568, 3136, 6272],
"G#": [26, 52, 104, 208, 415, 831, 1661, 3322, 6645],
"A": [28, 55, 110, 220, 440, 880, 1760, 3520, 7040],
"A#": [29, 58, 117, 233, 466, 932, 1865, 3729, 7459],
"B": [31, 62, 124, 247, 494, 988, 1976, 3951, 7902]
];
float quarterNote = 60.0f / bpm;
float sixteenthNote = quarterNote / 4.0f;
foreach(t, note; score.split)
{
string pitch;
uint octave, position, duration;
formattedRead(note, "%s/%s/%s/%s", &pitch, &octave, &position, &duration);
float freq = noteTable[pitch][octave];
fmSynth(snd, 0, freq, freq * 10, sixteenthNote * position, sixteenthNote * duration, volume);
}
}
void main(string[] args)
{
auto snd = new GenericSound(8.0f, 44100, 1, SampleFormat.S16);
string popcorn =
"C/4/0/1 A#/3/2/1 C/4/4/1 G/3/6/1 D#/3/8/1 G/3/10/1 C/3/12/1
C/4/16/1 A#/3/18/1 C/4/20/1 G/3/22/1 D#/3/24/1 G/3/26/1 C/3/28/1
C/4/32/1 D/4/34/1 D#/4/36/1 D/4/38/1 D#/4/40/1 C/4/42/1 D/4/44/1
C/4/46/1 D/4/48/1 A#/3/50/1 C/4/52/1 A#/3/54/1 C/4/56/1 G#/3/58/1 C/4/60/1";
recordScore(snd, popcorn, 120, 0.5);
saveWAV(snd, "popcorn.wav");
}