Программный синтезатор

от автора

И так господа, решил наконец разобраться с программным синтезом музыки, а именно с практической частью реализации подобной задачи. Давайте посмотрим что из это вышло и как оно реализовано…


Создаем волны

Все мы прекрасно понимаем что звук это волна и что частота колебаний волны в от нулевого уровня соответствует частоте звуковой волны, а амплитуда этой волны отвечает за его силу или попросту говоря громкость, но в машинном представлении звук записанный в виде импульсно-кодовой модуляции это массив данных, каждый элемент которого представляет позицию волны в конкретный момент времени.
Давайте рассмотрим простую звуковую волну в формате 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 секунду ноты la четвертой октавы, его частота 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 ^ (абсолютный индекс ноты / количество нот). Если вы взгляните на любой пиано-подобный инструмент то вспомните что на нем есть блоки по 7 белых клавиш и 5 черных, блоки это октавы, белые клавиши это основные ноты (do, re, mi, fa, so, la, si) а черные их полутона, то есть всего 12 звуков в октаве и наша формула приобретает вид 440 * 2 ^ (абсолютный индекс ноты / 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 создает паузу, остальные символы это ноты. Собственно на этом написание программного синтезатора закончено и мы можем проверить результат прослушав отрезочек «шутки Баха»

Бинарный файл
Исходный код

ссылка на оригинал статьи http://habrahabr.ru/post/178915/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *