Процедурная музыка своими руками

Однажды на работе мне в руки попала книга «Поваренная книга C#. Сборник рецептов.» (название точно не вспомню, поэтому любителям C# тут предстоит облом) и в ней упоминались интересные, но в целом, бесполезные функции, использующие простые вызовы WinAPI. Казалось бы, ничего интересного, но я наткнулся на одну вещь, которую я еще не делал, хотя что-то подобное в нашем блоге уже было, но на несколько более высоком уровне…

Дело в том, что я обнаружил в книге функцию, которая генерирует синусоидальный (хотя не уверен, что именно синусоидальный) сигнал с заданной частотой и длительностью, и использует для этого системный динамик. В общем, ерунда ерундой, но вспомнились былые времена, когда на мобильных телефонах (еще не смартфонах) можно было простой файл с некоторыми инструкциями проигрывать, редактируя его прямо на телефоне.

Вот тут собственно и родилась идея.

Известно, что нот, из которых состоит музыка, в принципе немного. Также несомненным фактом является то, что ноты условно можно организовать в несколько рядов, согласно увеличению тона по некоторой привычной для человека психологической шкале. Эти ряды принято называть октавами, и что самое главное — их тоже немного.

Нота — это некий элементарный, можно даже сказать, чистый звук.

А этот звук имеет определенные физические параметры, из которых наиболее удобными являются частота и длительность. Исходя из этого, можно заключить, что имея таблицу частот для нескольких октав, а также обозначив ноты некоторыми символами, можно варьируя их длительности, создавать или проигрывать разные мелодии.

Удобнее всего, ноты обозначить так, как их обозначали известные музыкальные деятели, которым часто надоедало писать ноты, и которые записывали их латинскими буквами:

c «до»
d «ре»
e «ми»
f «фа»
g «соль»
a «ля»
b «си»

Будем считать, что за каждым буквенным обозначением может стоять некотрое число, которое будет обозначать номер октавы. Разумеется, не вся музыкальная палитра непосредственно доступна человеческим ушам, а потому, очевидно, что номер с которого будет начинать таблица частот будет отличен от нуля или единицы.

Также, стоит отметить то, что в музыке есть и специальные обозначения (которых я впрочем не знаю), вводящие дополнительные ноты, которые не входят в октавы. Под дополнительными нотами подразумеваются так называемые диезы и бемоли и их надо учесть.

К сожалению, частот бемолей найти не удалось, а вот диезы можно добавить в нашу скромную таблицу. Для этого к цифро-буквенному нотному представлению добавим еще один символ — символ решетки, который будет обозначать модифицированную диезом ноту.

Удобнее всего, хранить описания нот в виде массива, но не простого, а ассоциативного. Чтобы это осуществить создадим массив типа const(int[string]), где в качестве ключа будет обозначение ноты (в том числе и с диезами), а в роли значения будет выступать ее частота:

auto notesFrequencies = cast(const int[string]) [
			"b2"  :  123,
			"c3"  :  131,
			"c#3" :  139,
			"d3"  :  147,
			"d#3" :  156,
			"e3"  :  165,
			"f3"  :  175,
			"f#3" :  185,
			"g3"  :  196,
			"g#3" :  208,
			"a3"  :  220,
			"a#3" :  233,
			"b3"  :  247,
			"d4"  :  294,
			"d#4" :  311,
			"e4"  :  330,
			"f4"  :  349,
			"f#4" :  370,
			"g4"  :  392,
			"g#4" :  415,
			"a4"  :  440,
			"a#4" :  466,
			"b4"  :  494,
			"c5"  :  523,
			"c#5" :  554,
			"d5"  :  587,
			"d#5" :  622,
			"e5"  :  659,
			"f5"  :  698,
			"f#5" :  740,
			"g5"  :  784,
			"g#5" :  831,
			"a5"  :  880,
			"a#5" :  932,
			"b5"  :  988,
			"c6"  :  1047,
			"c#6" :  1109,
			"d6"  :  1175,
			"d#6" :  1245,
			"e6"  :  1319,
			"f6"  :  1397,
			"f#6" :  1480,
			"g6"  :  1568,
			"g#6" :  1661,
			"a6"  :  1760,
			"a#6" :  1865,
			"b6"  :  1976,
			"c7"  :  2093,
			"c#7" :  2217,
			"d7"  :  2349,
			"d#7" :  2489,
			"e7"  :  2637,
			"f7"  :  2794,
			"f#7" :  2960,
			"g7"  :  3136,
			"g#7" :  3322,
			"a7"  :  3520,
			"a#7" :  3729,
			"b7"  :  3951,
			"c8"  :  4186,
			"c#8" :  4435,
			"d8"  :  4699,
			"d#8" :  4978
		];

Таким образом, имеем целый набор различных нот, каждую из которых достаточно легко воспроизвести, не использовав при этом никаких сторонних библиотек. В операционной системе Windows есть достаточно много полезных встроенных функций, одна из которых нам и потребуется. В данном случае нас интересует функция Beep, которая имеет вот такую простую сигнатуру:

