понедельник, 23 июня 2008 г.

Программируем музыку

Статей и уроков я никогда не писал и не претендую. Все что ниже — собранные мною грабли.

Идея
Как сделать для игры саундтрек? Одна зацикленная песня быстро надоест, у нее должно быть как минимум начало (а без ухищрений обязан быть и конец), поэтому уже на третьем повторе игрока стошнит. Затем, очень бы хотелось, чтобы музыка отражала состояние игры, и уж вообще здорово, если игрок будет своими действиями менять мелодию.

Поэтому, я решил сделать псевдо-генерацию саундтрека — взять несколько маленьких кусочков, и во время игры в реальном времени собирать из них мелодию. Просто и хорошо =)


Ножницы
Что ж, попробуем разделить песню на маленькие составляющие и управлять ими как захочется. Когда-то я накидал в FruityLoops простенький трек, выглядело это примерно так:


Белые прямоугольнички — это и есть звуки, паттерны, каждый длиной 8, 4, 2 и 1 условных квадратиков (бит). Сохраняем каждый паттерн как отдельный файлик — и трек разрезан, остаётся только склеить его обратно уже в игре.


На будущее: у меня паттерн длиной 8 получился равен 2,824 секундам (зависит от темпа!!) и был выбран как основной (самый большой).


Клей
Так. Получилось несколько наборов звуков: басы, пианинка и гитара. Разумеется, одновременно проигрывать, например, 2 гитары смысла нет — получится каша, поэтому рождается страшное понятие инструмента.

В моём случае, это просто массив из N целочисленных, где N — число различных паттернов для инструмента, а числа в массиве означают сколько ещё раз надо проигрывать соответствующий звук (наверное, ничего непонятно =)

Вот примерный кусочек кода для проигрывания одного инструмента:
procedure SoundUpdate(count : single);
begin
timer := timer + count;

// текущий паттерн в очередной раз закончился
if timer > patterns[current].length then
begin
Play(patterns[current]); // проигрываем ещё раз
patterns[current] := patterns[current] - 1; // и уменьшаем счетчик

// если счётчик достиг нуля, выбираем случайным образом новый
// текущий паттерн, и назначаем ему случайное число повторений
if patterns[current] <= 0 then
begin
current := random(high(patterns_beat) + 1);
patterns[current] := (1 + random(2));
end;

timer := 0;
end;
end;

Проблемы не-ООП
Это было очень легко. Но, неэкономно — уже для трёх инструментов получалось слишком много кода. Не страшно, конечно, но настоящие проблемы начались когда я попробовал добавить паттерн другой длины. Например, я хочу, чтобы перед тем как зазвучит гитара (длиной 8), проигрался паттерн с гитарным вступлением (длиной 4, ещё и с отступом на 4 у.е.)

Я попробовал завести несколько таймеров, чтобы отсчитывать нужные моменты для всех возможных длин (напомню: 8, 4, 2 и 1), но их получилось жутковатое количество — ведь паттерн длиной 4 может прозвучать как в первой, так и второй половине паттерна длины 8 (и неплохо бы это дело контролировать). Итого, получится аж 15 таймеров, управляемых "вручную" — неудобно. Значит, пишем класс!)


Класс
Это был самый сложный этап. Получилось так:
const
base = 2824; // "базовая" длина (в миллисекундах)

TSoundPatterns = class
patterns : array of integer; // счетчики воспроизведения
current : byte; // текущий паттерн
start : single; // отступ
timer : single; // таймер, ясное дело
played : boolean; // флаг "уже проигранности" паттерна
public
procedure Play; // момент воспроизведения
procedure Next; virtual; // выбор следующего паттерна
procedure Update(count : single); // обновление
end;


procedure TSoundPatterns.Play;
begin
// нулевой номер паттерна означает тишину (не воспроизводится)
if current <> 0 then
sound.Play(current); // псевдокод! просто отдаем звуковому движку
// команду проигрывать звук, соответствующий
// нашему инструменту и текущему паттерну

