Библиотека 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), я взял отсюда: http://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");
Кстати, поскольку позиции задаются явно, можно записать несколько нот на одной позиции и получить аккорд.
Полный исходник программы:
module audiotest; 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"); }