И так господа, решил наконец разобраться с программным синтезом музыки, а именно с практической частью реализации подобной задачи. Давайте посмотрим что из это вышло и как оно реализовано…
Все мы прекрасно понимаем что звук это волна и что частота колебаний волны в от нулевого уровня соответствует частоте звуковой волны, а амплитуда этой волны отвечает за его силу или попросту говоря громкость, но в машинном представлении звук записанный в виде импульсно-кодовой модуляции это массив данных, каждый элемент которого представляет позицию волны в конкретный момент времени.
Давайте рассмотрим простую звуковую волну в формате PCM лучше, для этого сначала напишем функцию которая будет моделью синусоидальной волны. Принимать она будет два значения — смещение и частоту.
А теперь добавим ее в класс Program и напишем главную функцию Main которая будет инициализировать массив данных длиной в 75 элементов который будет представлять наш звук и циклично заполним каждую его ячейку используя для этого только что написанную нами модель синусоиды. Чтобы рассчитать значение функции для конкретного смещения нам надо учесть период синусоиды равный 2 * Пи и умножить этот период на требующуюся нам частоту волны. Но для того чтобы понять какой же результирующей частоты выйдет звук в формате PCM нужно знать его частоту дискретизации. Частота дискретизации — частота выборки элементов за единицу времени, ну а если совсем упрощенно то это количество элементов массива на секунду, это значит что частота звука в формате PCM это частота волны разделенная на частоту его дискретизации. Давайте сгенерируем звуковую волну частотой 2 Гц при этом условимся что частота дискретизации будет равна 75 Гц.
И теперь чтобы увидеть результат нашей работы добавим в класс Program новую функцию способную визуализировать нашу функцию прямо в консоле (Так выйдет быстрее всего) поэтому вдаваться в ее подробности не будем.
Теперь мы можем увидеть как же выглядит наши два герца в машинном представлении.
Но ведь это только один вид волны в то время как существует множество других видов волн, давайте опишем функции способные моделировать основные типы волн и рассмотрим как они выглядят.
Учитывайте что период функции Sine и Flat равен 2 * Пи, а период функций Saw и Triangle равен единице.
Когда мы смогли создавать и даже рассмотреть наш звук хотелось бы его еще и услышать для этого давайте запишем его в контейнере .wav и прослушаем. Правда пока мы не знаем как устроен контейнер Wave, надо исправить эту досадную ошибку! Итак wave файл очень простой он состоит из трех частей, первая это блок-заголовок, вторая это блок формата, третья это блок данных. Все вместе это выглядит так:
Собственно все предельно ясно, подробности по каждому из пунктов описаны здесь. Поскольку наша задача предельно простая мы будем использовать всегда разрядность равную 16 битам и только одну дорожку. Функция которую я сейчас напишу будет сохранять наш PCM звук в контейнере Wav внутрь потока, что позволит сделать работу более гибкой. Итак вот как она выглядит.
Видите как все просто, а главное теперь мы можем услышать наш звук, давайте сгенерируем 1 секунду ноты ля первой октавы, его частота 440 Гц, при такой задаче функция Main будет иметь такой вид
Запускаем программу и о чудо! У нас появился test.wav загрузив его в плеере слушаем пищание до достижения катарсиса и двигаемся дальше. Давайте рассмотрим нашу волну со всех сторон на осцилографе и спектрограмме чтобы убедиться что мы получили именно тот результат которого добивались.
Но в жизни звуки звучат не бесконечно, а стихают давайте напишем модификатор который будет глушить наш звук со временем. Ему потребуются абсолютные величины поэтому передадим ему коэффициент, текущую позицию, частоту, множитель коэффициента и частоту дискретизации, а абсолютные величины он вычислит сам, коэффициент всегда должен быть отрицательным.
Строку которая вычисляет уровень звука тоже нужно изменить.
Теперь на осцилографе мы увидим совсем другую картину.
Раз уж нам удалось сыграть ноту la четвертой октавы нам никто не мешает играть разные ноты. А вам никогда не было интересно как узнать частоты нот? Оказываться есть прекрасная формула 440 * 2 ^ (абсолютный индекс ноты / 12). Если вы взгляните на любой пиано-подобный инструмент то вспомните что на нем есть блоки по 7 белых клавиш и 5 черных, блоки это октавы, белые клавиши это основные ноты (до, ре, ми, фа, соль, ля, си) а черные их полутона, то есть всего 12 звуков в октаве это называется равномерно темперированный строй.
Давайте рассмотрим график этой функции.
Но записывать ноты мы будем в научной нотации поэтому немного изменим формулу опустив ее на 4 октавы и запишем ее в родном для нас виде.
Теперь когда мы собрали базовый функционал и отладили его работу давайте продумаем архитектуру будущего синтезатора.
Синтезатор будет представлять из себя некий набор объектов elements которые будут синтезировать звук и накладывать его на пустой массив данных в нужном месте, этот массив и объекты elements будут содержаться в объекте track. Классы описывающие их будут содержаться в пространстве имен Synthesizer, давайте опишем класс Element и Track
Теперь мы пришли к последней функции которая будет читать строку с нотами и генерировать нашу мелодию
Для этого создадим Dictionary который будет ассоциировать названия нот с индексами, а также будет содержать управляющие ключи/индексы.
Сама функция будет разбивать строку на слова и дальше обрабатывать каждое слово по отдельности разделяя его на две части — левую и правую, правая часть всегда состоит из одного символа (цифры) которая записывается в переменную октава как число, а длина первой части это длина слова — 1 (то есть слово минус правая часть) и дальше служит ключом к нашему словарю который возвращает индекс ноты, после того как мы разобрали слово мы решим что делать, если индекс управляющий то мы выполним соответствующую индексу функцию, а если нет то это значит что мы имеем индекс ноты и мы добавим к нашему треку новый звук нужной нам длины и частоты нашей ноты.
С этого момента мелодию можно записать в виде
Бинарный файл
Исходный код
Создаем волны
Все мы прекрасно понимаем что звук это волна и что частота колебаний волны в от нулевого уровня соответствует частоте звуковой волны, а амплитуда этой волны отвечает за его силу или попросту говоря громкость, но в машинном представлении звук записанный в виде импульсно-кодовой модуляции это массив данных, каждый элемент которого представляет позицию волны в конкретный момент времени.
Давайте рассмотрим простую звуковую волну в формате PCM лучше, для этого сначала напишем функцию которая будет моделью синусоидальной волны. Принимать она будет два значения — смещение и частоту.
public static double Sine(int index, double frequency) {
return Math.Sin(frequency * index);
}
А теперь добавим ее в класс Program и напишем главную функцию Main которая будет инициализировать массив данных длиной в 75 элементов который будет представлять наш звук и циклично заполним каждую его ячейку используя для этого только что написанную нами модель синусоиды. Чтобы рассчитать значение функции для конкретного смещения нам надо учесть период синусоиды равный 2 * Пи и умножить этот период на требующуюся нам частоту волны. Но для того чтобы понять какой же результирующей частоты выйдет звук в формате PCM нужно знать его частоту дискретизации. Частота дискретизации — частота выборки элементов за единицу времени, ну а если совсем упрощенно то это количество элементов массива на секунду, это значит что частота звука в формате PCM это частота волны разделенная на частоту его дискретизации. Давайте сгенерируем звуковую волну частотой 2 Гц при этом условимся что частота дискретизации будет равна 75 Гц.
class Program {
public static void Main(string[] args) {
double[] data = new double[75]; // Инициализируем массив.
for (int index = 1; index < 76; index++) { // Вычисляем данные для всего массива.
data[index-1] = Sine(index, Math.PI * 2 * 2.0 / 75); // Период разделенный на частоту дискретизации.
}
Console.ReadKey(true); // Ждем нажатия любой клавиши.
}
public static double Sine(int index, double frequency) {
return Math.Sin(frequency * index);
}
}
И теперь чтобы увидеть результат нашей работы добавим в класс Program новую функцию способную визуализировать нашу функцию прямо в консоле (Так выйдет быстрее всего) поэтому вдаваться в ее подробности не будем.
public static void Draw (double[] data) {
Console.BufferHeight = 25; // Изменяем длину буфера консоли чтобы избавиться от ползунка.
Console.CursorVisible = false; // отключаем курсор для красоты.
for (int y = 0; y < 19; y++) {// Выписываем индексы уровня звука.
Console.SetCursorPosition(77, y + 5);// Устанавливаем курсор в нужную позицию.
Console.Write(9 - y); // Выписываем номер индекса уровня.
}
for (int x = 0; x < 75; x++) { // Перебираем все элементы массива
Console.SetCursorPosition(x, x % 3); //Устанавливаем курсор в нужную точку.
Console.Write(x + 1); // пишем индексы элемента.
int point = (int)(data[x] * 9); // Вычисляем уровень и приводим его к амплитуде от -9 до 9.
int step = (point > 0)? -1 : 1; // Узнаем в какую сторону 0.
for (int y = point; y != step; y += step) {// перебираем столбик
Console.SetCursorPosition(x, point + 14 - y); //Устанавливаем курсор в нужную позицию.
Console.Write("█"); // Рисуем точку.
}
}
}
Теперь мы можем увидеть как же выглядит наши два герца в машинном представлении.
Но ведь это только один вид волны в то время как существует множество других видов волн, давайте опишем функции способные моделировать основные типы волн и рассмотрим как они выглядят.
private static double Saw(int index, double frequency) {
return 2.0 * (index * frequency - Math.Floor(index * frequency )) -1.0;
}
Результат работы функции Saw
private static double Triangle(int index, double frequency) {
return 2.0 * Math.Abs (2.0 * (index * frequency - Math.Floor(index * frequency + 0.5))) - 1.0;
}
Результат работы функции Triangle
private static double Flat(int index, double frequency) {
if (Math.Sin(frequency * index ) > 0) return 1;
else return -1;
}
Результат работы функции Flat
Учитывайте что период функции Sine и Flat равен 2 * Пи, а период функций Saw и Triangle равен единице.
Записываем Wav файл
Когда мы смогли создавать и даже рассмотреть наш звук хотелось бы его еще и услышать для этого давайте запишем его в контейнере .wav и прослушаем. Правда пока мы не знаем как устроен контейнер Wave, надо исправить эту досадную ошибку! Итак wave файл очень простой он состоит из трех частей, первая это блок-заголовок, вторая это блок формата, третья это блок данных. Все вместе это выглядит так:
Собственно все предельно ясно, подробности по каждому из пунктов описаны здесь. Поскольку наша задача предельно простая мы будем использовать всегда разрядность равную 16 битам и только одну дорожку. Функция которую я сейчас напишу будет сохранять наш PCM звук в контейнере Wav внутрь потока, что позволит сделать работу более гибкой. Итак вот как она выглядит.
public static void SaveWave(Stream stream, short[] data, int sampleRate) {
BinaryWriter writer = new BinaryWriter(stream);
short frameSize = (short)(16 / 8); // Количество байт в блоке (16 бит делим на 8).
writer.Write(0x46464952); // Заголовок "RIFF".
writer.Write(36 + data.Length * frameSize); // Размер файла от данной точки.
writer.Write(0x45564157); // Заголовок "WAVE".
writer.Write(0x20746D66); // Заголовок "frm ".
writer.Write(16); // Размер блока формата.
writer.Write((short)1); // Формат 1 значит PCM.
writer.Write((short)1); // Количество дорожек.
writer.Write(sampleRate); // Частота дискретизации.
writer.Write(sampleRate * frameSize); // Байтрейт (Как битрейт только в байтах).
writer.Write(frameSize); // Количество байт в блоке.
writer.Write((short)16); // разрядность.
writer.Write(0x61746164); // Заголовок "DATA".
writer.Write(data.Length * frameSize); // Размер данных в байтах.
for (int index = 0; index < data.Length; index++) { // Начинаем записывать данные из нашего массива.
foreach (byte element in BitConverter.GetBytes(data[index])) { // Разбиваем каждый элемент нашего массива на байты.
stream.WriteByte(element); // И записываем их в поток.
}
}
}
Видите как все просто, а главное теперь мы можем услышать наш звук, давайте сгенерируем 1 секунду ноты ля первой октавы, его частота 440 Гц, при такой задаче функция Main будет иметь такой вид
public static void Main(string[] args) {
int sampleRate = 8000; // наша частота дискретизации.
short[] data = new short[sampleRate]; // Инициализируем массив 16 битных значений.
double frequency = Math.PI * 2 * 440.0 / sampleRate; // Рассчитываем требующуюся частоту.
for (int index = 0; index < sampleRate; index++) { // Перебираем его.
data[index] = (short)(Sine(index, frequency) * short.MaxValue); // Приводим уровень к амплитуде от 32767 до -32767.
}
Stream file = File.Create("test.wav"); // Создаем новый файл и стыкуем его с потоком.
SaveWave(file, data, sampleRate); // Записываем наши данные в поток.
file.Close(); // Закрываем поток.
}
Запускаем программу и о чудо! У нас появился test.wav загрузив его в плеере слушаем пищание до достижения катарсиса и двигаемся дальше. Давайте рассмотрим нашу волну со всех сторон на осцилографе и спектрограмме чтобы убедиться что мы получили именно тот результат которого добивались.
Но в жизни звуки звучат не бесконечно, а стихают давайте напишем модификатор который будет глушить наш звук со временем. Ему потребуются абсолютные величины поэтому передадим ему коэффициент, текущую позицию, частоту, множитель коэффициента и частоту дискретизации, а абсолютные величины он вычислит сам, коэффициент всегда должен быть отрицательным.
public static double Length(double compressor, double frequency, double position, double length, int sampleRate){
return Math.Exp(((compressor / sampleRate) * frequency * sampleRate * (position / sampleRate)) / (length / sampleRate));
}
Строку которая вычисляет уровень звука тоже нужно изменить.
data[index] = (short)(Sine(index, frequency) * Length(-0.0015, frequency, index, 1.0, sampleRate) * short.MaxValue);
Теперь на осцилографе мы увидим совсем другую картину.
Пишем музыку
Раз уж нам удалось сыграть ноту la четвертой октавы нам никто не мешает играть разные ноты. А вам никогда не было интересно как узнать частоты нот? Оказываться есть прекрасная формула 440 * 2 ^ (абсолютный индекс ноты / 12). Если вы взгляните на любой пиано-подобный инструмент то вспомните что на нем есть блоки по 7 белых клавиш и 5 черных, блоки это октавы, белые клавиши это основные ноты (до, ре, ми, фа, соль, ля, си) а черные их полутона, то есть всего 12 звуков в октаве это называется равномерно темперированный строй.
Давайте рассмотрим график этой функции.
Но записывать ноты мы будем в научной нотации поэтому немного изменим формулу опустив ее на 4 октавы и запишем ее в родном для нас виде.
private static double GetNote(int key, int octave) {
return 27.5 * Math.Pow(2, (key + octave * 12.0) / 12.0);
}
Теперь когда мы собрали базовый функционал и отладили его работу давайте продумаем архитектуру будущего синтезатора.
Синтезатор будет представлять из себя некий набор объектов elements которые будут синтезировать звук и накладывать его на пустой массив данных в нужном месте, этот массив и объекты elements будут содержаться в объекте track. Классы описывающие их будут содержаться в пространстве имен Synthesizer, давайте опишем класс Element и Track
public class Element {
int length;
int start;
double frequency;
double compressor;
public Element(double frequency, double compressor, double start, double length, int sampleRate) {
this.frequency = Math.PI * 2 * frequency / sampleRate ;
this.start = (int)(start * sampleRate);
this.length = (int)(length * sampleRate);
this.compressor = compressor / sampleRate;
}
public void Get(ref short[] data, int sampleRate) {
double result;
int position;
for (int index = start; index < start + length * 2; index++) {
position = index - start;
result = 0.5 * Sine(position, frequency) ;
result += 0.4 * Sine(position, frequency / 4);
result += 0.2 * Sine(position, frequency / 2);
result *= Length(compressor, frequency, position, length, sampleRate) * short.MaxValue * 0.25;
result += data[index];
if (result > short.MaxValue) result = short.MaxValue;
if (result < -short.MaxValue) result = -short.MaxValue;
data[index] = (short)(result);
}
}
private static double Length(double compressor, double frequency, double position, double length, int sampleRate){
return Math.Exp((compressor * frequency * sampleRate * (position / sampleRate)) / (length / sampleRate));
}
private static double Sine(int index, double frequency) {
return Math.Sin(frequency * index);
}
}
public class Track {
private int sampleRate;
private List<Element> elements = new List<Element>();
private short[] data;
private int length;
private static double GetNote(int key, int octave) {
return 27.5 * Math.Pow(2, (key + octave * 12.0) / 12.0);
}
public Track(int sampleRate) {
this.sampleRate = sampleRate;
}
public void Add(double frequency, double compressor, double start, double length) {
if (this.length < (start+ length * 2 + 1) * sampleRate) this.length = (int)(start + length * 2 +1) * sampleRate;
elements.Add(new Element(frequency, compressor, start, length, sampleRate));
}
public void Synthesize() {
data = new short[length];
foreach (var element in elements) {
element.Get(ref data, sampleRate);
}
}
}
Теперь мы пришли к последней функции которая будет читать строку с нотами и генерировать нашу мелодию
Для этого создадим Dictionary который будет ассоциировать названия нот с индексами, а также будет содержать управляющие ключи/индексы.
Сама функция будет разбивать строку на слова и дальше обрабатывать каждое слово по отдельности разделяя его на две части — левую и правую, правая часть всегда состоит из одного символа (цифры) которая записывается в переменную октава как число, а длина первой части это длина слова — 1 (то есть слово минус правая часть) и дальше служит ключом к нашему словарю который возвращает индекс ноты, после того как мы разобрали слово мы решим что делать, если индекс управляющий то мы выполним соответствующую индексу функцию, а если нет то это значит что мы имеем индекс ноты и мы добавим к нашему треку новый звук нужной нам длины и частоты нашей ноты.
public void Music (string melody, double temp = 60.0) {
string[] words = melody.Split(' ');
foreach (string word in words) {
int note = notes[word.Substring(0, word.Length - 1)];
int octave = Convert.ToInt32(word.Substring(word.Length - 1, 1));
if (note > 2){
switch (note) {
case 3:
dtime = Math.Pow(0.5, octave + 1) * 8 * (60.0 / temp);
break;
case 4:
length += (int)(Math.Pow(0.5, octave + 1) * 8 * (60.0 / temp));
position += Math.Pow(0.5, octave + 1) * 8 * (60.0 / temp);
break;
}
} else {
Add(GetNote(note, octave), -0.51, position, dtime);
position += dtime;
}
}
}
С этого момента мелодию можно записать в виде
L4 B6 S4 D7 B6 F#6 S4 B6 F#6
Где L команда задающая длину ноты, а S создает паузу, остальные символы это ноты. Собственно на этом написание программного синтезатора закончено и мы можем проверить результат прослушав отрезочек «шутки Баха»Бинарный файл
Исходный код