patterns[current] := patterns[current] - 1;
if patterns[current] <= 0 then
Next; // генерируем дальнейшую судьбу нашей мелодии
end;

procedure TSoundPatterns.Update(count : single);
begin
timer := timer + count;
if (timer > start) and (not played) then // время проиграть звук
begin
Play;
played := true;
end;

if timer >= base then // следущая интерация, все по новой
begin
timer := 0;
played := false;
end;
end;
Теперь, для каждого инструмента (класса-потомка) нужно переопределить процедуру Next — и у каждого будет своя судьба =)
procedure TSPBeat.Next;
begin
current := random(high(patterns)) + 1; // выбор номера следующего паттерна
patterns[current] := (1 + random(2)) * 2; // и назначение ему числа повторений
end;

procedure TSPGuitar.Next;
var
i : integer;
begin
i := current;
current := random(high(patterns)) + 1; // выбор номера следующего паттерна
patterns[current] := (2 + random(2)) * 2; // и назначение ему числа повторений

if (i = 0) and (current <> 0) then // если была тишина (нулевой паттерн), а
begin // дальше зазвучит гитара (ненулевой),
sp_guitar_start.current := 1; // то проигрываем вступление
sp_guitar_start.patterns[sp_guitar_start.current] := 1; // один раз
end;
end;
Довольно просто, но на написание (осознание) потратилось порядочно времени.


Повторения
Всё хорошо, но паттерн больше одного раза за промежуток времени base не произведётся. А хотелось — например мышеклик (длиной 1) должен звучать сразу после щелчка мыши, а не ждать секунду-две для синхронизации. Для этого немножко колдуем:

// вписать вместо флага played
repeat_next : integer; // счетчик повторений
repeat_step : integer; // шаг повторений

// вычисление шага, _repeats — необходимое число повторений
repeat_step := 8 + _start - _repeats + 1;

procedure TSoundPatterns.Update(count : single);
begin
timer := timer + count;
if timer > start + repeat_next / 8 * temp + 4 then
begin
Event;
repeat_next := repeat_next + repeat_step;
end;

if timer >= temp then
begin
timer := 0;
repeat_next := 0;
end;
end;
Теперь, для того чтобы воспроизвести звук один раз прямо сейчас (и с синхронизацией по музыке! =) достаточно задать _repeats = 8, посчитать repeats_step и написать процедуру из двух строчек. Вот такая она для мышеклика:
procedure Click;
begin
sp_click.current := 1;
sp_click.patterns[sp_click.current] := 1;
end;

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

Во-вторых, нужно не забыть "почистить" звуки, чтобы в конце не было щелчков, а зацикленность была плавной и незаметной, но тут уже дело слуха (кстати, тестировать музыку очень сложно).

На самом деле здесь всё очень просто, на осмысление и программирование ушло 2-3 часа (раза в полтора меньше чем на этот пост =), так что не бойтесь, результат того стоит:
— Твоя музыка?! о_О
— Генерируемая?!! О_О
Удачи!) Надеюсь, в целом понятно. Вопросы? ↓

3 комментария:

grouzd)ev комментирует...

Хорошие мысли всегда приходят позже и от других (elmortem):

1) Можно обойтись одним глобальным таймером - появится некоторая экономия вычислений, но, по-моему, очень незначительная

2) В качестве базового куска логичнее было взять 1 у.е., а не 8 (а вместо числа повторений нужно будет задавать длину паттерна). Так будет нагляднее и будет работать в более общем случае.

beisik комментирует...

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

И почему не написанно ничего про изменение мелодии в зависимости от состояния игры? Мне самому это придумавать надо что ли? :)

Эт, я так, думаю всунуть такую музыкособиралку в the sorrow :)

grouzd)ev комментирует...

Мелодия от состояния игры не зависит (хотя могла бы). Когда писался пост, мышеклик был достаточно длинным звуком, и воспроизводить его отдельно было преступлением.

На самом деле, смысл всего извращения - Дебрайзис =)