static bool Beep(int frequency, int duration);

Импортируем ее, используя стандартную директиву компилятора pragma(lib) и уже готовую к линкованию с программой библиотеку kernel32.lib, которая есть в типовой поставке компилятора D:

pragma(lib, "kernel32.lib");

extern (Windows) 
	static bool Beep(int frequency, int duration);

Поскольку системный динамик присутствует в количестве «один штука», то логично будет предположить, что нужен класс, который можно создать лишь однажды. Вспоминая принципы ООП, а также некоторый учебный материал, который ранее публиковался в нашем блоге, легко находим решение — паттерн «Одиночка».

Для реализации этого решения создадим параметризованный интерфейс, который будет говорить о том, что класс в обязательном порядке должен реализовать минимально необходимый метод одиночки, который называется getInstance():

interface ISingleton(T) if (is(T==class))
{
	public static T getInstance();
}

Как следует из названия, этот метод предназначен для контроля создания экземпляра с приватным конструктором, и гарантирует создание единственного экземпляра класса на всю программу.

Оставим пока реализацию самого метода, и подумаем, чего не хватает в будущем классе простого проигрывателя нот, который для определенности назовем SimpleBeeper.

Саму мелодию проще всего передать как строку нот, в которой ноты отделены друг от друга разделителем, в роли которого можно использовать пробел. Так как ноты проигрываются поодиночке, то для каждой ноты следует добавить параметр, который будет сигнализировать о ее длительности. Поэтому метод класса SimpleBeeper, который будет воспроизводить записанную в наших обозначениях мелодию, помимо строки нот должен принимать что-нибудь вроде массива длительностей.

Массив длительностей легко проинтерпретировать в программе, однако, при таком положении дел, данные относящиеся к одному и тому же объекту (объект не в том смысле, в котором он фигурирует в концепциях ООП) хранятся в разных формах, а это не порядок. Поэтому, в целях сохранения единообразия и упрощения разбора нот, представим длительность каждой ноты в виде строки целых чисел. Одно число такой строки будем интерпретировать как время в течении, которого проигрывается нота, заданное в миллисекундах.

Таким образом, метод, который проигрывает мелодию будет выглядеть следующим образом:

public void playMusic(string notes, string durations)
	{
		import std.conv : to;
		import std.range : zip;
		import std.string : split;

		foreach(noteAggregate; zip(split(notes), split(durations)))
		{
			playNote(noteAggregate[0], to!int(noteAggregate[1]));
		}
	}

Внутри цикла foreach происходит проход по всем комбинациям «нота — длительность», представленных в виде кортежа из двух элементов. Подобный кортеж создает функция zip из модуля стандартной библиотеки std.range. Эта любопытная процедура, принимает в нашем случае два аргумента, каждый из которых разбивает свою строку данных на отдельные строки, что достигается применением функции split из std.string. Процедура split осуществляет разбиение строки на отдельные подстроки, используя некоторый символ в качестве разделителя. Нетрудно заметить, что по умолчанию для split разделитель не указан, однако, если посмотреть определение этой функции в стандартной библиотеке, то можно увидеть, что ее вторым аргументом является любой из типичных разделителей (описаны в сигнатуре split). В данном случае, в роли разделителя выступает пробел, а потому второй аргумент (из-за его широкого применения и наличия определения в сигнатуре функции)  можно опустить.

Создадим на основе описанного выше класс и добавим реализацию метода getInstance:

class SimpleBeeper : ISingleton!SimpleBeeper
{
	private
	{
		static SimpleBeeper instance;
		
	}

	private this()
	{

	}

	private void playNote(string note, int duration)
	{
		auto notesFrequencies = cast(const int[string]) [
			"b2"  :  123,
			"c3"  :  131,
			"c#3" :  139,
			"d3"  :  147,
			"d#3" :  156,
			"e3"  :  165,
			"f3"  :  175,
			"f#3" :  185,
			"g3"  :  196,
			"g#3" :  208,
			"a3"  :  220,
			"a#3" :  233,
			"b3"  :  247,
			"d4"  :  294,
			"d#4" :  311,
			"e4"  :  330,
			"f4"  :  349,
			"f#4" :  370,
			"g4"  :  392,
			"g#4" :  415,
			"a4"  :  440,
			"a#4" :  466,
			"b4"  :  494,
			"c5"  :  523,
			"c#5" :  554,
			"d5"  :  587,
			"d#5" :  622,
			"e5"  :  659,
			"f5"  :  698,
			"f#5" :  740,
			"g5"  :  784,
			"g#5" :  831,
			"a5"  :  880,
			"a#5" :  932,
			"b5"  :  988,
			"c6"  :  1047,
			"c#6" :  1109,
			"d6"  :  1175,
			"d#6" :  1245,
			"e6"  :  1319,
			"f6"  :  1397,
			"f#6" :  1480,
			"g6"  :  1568,
			"g#6" :  1661,
			"a6"  :  1760,
			"a#6" :  1865,
			"b6"  :  1976,
			"c7"  :  2093,
			"c#7" :  2217,
			"d7"  :  2349,
			"d#7" :  2489,
			"e7"  :  2637,
			"f7"  :  2794,
			"f#7" :  2960,
			"g7"  :  3136,
			"g#7" :  3322,
			"a7"  :  3520,
			"a#7" :  3729,
			"b7"  :  3951,
			"c8"  :  4186,
			"c#8" :  4435,
			"d8"  :  4699,
			"d#8" :  4978
		];

		Beep(notesFrequencies[note], duration);
	}

