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

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



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


Все мы прекрасно понимаем что звук это волна и что частота колебаний волны в от нулевого уровня соответствует частоте звуковой волны, а амплитуда этой волны отвечает за его силу или попросту говоря громкость, но в машинном представлении звук записанный в виде импульсно-кодовой модуляции это массив данных, каждый элемент которого представляет позицию волны в конкретный момент времени.
Давайте рассмотрим простую звуковую волну в формате 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 создает паузу, остальные символы это ноты. Собственно на этом написание программного синтезатора закончено и мы можем проверить результат прослушав отрезочек «шутки Баха»

Бинарный файл
Исходный код
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 36

    0
    А что использовали для спектрограммы? Недавно сталкивался с подобной задачей, и не нашел ни одного удобного инструмента для анализа звука.
      0
      Тоже совсем недавно сталкивался и в этом направлении очень помогла эта статья.
        +2
        Использовался банальный foobar2000 с компонентом Musical Spectrum
          0
          Для спектрограммы можно использовать амплитудный спектр (модуль по комплексному массиву после быстрого преобразования фурье). А если его брать в пределах некоторого скользящего по времени «окна» — то как раз каждый очередной спектр в новом положении «окна» — будет меняться по сравнению с предыдущим положением, прямо как в видео в посте. Что может быть удобнее, чем написать это самому (взяв готовую математическую библиотеку с БПФом). Хотя, конечно, это более мудрёно, чем готовые решения.
            0
            Смотрел на этот вариант. Остановила необходимость подгонять входные-выходные данные для стороннего БПФ-а (в каких-то библиотеках есть коэффициент 1/N, в каких-то нет, к примеру, ну не доверяю я сторонним математическим библиотекам), а свой БПФ на момент задачи не осилил. А сейчас вот так смотрю, и думаю, видимо придется все-таки свой БПФ писать.
          –6
          Не хочу никого обидеть, но стиль программирования просто ужасен.
          Начиная от циклов, заканчивая подряд идущими write'ами, игнорированием интерфейса IDisposable, «доскладыванием» числа на много строк и изобилием магических чисел.
            +5
            Вполне нормальный стиль. Ему важна была идея, а не то что Вы скажете о его стиле. Все ваши претензии высосаны из пальца.

            А вообще вам сюда:
            habrahabr.ru/post/178747/

            Статья отличная!
              –1
              Заранее сказал — не хочу никого обидеть. Если бы я писал текст статьи БоЛьШиМи и МаЛеНьКиМи буквами, кого бы интересовала идея, изложенная в ней?
                +3
                Это если бы Вы писали. А так вы пока еще ничего не писали.

                У него с регистром все в норме. Вы опять придираетесь!
                  +3
                  С регистром — обычная аналогия, не стоит понимать буквально.
                  Я не пишу по одной простой причине — я не вижу в этом смысла. Не вижу я что могу поведать что-то важное, пока что.
                  Писать на хабр очередное «как мы собрали старт-ап», «как я собрал сервер», «какую я написал очередную тривиальную программу», «какой алгоритм я использовал для смыва унитаза», «моя история успеха», «Как Google делает очки», «как борются корпорации», «ответ на ужасающий по своей безграмотности другой пост», «как оптимизировать так чтобы читать было невозможно», «как я расписал предельно ясное слово из википедии на целую статью», «как я перепечатал текст из MSDN», «в каких тайландах/китаях/кореях я побывал» и прочую муть, не вижу смысла — это не дает абсолютно никакого знания. Как поразительно, что эта псевдоинтеллектуальщина — большая часть местного населения.
                    0
                    «большая часть местного населения» к сожалению, да и все остальные тоже, не так умны как Вы. они должны почитать вас и приносить Вам дары. Вы никогда не увидите ничего важного, никогда не напишите, а вот критиковать стиль и темы будете всегда. Так устроены многие люди.

                    Писать про очередное:
                    «как мы собрали старт-ап» — а вы собирали?
                    «как я собрал сервер» — может собирали реальный нормальный сервер? я не говорю про «игрушки»
                    «моя история успеха» — а была ли?
                    «какой алгоритм я использовал для смыва унитаза» — это действительно хорошая тема! Если кто возьмется написать, буду благодарен. Запланируйте на пятницу!
                    «в каких тайландах/китаях/кореях я побывал» — бывали? это действительно интересно. многие статьи раскрывают такие мелочи, которых не узнаешь ни по каким познавательным каналам.
                      +1
                      Я не наталкивал никого к мысли о том что я «умнее». Но это псевдоинтеллектуально. Ваши высказывания сродни фразам, «сначала добейся, потом говори». Это смешно, как бы вы меня тут не минусовали. И, подчеркну, я не скажу, что я этого не делал.
                      Я считаю, что все что вы перечисляете здесь «выборочно» есть самые обычные вещи. Просто кто-то их делает, а кто-то делает и пишет статью. Только вот проблема — учить надо не тому, что всем итак ясно, а тому, что людям в действительности открывает глаза на что-то новое.
              +2
              Да не стиль программирования был целью, я попытался написать так чтобы даже новичок понял суть написанного. Поэтому и раскладывал числа на много строк использовал страшноватый BitConverter с foreach вместо чего то подобного stream.WriteByte((byte)(data[i] & 0xff)); stream.WriteByte(unchecked ((byte)(data[i] >> 8)));.
                –3
                Если бы вы хотели именно этого, тогда бы вы разделили это
                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];
                

                На несколько читаемых элементов.

                Вы бы использовали директиву using чтобы было наглядно видно где закончилось чтение стрима

                Вы бы использовали константы чтобы как-то «обличить» ваши магические числа.

                А вот такой код:
                        double[] data = new double[75]; // Инициализируем массив.
                        for (int index = 1; index < 76; index++) { // Вычисляем данные для всего массива.
                            data[index-1] = Sine(index, Math.PI * 2 * 2.0 / 75); // Период разделенный на частоту дискретизации.
                        }
                

                С числами 75, 76, 75, которые в сути одно и тоже, циклом зачем-то начинающимся с единицы, чтобы потом в теле цикла счетчик приходилось вычитать по-вашему упрощают суть?
                  0
                  Вы просто не понимаете что значит этот код и зачем он так разделен. Именно в таком виде он и читабельный и показывает что результирующая волна состоит из пропорционально сложенных волн основной, смещенный на одну октаву и смещенной на две октавы, после чего я показываю что глушу волну привожу его к амплитуде short.MaxValue, а 0.25 это громкость. Это не решение данной задачи а попытка показать как вобще синтез звука устроен.
                  Никаких числе магических нет, это не готовый проект, а заготовка для отработки техники.
                  А смещаем мы цикл просто для одной цели чтобы подправить округление чисел для красоты картинки, или по вашему это от фонаря написано?
              0
              Друзья, а у кого есть материалы по синтезу звука на Objective-C с использованием Core Audio? Какие уже готовые библиотеки (желательно бесплатные) порекомендуете использовать?
                +1
                Есть хорошая бесплатная книга: iZotope iOS Audio Programming (в iBooks можно бесплатно скачать). Радует, что она от реальной фирмы, разрабатывающей музыкальные приложения.
                0
                Отличная статья, очень понравилось. Вопрос автору: а как в вашей программе реализовано проигрывание? Вы проигрываете wav-файл, или же есть возможность проигрывать массив данных, который вы создаете?
                  0
                  Стандартными средствами. Да проигрываем wav-файл (он отличается на 44 байта от нашего массива) впрочем читать его можно из MemoryStream. Теоретически можно доработать поддержку скажем DirectSound.
                  0
                  Попытался запустить проект в Mono под OS X, звук не воспроизводится. Попробую хотя бы в файл сохранить.
                    0
                    Да, в файлике всё ок работает.
                      +1
                      А проигрывает он звук с помощью библиотеки winmm.dll, не мудрено что на маке ее нет…
                      +1
                      При рассмотрении подобных задач думаю, что не зря ходил в универ на цифровую обработку сигналов.
                      Спасибо очень познавательно!
                        0
                        Статья интересная, но справедливости ради стоит сказать, что для реализации достаточно только школьных знаний математики и физики.
                        +3
                        Кто интересуется ещё вот и вот… ещё было суперское видео по этой теме на Clojure, забыл где видел — там прям в реал-тайме мужик разрабатывал крутую композицию + небольшую графику к ней.) И я сейчас не про эту запись… еще была клевая =)
                        0
                        Когда-то баловался с физическим программным синтезом звуков музыкальных инструментов. Даже что-то получилось. Вот это действительно интересно.
                          0
                          Содержание статьи напомнило Play-Notes на powershell и A toy piano на tcl.
                          • НЛО прилетело и опубликовало эту надпись здесь
                              0
                              Я просто в восторге! Мне как разработчику банковских систем полезно вспомнить, что компьютер ещё умеет делать и клёвые штуки, а не только сухие интерфейсы. Спасибо огромное за столь читабельный код и за нотку радости в понедельник вторник.
                                0
                                Для работы со звуком на С# есть неплохая библиотека NAudio и видеоуроки по ней.
                                  +2
                                  Вот только чистая синусоида звучит очень уныло! Глухо и невыразительно.
                                  Добавьте гармоник! Хотя бы квинту-октаву. Или сразу возьмите пилу вместо синусоиды.
                                  У реального инструмента «чистой» ноты нет, есть спектр частот. На той же гитаре можно ударить струну возле 12-го лада и получить ту же унылую синусоиду. А можно щипнуть почти возле подставки и получить насыщенный «металлический» звук с кучей гармоник.
                                  • НЛО прилетело и опубликовало эту надпись здесь
                                      0
                                      Я в своё время подобную задачу решал на ZX-Spectrum, just for fun.
                                      Началось с того, что припаял 8-разрядный ЦАП и выделил порт. И понеслась…
                                      Разумеется, ни о каком real-time просчёте синусоиды там не могло быть и речи. Вместо этого заранее просчитывалась таблица сэмплов (на бейсике :). и в цикле (на ассемблере) я просто складывал циклически эти сэмплы и гнал результат на ЦАП.
                                      Мелодия (многоголосная) записывалась буквенно-цифровой нотацией, каждые 4 ноты — один аккорд.
                                      Например, C-dur — как C1E1G1C2 — и т.д.
                                      И вроде всё было идеально — программа выверена по тактам, никаких задержек и шероховатостей. Но вот звук был просто кошмарным. Потребовалось несколько дней, чтобы понять, что это всё из-за того, что в качестве сэмпла использовалась банальная синусоида!
                                      • НЛО прилетело и опубликовало эту надпись здесь

                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                  Самое читаемое