	public static SimpleBeeper getInstance()
	{
		if (instance is null)
		{
			instance = new SimpleBeeper;
		}
		return instance;
	}

	public void playMusic(string notes, string durations)
	{
		import std.conv : to;
		import std.range : zip;
		import std.string : split;

		foreach(noteAggregate; zip(split(notes), split(durations)))
		{
			playNote(noteAggregate[0], to!int(noteAggregate[1]));
		}
	}
}

В принципе, ничего нового, а единственное, что может вызвать вопрос — это параметризованный интерфейс. Тут все достаточно просто: наследуем интерфейс, подставляя в шаблон интерфейса в качестве параметра имя самого класса, а компилятор автоматически сопоставит нужные типы и сгенерирует класс SimpleBeeper.

Испытаем класс, проиграв что-то отдаленно похожее на заглавную мелодию из «Симпсонов», описав процедуру main вот так:

void main()
{
	SimpleBeeper beeper = SimpleBeeper.getInstance();
	beeper.playMusic(
		"c6 e6 f#6 a6 g6 e6 c6 a5 f#5 f#5 f#5 f#5 f#5", 
		"300 300 300 2400 300 300 300 2400 2400 2400 2400 600"
		);
}
Полный код примера под катом.

pragma(lib, "kernel32.lib");

extern (Windows) 
	static bool Beep(int frequency, int duration);


interface ISingleton(T) if (is(T==class))
{
	public static T getInstance();
}

class SimpleBeeper : ISingleton!SimpleBeeper
{
	private
	{
		static SimpleBeeper instance;
		
	}

	private this()
	{

	}

	private void playNote(string note, int duration)
	{
		auto notesFrequencies = cast(const int[string]) [
			"b2"  :  123,
			"c3"  :  131,
			"c#3" :  139,
			"d3"  :  147,
			"d#3" :  156,
			"e3"  :  165,
			"f3"  :  175,
			"f#3" :  185,
			"g3"  :  196,
			"g#3" :  208,
			"a3"  :  220,
			"a#3" :  233,
			"b3"  :  247,
			"d4"  :  294,
			"d#4" :  311,
			"e4"  :  330,
			"f4"  :  349,
			"f#4" :  370,
			"g4"  :  392,
			"g#4" :  415,
			"a4"  :  440,
			"a#4" :  466,
			"b4"  :  494,
			"c5"  :  523,
			"c#5" :  554,
			"d5"  :  587,
			"d#5" :  622,
			"e5"  :  659,
			"f5"  :  698,
			"f#5" :  740,
			"g5"  :  784,
			"g#5" :  831,
			"a5"  :  880,
			"a#5" :  932,
			"b5"  :  988,
			"c6"  :  1047,
			"c#6" :  1109,
			"d6"  :  1175,
			"d#6" :  1245,
			"e6"  :  1319,
			"f6"  :  1397,
			"f#6" :  1480,
			"g6"  :  1568,
			"g#6" :  1661,
			"a6"  :  1760,
			"a#6" :  1865,
			"b6"  :  1976,
			"c7"  :  2093,
			"c#7" :  2217,
			"d7"  :  2349,
			"d#7" :  2489,
			"e7"  :  2637,
			"f7"  :  2794,
			"f#7" :  2960,
			"g7"  :  3136,
			"g#7" :  3322,
			"a7"  :  3520,
			"a#7" :  3729,
			"b7"  :  3951,
			"c8"  :  4186,
			"c#8" :  4435,
			"d8"  :  4699,
			"d#8" :  4978
		];

		Beep(notesFrequencies[note], duration);
	}

	public static SimpleBeeper getInstance()
	{
		if (instance is null)
		{
			instance = new SimpleBeeper;
		}
		return instance;
	}

	public void playMusic(string notes, string durations)
	{
		import std.conv : to;
		import std.range : zip;
		import std.string : split;

		foreach(noteAggregate; zip(split(notes), split(durations)))
		{
			playNote(noteAggregate[0], to!int(noteAggregate[1]));
		}
	}
}

void main()
{
	SimpleBeeper beeper = SimpleBeeper.getInstance();
	beeper.playMusic(
		"c6 e6 f#6 a6 g6 e6 c6 a5 f#5 f#5 f#5 f#5 f#5", 
		"300 300 300 2400 300 300 300 2400 2400 2400 2400 600"
		);
}

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

P.S.: Я никак не связан с музыкой, а потому, многие приведенные объяснения на тему музыки могут быть ошибочными. Мне было важно показать принцип, а не саму теорию музыки, поэтому за подробностями лучше обратиться к компетентному источнику.

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