Как стать автором
Обновить

Punk riff generator

Уровень сложностиСредний
Время на прочтение38 мин
Количество просмотров1.7K

Когда-то давно, может быть лет пять назад, мне захотелось воспроизвести в браузере звук. Уж не помню, какая конкретно у меня была задача, и чего я хотел добиться — скорее всего просто поиграться с разными семплами; может, запрограммировать трек. Пошел в гугл с вопросом, как это сделать, попал на StackOverflow с вопросом/ответом примерно такого вида. И увидел, как на запрос "playing a simple sound" на меня вываливают какие-то контексты, буферы, декодирование... И так мне стало душно, что я сразу махнул рукой на все это и рассудил, что не сильно-то мне это все и интересно — уж точно не такой ценой. И забыл эту тему на долгие годы.

Как я к этой теме вернулся, я тоже особо не помню. Но мне захотелось воспроизвести в браузере обычный синусоидальный сигнал, а потом в случае успеха как-то поиграться с ним. Возможно, на меня (не)здорово повлияла Hyper Light Drifter с ее chiptune музыкой.

Потом затея стала стихийно развиваться, и в результате случайным образом вылилась в мини-проект "Punk riff generator" или "Генератор панк-риффов", о котором мы в статье и поговорим. В проекте заложена нехитрая идея: дать возможность генерировать и воспроизводить в браузере 4 случайных аккорда, которые можно было бы впоследствии использовать как основу для очередной панк-песни. Ну не здорово ли?

Настраиваемся на прочтение статьи правильно:

  • Статья насчитывает с пару десятков коротких аудио-дорожек, чтобы вы ощутили эволюцию звука, который мы будем творить собственными руками. А эволюция будет, что надо!

  • Повествование будет сопровождаться немного душноватыми спусками в теорию звука, но я попробую объяснять доходчиво

  • Смело предположу, что вам не понадобится никакой музыкальный бекграунд, чтобы понять происходящее в статье. Но это не точно! А на сколько это не точно, мне расскажут в комментариях

  • Статья вышла хитросплетенным миксом из математики, теории музыки и программирования. Читайте и развивайтесь всесторонне :)

Простая синусоида

Итак, мой первый порыв был воспроизвести в браузере синусоидальный сигнал. Сказано-сделано — я как всегда пошел на Stack Overflow, скопипастил оттуда решение, вставил в script-секцию html-страницы и получил желанный сигнал:

Отлично, то что я хотел! Теперь давайте задним числом посмотрим, что за код отвечает за генерацию этого звука. В браузерном JS звуками заведует Web Audio API, и именно его буферы и декодирование отпугнули меня при первой попытке ознакомиться с браузерным аудио некоторое количество лет назад. API действительно достаточно низкоуровневый, а потому, кстати говоря, очень гибкий и с массой возможностей. К самому API мы еще вернемся, он в исходном коде присутствует в большом количестве, но в данном конкретном случае мы код API проигнорируем и сразу обратим внимание на ключевой кусочек кода, который формирует нашу синусоиду:

function sineWaveAt(sampleNumber, tone) {
	const time = sampleNumber / context.sampleRate
	return Math.sin(2 * Math.PI * tone * time)
}

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

Физика (и математика) звука

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

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

Как нам визуализировать такое колебание? Самый простой способ — представить себе график синуса; ну или косинуса, если хотите — они в разрезе нашей задачи не особо отличаются. Синус в каком-то смысле — пример чистейшего звука в вакууме: нота без тембра, окраса, примесей. Попробуем построить каноничную синусоиду y=sin(x):

Скорее всего вам этот график знаком еще со школы, и вы помните, что он бесконечно простирается влево и вправо по оси x — тут у нас идет время в секундах. По оси y — амплитуда колебания волны. Если совсем грубо, то ось y можно считать за громкость сигнала. С этой амплитудой немного сложно, потому что непонятно, с чем соотнести ее величины, так что для простоты давайте будем считать, что если звук лежит в диапазоне [-1;1], то это такой полновесный, эталонный по громкости звук. Буду признателен, если в комментариях кто-нибудь сможет как-то более формально охарактеризовать значения по оси y.

График синусоиды пусть и бесконечен, но у него есть четко выраженный повторяющийся паттерн — период. Для y=sin(x) период равен 2\pi секунды:

И вот здесь с волной y=sin(x) возникает проблема: если одно колебание этого сигнала длится 2\pi \approx 6.28 секунд, следовательно частота такого сигнала равна 1/2\pi\approx0.16Hz. Человек же при этом способен услышать звук лишь в частотном диапазоне от 20Hz до 20000Hz в лучшем случае. Т.е. наш сигнал y=sin(x) — это какой-то экстремальный случай инфразвука, и вряд ли какое-либо живое существо способно его услышать.

Что можно сделать, чтобы волна стала слышимой? Вообще синусоиду можно растягивать по обеим осям, формула синуса для этого слегка обогащается коэффициентами:

y=a*sin(h x)

где

a — скейл по оси y. Чем a больше, тем синусоида выше и, соответственно, громче.
h — скейл по оси x. Чем h больше, тем период синусоиды короче, график сжатее, а частота выше (звук писклявее).

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

y=a*sin(2\pi h x)

Вот теперь с этим можно работать. Посмотрим на пример с двумя синусоидами:

Что мы можем сказать о этих графиках, имея на руках ту теорию, что мы только что освоили? Сможем ли мы из любопытства подобрать для них точные формулы? Давайте начнем с простого — с амплитуды. Зеленый график имеет вертикальный диапазон [-1; 1], т.е. амплитуда равна единице, прямо как у обычной синусоиды без явных коэффициентов. Синий график сжат по вертикали, и его экстремумы лежат на -0.75 и 0.75, следовательно его a=0.75.

Теперь посмотрим на горизонтальные свойства графиков. Можем обратить внимание на точку x=0.01. В отрезке [0; 0.01] умещается ровно один период синего графика и четыре периода графика зеленого. Иными словами за 0.01 секунды две волны успевают совершить 1 и 4 колебания соответственно. Нехитрая математика подскажет, нам, что это равно 1/0.01=100 и 4/0.01 = 400 колебаниям в секунду соответственно.

И вот теперь мы знаем, как эти две синусоиды выглядят формульно:

y_{green}=sin(2 \pi 400x)y_{blue}=0.75sin(2 \pi 100x)

Не забываем, что здесь делает 2\pi — это период колебания, и он дает числам 100 и 400 возможность трактоваться как "количество колебаний в секунду". Или если проще, то это частота колебания волны в герцах.

Вот и получаем, что зеленая волна — это звук в 400Hz на громкости 100%, а синяя волна — звук в 100Hz на громкости 75%.

Герцы — это хорошо, но может мы могли бы сказать, что это за ноты? И вот тут от теории звука мы плавно вступаем на территорию теории музыки.

Физика (и математика) музыки

Общеизвестно, что нот всего семь. Более точным же утверждением, правда, будет, что:

  • Нот всего семь

  • Но это касается западной музыки

  • Нот не совсем семь, а скорее двенадцать

  • Никто вам не запретит выходить за пределы этих двенадцати нот, и это делается повсеместно

А что это за такие 12 нот, и почему все таки не семь, ведь до ре ми фа соль ля си же? А вот смотрите:

Не спрашивайте, почему так, и какая за этим стоит логика. Скорее всего так исторически сложилось. Стоит заметить, что вообще нот бесконечное множество: после того как вы на пианино сыграли эти 12 нот, вы можете пойти дальше и сыграть снова эти же 12 нот, но уже в другой октаве. Это работает в обе стороны — как влево, к более басовым, низким нотам; так и вправо, к более высоким.

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

Почему именно такие значения закрепились как именные ноты, на которых строится вся западная музыка — это уже больше вопрос человеческого восприятия и культуры, которая восходит своими корнями к древней Греции, и мы, пожалуй, углубляться в эту тему не станем. Скажем лишь, что за эталонную частоту обычно берется ля четвертой октавы (A4), ей присвоили частоту 440Hz, а остальные ноты строятся от нее основываясь на специальных пропорциях, благозвучных для уха.

А вот синусоидальная волна, которую мы успели сгенерировать в нашем проекте — это Ми четвертой октавы с частотой 329.63Hz.


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

function sineWaveAt(sampleNumber, tone) {
	const time = sampleNumber / context.sampleRate
	return Math.sin(2 * Math.PI * tone * time)
}

Последняя строчка теперь нам плюс-минус понятна, поскольку:

ожидание y=a*sin(2\pi h x)
реальность Math.sin(2 * Math.PI * tone * time)

tone - в данном случае частота в герцах, time — это наш x, ведь не забываем, что ось x отвечает за время. Но как мы сформировали переменную time, и откуда взялись sampleNumber и sampleRate? И вот тут мы снова должны кратко нырнуть в теорию — в теорию оцифровки звука.

Оцифровка звука

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

Наверняка вы видели такие цифры, как 44.1kHz или 48kHz, когда речь заходила о CD или DVD качестве звука. Это показатели (кстати говоря, снова частоты, но уже другие) того, на сколько точек будет разделена одна секунда сигнала. Например, частота дискретизации в 44.1kHz — это раздербанивание одной секунды сигнала на целых 44100 точек. Соответственно, точка №44100 будет представлять звук на момент времени 1с, №66150 — 1.5с, №88200 — 2с и т.д.


Снова возвращаемся к коду!

function sineWaveAt(sampleNumber, tone) {
	const time = sampleNumber / context.sampleRate
	return Math.sin(2 * Math.PI * tone * time)
}

Как мы посчитали время? У Web Audio API есть свой аудио-контекст, который поддерживает какую-то конкретную частоту дискретизации звука, лежащую в переменной sampleRate. Собственно, чтобы выйти на время x, мы должны знать номер сэмпла, для которого мы хотим взять значение нашей синусоиды: sampleNumber. Ну и, наконец, деление номера семпла на частоту дискретизации дает нам точное время в секундах.

Наконец-таки мы смогли разобраться в двух-строчном коде! И все ради того, чтобы в следующий же момент я вам объявил, что у Web Audio API есть специальная oscillator-нода, которая сама умеет генерировать синусоидальный сигнал любой частоты, и мы могли не возиться с этими формулами вручную. Зато сколько опыта мы получили!

Осциллятор

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

Объекту OscillatorNode можно задать частоту в Hz, чтобы получить звук любой высоты. Но что интереснее, и с чем мы еще не сталкивались: сигналу можно задать не только форму синусоиды, но и так же форму квадрата, треугольника и форму пилы. У Википедии есть показательное изображение:

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

Те, кто играл в 8-битные приставки, могут словить легкое чувство дежавю — примерно так разработчики Денди могли менять тембр для игровых мелодий, варьируя звуки от очень мягких, плавных до резких и режущих ухо.

Теперь давайте создадим нашу осцилляторную ноду и сравним ее использование с тем, что у нас было написано в функции sineWaveAt:

const oscillator = context.createOscillator();
oscillator.frequency.value = toneInHz

В самом минималистичном варианте как бы и все: создали ноду, задали ей высоту звучания — готово! Но сам факт создания ноды и ее настройка не дают нам тут же звук в браузере. Поскольку, чтобы наш осциллятор был услышан, его сигнал нужно куда-то направить. И вот здесь мы постепенно начнем углубляться в Web Audio API, чего я не сделал для предыдущего примера, поскольку код с sineWaveAt в любом случае был на выброс.

Как я уже вскользь упоминал, Web Audio API предоставляет нам некий контекст, AudioContext. По сути своей он является глобальным объектом, у которого есть все необходимые методы для создания нод и их соединения друг с другом. Ноды в свою очередь могут быть:

  • Источниками звука, как наша OscillatorNode. Или, к примеру, существует нода, которая может воспроизвести mp3-файл

  • Преобразователями звука. Это ноды для различного эквалайзинга, наложения эха, изменения громкости и т.п.

  • Приемниками звука. На самом деле такая нода есть только одна, и она — что-то вроде виртуального динамика, на которую подается итоговый звук, и именно он будет слышен через ваши колонки или наушники

Основная суть и принцип работы Web Audio API: вы создаете необходимые вам ноды, соединяете их друг с другом, чтобы звук в нужном порядке проходил и преобразовывался через них, а в самом конце итоговый сигнал нужно подать на финальную ноду AudioContext.destination.

В нашем случае схема будет достаточно простой: у нас есть нода-осциллятор, и мы соединяем ее с AudioContext.destination, и в браузере будет слышен звук. Но я бы на вашем месте остерегался его слушать, поскольку здесь закрался нюанс. Громкость осциллятора очень высокая, потому что выдаваемый им сигнал лежит в знакомом нам диапазоне y\in[-1; 1], который означает что-то вроде "полновесной громкости".

Громкость сигнала нужно значительно убавить, и сама нода осциллятора не дает никаких настроек для этого. Потому что за громкость отвечает отдельная нода GainNode. У нее есть буквально одна настройка — gain. Это громкость в диапазоне [0; 1]. Я остановился на 0.25.

Итого, схематично мы должны получить какой-то такой результат:

Выразим это в коде:

context = new AudioContext();

const oscillator = context.createOscillator()
oscillator.frequency.value = tone

const gain = context.createGain()
gain.gain.value = 0.25

oscillator.connect(gain)
gain.connect(context.destination)

oscillator.start(context.currentTime)

Создали контекст, создали и настроили две ноды, все подсоединили и в конце запустили наш осциллятор командой start. Только после этого пинка осциллятор станет генерировать звук, который пойдет по всему нашему незамысловатому контуру: Gain-нода примет звук с осциллятора, ослабит его на четверть и передаст результат на выход.

Результат будет буквально таким же, как и в прошлый раз. Поэтому давайте ради разнообразия зададим осциллятору треугольную форму:

oscillator.type = "triangle";

Теперь это звучит вот так:

Воспроизводим мелодию

Теперь полученный нами сигнал будем использовать для того, чтобы воспроизвести какую-нибудь простую мелодию. Я для этой цели выбрал начальный отрывок из песни Seven Nation Army. Полагаю, большинство может воспроизвести эти ноты в своей голове, поэтому я не стану приводить отрывок из этой песни.

Как только возникает задача проиграть последовательность звуков, мы неизбежно сталкиваемся с временной шкалой во всех ее проявлениях и с сопутствующими вопросами:

  • Как долго проигрывать каждый звук?

  • В какой единице измерения задавать длительность нот?

  • Как задать темп песни? И если захочется ускорить или замедлить общий темп, как сохранить корректную длительность нот относительно друг друга?

  • Как резать песню на такты, и как количество тактов зависит от темпа песни?

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

Видите семь последовательно записанных нот? В качестве любопытного упражнения попробуйте воспроизвести в голове Seven Nation Army и проследовать за каждой нотой на нотном стане. Ставь класс, если вышло. А теперь разберемся, как, что и зачем здесь записано.

Начнем с любопытной записи 4/4 после скрипичного ключа. Она означает, что под эту песню вы можете топать ногами, хлопать руками и циклично кричать "раз, два, три, четыре! раз, два, три, четыре!". И если вы поймаете грув и будете делать это в нужном темпе, то вам будет очень весело.

♩=110 наверху как раз обозначает темп песни, который подскажет с какой скорость нужно хлопать и кричать. Темп измеряется в bpm или beats per minute (удары в минуту). Дословно 110 bpm нужно трактовать так: если непрерывно играть ноту длительностью ♩, то за одну минуту их будет проиграно ровно 110 штук.

Но кто такой этот ваш ♩?! Здесь проще будет сначала показать, как все типы нот умещаются в такт 4/4:

Итак, разные ноты по-разному помещаются в такт "один, два, три, четыре!". В первом такте мы видим целую ноту — она звучит весь такт, все 4 притопа. Во втором такте размещены половинные ноты, их уже две — одна звучит пока "раз, два", вторая звучит пока "три, четыре!". В последующих тактах идут четвертные, восьмые и шестнадцатые ноты. Полагаю, принцип вы поняли.

Интересующая нас ♩ — четвертная нота. Она интересна тем, что в такте размерностью 4/4 она как раз играется ровно 4 раза и символизирует каждый "раз", "два", "три" и "четыре". Темп 110 bpm как раз завязан на количество таких нот в минуту. Причем никто не предлагает высчитывать все эти длительности вручную и считать до 110. Обычно музыканты и так хорошо знают, что песня с более-менее обычным, умеренным темпом — это 110-125 bpm; 90 bpm — это что-то медленное; 70 bmp — тягуче медленное; 150 bpm — это что-то быстрое, задорное; а 200 bpm — экстрим. Ну и всегда есть камертон, которому можно задать нужный bmp, и он сам будет отщелкивать четвертные ноты, чтобы вы могли играть в стабильном темпе даже без барабанщика.

Внимание: не запутайтесь в флажках! Запись ♫ — это всего лишь две сгруппированные ноты ♪♪. Аналогично ♬=𝅘𝅥𝅯𝅘𝅥𝅯. Группироваться ноты с флажком могут в очень длинные ряды

Интересный факт: в Чеченской Республике в 2024 году на законодательном уровне запретили музыку с темпом ниже 80 bpm и выше 160 bpm

Полагаю, вы не могли не заметить, что в нотной записи Seven Nation Army возле некоторых нот присутствуют подозрительные точки. Они увеличивают длительность ноты в 1.5 раза. То есть

♩.=♩♪

Сложно? Возможно, но музыканты привыкли.

Еще в недалеком будущем нам понадобятся паузы. Это как ноты, только их отсутствие. Сколько длится пауза, столько длится тишина. Они так же как и ноты существуют в целом, половинном, четвертном, восьмом, шестнадцатом и т.п. вариантах. И так же могут комбинироваться с точками, удлиняющими паузу в 1.5 раза:


У нас встает задача запрограммировать все эти концепции. Шквал теории, возможно, выглядел страшно, но на деле, если стартовать с понятия bpm, то все станет достаточно просто:

const BPM = 110
const BPS = BPM / 60

const FOURTH = 1 / BPS
const EIGHT = FOURTH / 2
const HALF = FOURTH * 2
const WHOLE = HALF * 2

const FOURTH_DOT = FOURTH * 1.5
const EIGHTH_DOT = EIGHTH * 1.5
const SIXTEENTH_DOT = SIXTEENTH * 1.5
const HALF_DOT = HALF * 1.5

Это собственно все константы, которые нам нужны, чтобы начать творить. Что мы сделали:

  • BPM задали, как и требуется песне в 110

  • Перевели BPM (beats per minute) в BPS (beats per second), поделив BPM на 60.

  • Помня, что beat — это именно четвертная нота, мы переводим BPS в SPB (seconds per beat), просто инвертируя значение. Теперь у нас есть количество секунд, которое должна звучать четвертная нота

  • Длительности остальных нот в секундах мы легко вычисляем через четвертную ноту

  • Ноты с точками просто домножаются на 1.5

Теперь представим, как могли бы выглядеть четыре повторения нашей Seven Nation Army фразы:

for (i = 0; i < 4; ++i) {
	playSineWave(E4, FOURTH_DOT)
	playSineWave(E4, EIGHT)
	playSineWave(G4, EIGHTH_DOT)
	playSineWave(E4, EIGHTH_DOT)
	playSineWave(D4, EIGHT)
	playSineWave(C4, HALF)
	playSineWave(B3, HALF)
}

Да, это определенно напоминает происходящее на нотном стане: последовательно играем каждую ноту, указав ее высоту и длительность. Осталось дело за малым: написать нашу пока несуществующую функцию playSineWave.

let timeline = 0.0

function playSineWave(tone, seconds) {
    const oscillator = context.createOscillator()

    oscillator.type = "triangle"
    oscillator.frequency.value = tone // value in hertz

    const gain = context.createGain()
    gain.gain.value = 0.25
    oscillator.connect(gain)
    
    gain.connect(context.destination)

    oscillator.start(context.currentTime + timeline)
    oscillator.stop(context.currentTime + timeline + seconds)

    timeline += seconds
}

По идее мы все это уже видели: последовательное соединение нод Oscillator, Gain, Destination. Основная хитрость здесь в том, когда запускать воспроизведение осциллятора и, что немаловажно, в какой момент его останавливать. Здесь нам помогает переменная timeline. Представьте, что во время проигрывания песни по нотному стану бежит ползунок, показывающий текущее время воспроизведения, как в любом музыкальном проигрывателе. Переменная timeline — это такой ползунок. В последней строке playSineWave этот ползунок каждый раз увеличивается на длительность очередной ноты. Соответственно start и stop у осциллятора мы запускаем в момент времени timeline а останавливаем по прошествию seconds секунд.

Важно понимать, что методы start и stop — это не синхронные методы с ожиданием выполнения операции, а асинхронные планировщики. В start вы планируете, в какое время в будущем запустите осциллятор; в stop — в какое время осциллятор остановит воспроизведение. Т.е. фактически наш внешний цикл for, в котором гоняется вся песня, отработает на самом старте программы за считанные миллисекунды и заранее распланирует будущее воспроизведение всех звуков. И только потом, на протяжении столького времени, сколько на это потребуется, аудио-контекст на фоне будет воспроизводить все, что вы ему напрограммировали. Именно поэтому без монотонно-нарастающего timeline ваш код заработает наиболее ужасающим способом — он запустит все 28 нот (4 цикла по 7 нот) одновременно. Этого мы точно не хотим, этого наши уши никак не выдержат.

А вот и результат наших стараний:

Устраняем щелчки

Возможно, вы заметили неприятные щелчки при смене каждой ноты. При поиске решения гугл на одной из первых строчек выдает статью, которая объясняет природу данного явления и предлагает одно из возможных простых решений: за миллисекунды до конца каждой ноты плавно убавлять громкость сигнала в 0. Т.е. делать эдакий micro fade out на каждую ноту. У меня это вышло примерно так:

const startTime = context.currentTime + timeline
const endTime = startTime + seconds

gain.gain.setTargetAtTime(0, endTime - DAMPING_START, DAMPING_DURATION);
oscillator.start(startTime)
oscillator.stop(endTime + DAMPING_DURATION)

DAMPING_START и DAMPING_DURATION я подобрал опытным путем так, чтобы пропали щелчки, но при этом ноты не стали "спотыкаться" и создавать паузы:

Power chord

Следующая цель: стать чуточку ближе к року. Для этого мы перестанем пиликать мелодию по одной ноте, а будем играть ее power chord'ами. Что это такое? Это такие три звука, проигрываемых одновременно. Все роковые риффы играются такими аккордами в 90% случаев. Если еще проще — именно power chord'ами играется сладостный уху "дж-дж".

На нотном стане такой сюжетный поворот выглядит страшновато:

Но бояться нечего — просто нотный стан для таких вещей уже едва подходит, а вот в гитарно-табулатурной записи это выглядит попроще:

Как читать эти циферки, я думаю, мы углубляться не будем. Скажу лишь, что на гитаре у power chord'а есть замечательная особенность: он имеет одну и ту же форму независимо от того, где вы его взяли на грифе (конечно, с оговорками, но все же), поэтому этой пальцевой конфигурацией можно резво елозить по грифу и издавать инфернальный рок.

Наше текущее исполнение функции playSineWave позволяет проигрывать только один звук в один момент времени, поэтому назрела пора усовершенствовать эту функцию. Начнем снова с внешнего цикла и с того, каким бы нам хотелось его видеть:

for(let i = 0; i < 4; ++i) {
	playSound([E3, B3, E4], FOURTH_DOT)
	playSound([E3, B3, E4], EIGHTH)
	playSound([G3, D4, G4], EIGHTH_DOT)
	playSound([E3, B3, E4], EIGHTH_DOT)
	playSound([D3, A3, D4], EIGHTH)
	playSound([C3, G3, C4], HALF)
	playSound([B2, Fsh3, B3], HALF)
}

Во-первых, название playSineWave мы сменим на более солидный playSound. И главное нововведение — передача массива с нотами вместо одинарного звука. Функция по своей сути останется все той же за исключением того, что теперь будет создаваться не один осциллятор, а N осцилляторов на каждую ноту в передаваемом массиве:

function playSound(notes, seconds) {
    const gain = context.createGain()
    gain.gain.value = VOLUME
    gain.connect(context.destination)

    for (let i = 0; i < notes.length; i++) {
        const oscillator = context.createOscillator()

        oscillator.type = "triangle"
        oscillator.frequency.value = notes[i]
        oscillator.connect(gain)

        const startTime = context.currentTime + timeline
        const endTime = startTime + seconds

        gain.gain.setTargetAtTime(0, endTime - DAMPING_START, DAMPING_DURATION)
        oscillator.start(startTime)
        oscillator.stop(endTime + DAMPING_DURATION)
    }

    timeline += seconds
}

Gain-нода при этом осталась одна, ибо зачем нам по gain-ноде на каждый осциллятор. Тут особо внимательные могут справедливо заметить, что зачем нам вообще каждый раз создавать gain-ноду внутри playSound — ведь будет достаточно всего одной gain-ноды вообще на всю программу? И это будет верное замечание. Исправимся:

const gain = context.createGain();

...

function playSound(notes, seconds) {
    const startTime = context.currentTime + timeline
    const endTime = startTime + seconds

    for (let i = 0; i < notes.length; i++) {
        const oscillator = context.createOscillator()
        oscillator.type = "sine"
        oscillator.frequency.value = notes[i]
        oscillator.connect(compressor)
        
        oscillator.start(startTime)
        oscillator.stop(endTime + DAMPING_DURATION)
    }

    gain.gain.setTargetAtTime(0, endTime - DAMPING_START, DAMPING_DURATION);
    gain.gain.setTargetAtTime(VOLUME, endTime + DAMPING_DURATION, DAMPING_DURATION);

    timeline += seconds
}

Заодно вынесли startTime и endTime из цикла. А вот наш fade out костыль после введения общей gain-ноды ломается — теперь gain-ноде нужно каждый раз возвращать громкость обратно после отрабатывания нашего fade out. В предыдущие разы все работало, потому что когда мы сбавляли громкость gain-ноды в ноль, нода нам более была не нужна, ведь в следующий раз мы просто (и расточительно) создавали новую.

С такой незамысловатой доработкой наша Seven Nation Army стала звучать немного иначе:

Это могло бы быть вступлением к неизвестной игре на вашу любимую 8-битную приставку. На гитарный звук не похоже совсем, мощи тоже не чувствуется никакой. Нужно попробовать обработать звук с осциллятора так, чтобы он стал более тяжелым и характерным. Если он станет звучать хотя бы как симуляция гитары в Guitar Pro 5, я буду доволен.

Distortion

Самое главное, что делает гитарный звук тяжелым — это эффект distortion и его разновидности. Distortion — это и есть тот самый "дж-дж". Гитаристы, как правило, используют специальные педали, чтобы применить этот эффект на свой гитарный звук.

— Но ты же говорил, что power chords — это "дж-дж"?
— Power chord'ами играется "дж-дж", а distortion — это и есть сам "дж-дж". На предыдущей звуковой дорожке хорошо слышно, что power chord не смог превратить нашу мелодию в хардкор, потому что не хватало того самого эффекта distortion.

В нашем распоряжении нет волшебной аналоговой гитарной педали, которая могла бы нам дать distortion, поэтому нам придется самим сделать его программно. К сожалению и Web Audio API не располагает готовой distortion-нодой, поэтому нам придется что-то выдумывать самостоятельно.

Что есть distortion по своей природе? Название подсказывает нам, что это некоторого рода "искажение" сигнала. Искажение такого характера, что чистый, спокойный звук становится громким, перегруженным, зудящим и гремящим. Но каким алгоритмом или какой функцией мы можем превратить синусоиду в нечто, что будет рычать и зудеть?

Общепринято реализовывать эффект distortion через преобразующую математическую функцию, которая была бы (осторожно, математический сленг!):

  • гладкой

  • монотонной

  • возрастающей

  • нелинейной

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

Выбирайте, какая сигмоида вам больше по душе:

y = \frac{kx}{1+|kx|}y = \frac{2}{\pi}arctan(\frac{\pi}{2}kx)y = tanh(kx)y = \frac{2}{1+e^{-kx}}-1y = \frac{(3 + k)*arctan(5sinh(0.25x))}{\pi + k|x|}y=1.3arctan(tanh(\frac{kx}{2}))

Какой бы сложности формулу вы не выбрали, их графики будут выглядеть достаточно схоже, разница лишь в нюансах формы и поведения при изменении переменной k:

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

Ради упрощения давайте представим, что мы имеем дело со звуком, который представлен каноничной синусоидой y=sin(x). В таком случае нам нужно каким-то образом наложить на синусоиду сигмоидное преобразование. Звучит страшно. Забористая википедийная алгебра подсказывает нам, что итоговую формулу искаженного сигнала можно получить через композицию двух функций: в нашем случае сигмоида и синуса, и математически это записывается как (f\circ sin)(x), если принять сигмоиду за f. Тут же в Википедии нас успокаивают, что запись с кружочком эквивалентна f(sin(x)). А это уже лично мне понятно: где раньше в формуле сигмоиды был x, теперь должна быть формула нашего сигнала, т.е. sin(x). Делаем простейшие подстановки и получаем искомое искажение синусоидного сигнала:

Вот мы и увидели distortion наяву — он оказался синусоидой, стремящейся к периодической прямоугольной функции.


Web Audio API предоставляет ноду WaveShaperNode, которая позволяет применить к любому сигналу преобразовательную функцию. С ее помощью мы и будем искажать звук, выдаваемый осцилляторами.

Ноде можно задать любой график в дискретном виде: массивом Float32Array. Работает это так:

  • График всегда расценивается как функция с x, лежащим в диапазоне [-1; 1]

  • Массив Float32Array при этом может быть любой длины. Чем длиннее массив, тем больше частота дискретизации вашего преобразующего графика. Нода будет расценивать первый элемент массива, как x=-1, средний элемент как x=0, а последний как x=1

  • Сами числа в массиве — значения по оси y

  • Нода сама применит этот преобразующий график к тому сигналу, который ей придет. Точно так же, как мы с вами вручную применили сигмоиду к синусоидальному сигналу на графике

В цепочку из нод должна будет вклиниться еще одна нода:

Я остановился на сигмоиде вида

y = \frac{(3 + k)*arctan(5sinh(0.25x))}{\pi + k|x|}

и сделал функцию, которая возвращает преобразование в том виде, который нужен ноде WaveShaperNode:

function makeDistortionCurve(k = 20) {
    const n_samples = 256
    const curve = new Float32Array(n_samples);

    for (let i = 0; i < n_samples; ++i ) {
        const x = i * 2 / n_samples - 1;
        curve[i] = (3 + k)*Math.atan(Math.sinh(x*0.25)*5) / (Math.PI + k * Math.abs(x));
    }
    return curve;
}

Частота дискретизации сигнала — 256 точек. Эту цифру я подбирал экспериментальным путем уже после того, как у меня все заработало — этого небольшого числа точек оказалось вполне достаточно, чтобы вполне корректно применять искажение. Строка const x = i * 2 / n_samples - 1; — это как раз то самое приведение диапазона [0; 256] к диапазону [-1; 1], в котором "работает" наша преобразующая функция. Параметр k — это тот самый коэффициент, который менялся на графиках сигмоид и регулировал их крутизну. Чем выше k, тем забористее звук. Это аналог крутилки gain на гитарной педали distortion.

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

let context;
let distortion;
let gain;

function makeDistortionCurve(k = 20) {
	...
}

async function init() {
    context = new AudioContext();

    gain = context.createGain();
    gain.gain.value = VOLUME

    distortion = context.createWaveShaper();
    distortion.curve = makeDistortionCurve(50);

    distortion.connect(gain)
    gain.connect(context.destination)
}

let timeline = 0.0

const VOLUME = 0.25

function playSound(notes, seconds) {
    const startTime = context.currentTime + timeline
    const endTime = startTime + seconds

    for (let noteIndex = 0; noteIndex < notes.length; noteIndex++) {
        const oscillator = context.createOscillator();
        oscillator.type = "sine";
        oscillator.frequency.setValueAtTime(notes[noteIndex], context.currentTime);
        oscillator.connect(distortion);
        
        oscillator.start(startTime)
        oscillator.stop(endTime + DAMPING_DURATION)
    }

    timeline += seconds
}

const button = document.querySelector("button");

button.onclick = async () => {
    if (!context) {
        await init();
    }

	const BPM = 110
	const BPS = BPM / 60
	
	const FOURTH = 1 / BPS
	const EIGHT = FOURTH / 2
	const HALF = FOURTH * 2
	const WHOLE = HALF * 2
	
	const FOURTH_DOT = FOURTH * 1.5
	const EIGHTH_DOT = EIGHTH * 1.5
	const SIXTEENTH_DOT = SIXTEENTH * 1.5
	const HALF_DOT = HALF * 1.5

	for(let i = 0; i < 4; ++i) {
		playSound([E3, B3, E4], FOURTH_DOT)
		playSound([E3, B3, E4], EIGHTH)
		playSound([G3, D4, G4], EIGHTH_DOT)
		playSound([E3, B3, E4], EIGHTH_DOT)
		playSound([D3, A3, D4], EIGHTH)
		playSound([C3, G3, C4], HALF)
		playSound([B2, Fsh3, B3], HALF)
	}
    timeline = 0
};

Пробежимся по основным пунктам:

  • Где-то на веб-странице лежит кнопка

  • При ее нажатии мы инициализируем все необходимое для старта:

    • Создаем контекст

    • Создаем и настраиваем gain и distortion ноды. Здесь мы и применяем makeDistortionCurve, чтобы задать distortion-ноде сигмоид

    • Тут же сооружаем цепочку distortion -> gain -> context.destination

  • Запускаем наш цикл с 4 повторениями мелодии

  • Мелодия все так же исполняется функцией playSound, которая выглядит так же как и раньше за небольшими исключениями:

    • Выход осциллятора теперь подается на ноду distortion, а не на ноду gain, как было раньше

    • Трюк с уровнем громкости у ноды gain ушел, поскольку, как оказалось, при тех искажениях, которые мы сейчас будем получать, щелчки уже не слышно. Был костыль, и не стало костыля — отпал сам собой

Запускаем нашу программу в предвкушении тяжелого перегруза!:

Ну, такое себе конечно, но уже что-то. Это действительно дисторшн, но уж больно пластмассовый, и у него большие проблемы с частотами. Почему так? Самый простой ответ на этот вопрос — потому что мы создали искусственный синтезированный звук, и его частотные характеристики, так называемая АЧХ совершенно не похожи на те, какие дал бы нам звук гитарной струны, пропущенный через distortion-эффект. Звук струны и звук нашей чистой синусоиды отличаются? Отличаются. Вот и результат после distortion тоже отличается, причем в худшую сторону.

Эквализация

Что мы можем с этим сделать? Первое, что приходит в голову — покрутить частоты так, чтобы звук стал приближен к тому, что мы хотели бы услышать.

— Но у изначальной синусоиды же была только одна частота, откуда теперь взялось некое множество частот?
— Во-первых power chord сам по себе — это три ноты, так что в один момент времени мы имеем как минимум три разные частоты. Ну а процесс искажения через distortion — это технически и есть обогащение звука дополнительными гармониками, так что теперь у нас на руках целый спектр самых разных частот

Наверняка вы видели в каждом втором музыкальном проигрывателе "скачущие волны", которые пульсируют и меняют свою форму в процессе проигрывания песни. Это собственно и есть все частоты среза песни, и они меняются во времени:

У Web Audio API есть механизмы, чтобы "гнуть" эту белую кривую, изображенную на картинке выше. Этой цели служит BiquadFilterNode. Чтобы понять, как она работает, давайте представим, что эта белая кривая для какого-то особенного звука вырождается в горизонтальную линию:

Теперь мы можем удобно показать, что с этой частотной кривой умеет делать BiquadFilterNode:

Источник изображения: https://subscription.packtpub.com/book/business-and-other/9781782168799/1/ch01lvl1sec12/building-an-equalizer-using-biquadfilternode-advanced

Итак, в нашем распоряжении есть целый ряд возможных частотных преобразований:

  • Lowpass. Плавно срезает все верха, начиная с указанной частоты и далее вправо

  • Highpass. Плавно срезает все басы, начиная с указанной частоты и далее влево

  • Bandpass. Плавно срезает все низы и верха, начиная с указанной частоты и далее влево и вправо, оставляя небольшую нетронутую полянку вокруг заданной частоты. Размер полянки задается отдельным параметром

  • Notch. Делает операцию обратную Bandpass — плавно срезает полянку, оставляя все остальное нетронутым

  • Lowshelf. Усиливает или уменьшает все частоты левее указанной частоты. Эффект усиления или уменьшения регулируется отдельным параметром, который может быть отрицательным в случае уменьшения уровня частот

  • Highshelf. Усиливает или уменьшает все частоты правее указанной частоты. Эффект усиления или уменьшения регулируется отдельным параметром, который может быть отрицательным в случае уменьшения уровня частот

  • Peaking. Усиливает или уменьшает частоты в окрестности указанной частоты. Размер окрестности у уровень усиления или ослабления регулируется двумя дополнительными параметрами

  • Allpass. Я не понял, что делает этот фильтр. Комментаторы, выручайте! Он пропускает все частоты, но изменяет их фазовые взаимосвязи. Что это может значить — ровным счетом понятия не имею :)

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

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

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

  • Cut Nosal. Полностью срезал так называемые "носовые частоты" в окрестности 1000 Hz фильтром notch. Звук стал не таким гудящим и мутным

  • Cut Highs. Через lowpass срезал все частоты начиная с 8500 Hz, чтобы звук не был таким тонким и трещащим. По логике убирать частоты начиная аж с 8500 Hz — это как резать по живому, но по моим ощущениям стало лучше

  • Cut Lows. Срезал басы highpass-фильтром начиная с 120 Hz, чтобы не сильно бубнило

  • Peak Mids. Сделал ощутимый прирост средних частот в районе 3150 Hz с помощью фильтра peaking. Средние частоты — родной гитарный диапазон, поэтому я здраво рассудил, что его нужно выпятить

В коде вся эта эквализация представлена следующим образом:

async function init() {
	...
	
	// cut around 1000 Hz
	let cutNosal = context.createBiquadFilter();
	cutNosal.type = "notch";
	cutNosal.frequency.setValueAtTime(1000, context.currentTime);
	cutNosal.Q.setValueAtTime(4, context.currentTime);
	
	// cut above 8500 Hz
	let cutHighs = context.createBiquadFilter();
	cutHighs.type = "lowpass";
	cutHighs.frequency.setValueAtTime(8500, context.currentTime);
	cutHighs.Q.setValueAtTime(0, context.currentTime);
	
	// cut below 120 Hz
	let cutLows = context.createBiquadFilter();
	cutLows.type = "highpass";
	cutLows.frequency.setValueAtTime(120, context.currentTime);
	cutLows.Q.setValueAtTime(0, context.currentTime);
	
	// boost around 3000 Hz
	let peakMids = context.createBiquadFilter();
	peakMids.type = "peaking";
	peakMids.frequency.setValueAtTime(3150, context.currentTime);
	peakMids.Q.setValueAtTime(1.5, context.currentTime);
	peakMids.gain.setValueAtTime(5, context.currentTime);
	
	distortion.connect(cutNosal)
	cutNosal.connect(cutHighs)
	cutHighs.connect(cutLows)
	cutLows.connect(peakMids)
	peakMids.connect(gain)
	gain.connect(context.destination)
}

И вот как это зазвучало:

Не то, чтобы стало ощутимо лучше, правда? Звук все равно не особо приятный. А еще он плоский и не имеет объема. Поэтому я подумал, что неплохо бы наложить на звук немного reverb'а.

Reverb

Reverb — это буквально эффект эха. Чем больше reverb'а вы наложите на звук, тем объемнее будет казаться пространство, в котором этот звук воспроизводится.

Web Audio API предоставляет нам ноду ConvolverNode, с помощью которой можно сделать вообще любой reverb. Но API добивается такой ультимативной универсальности хитрым и достаточно ленивым способом — у ноды есть всего один главный параметр buffer, в который нужно подать impulse response.

Что такое impulse response? Это аудиофайл, на который специальным образом записана "атмосфера" комнаты, помещения или любого другого окружения. Под "атмосферой" в данном случае подразумевается отклик помещения или комнаты на сигналы всех частот. Еще это можно представить как если бы вы микрофоном записывали гитару, играющую, например, в большой комнате, а потом из этой записи бы вычли звук гитары. Все, что осталось — это отклик помещения на звуки. По сути impulse response предоставляет нам такой отклик.

В интернете можно найти большое множество файлов impulse response'ами на любой вкус для эмуляции любого помещения — от тесной кладовки или туалета и до станции метро, собора или огромного стадиона. Применив один из таких impulse response'ов на вашу аудиодорожку с инструментом, вы получаете эхо, характерное для выбранного помещения.

Я никогда не сталкивался с применением impulse response ранее, но это оказалось несложно: я скачал с интернета небольшую библиотеку с wav-файлами, скармливал их в ConvolverNode и смотрел на результат. Через некоторое количество попыток получил приемлемый результат.

Вот такой функцией мы получаем готовую, настроенную для дальнейшего использования ноду:

async function createReverb() {
    let convolver = context.createConvolver();
  
    // load impulse response from file
    let response = await fetch("./WireGrind_s_0.8s_06w_100Hz_02m.wav");
    let arraybuffer = await response.arrayBuffer();
    convolver.buffer = await context.decodeAudioData(arraybuffer);
  
    return convolver;
}

Ну а далее по известной схеме эта нода внутри нашего init встраивается в цепочку:

async function init() {
	...

    let reverb = await createReverb();

	...
    
    distortion.connect(cutNosal)
    cutNosal.connect(cutHighs)
    cutHighs.connect(cutLows)
    cutLows.connect(peakMids)
    peakMids.connect(reverb)
    reverb.connect(gain)
    gain.connect(context.destination)
}

Цепочка эффектов, надо сказать, уже разрослась:

Наслаждаемся результатом:

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

Генерация аккордов

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

Первая же проблема, или точнее, неопределенность, возникает практически сразу — как именно мы будем проигрывать сгенеренную последовательность аккордов? Ну, то есть, мы могли бы, к примеру, просто ударить по струнам по одному разу на каждый аккорд. Но это же было бы скучно и невесело? Окей, раз уж у нас панк-рифф генератор, то может будет достаточно просто методично, монотонно бить по струнам вниз 8 раз, как карикатурные басисты из анекдотов? Это более приемлемый вариант, но это ведь тоже очень скучно.

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

RYTHMS = [
    [EIGHTH, EIGHTH, EIGHTH, EIGHTH],
    [EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH],
    [FOURTH, EIGHTH, FOURTH, EIGHTH, EIGHTH, EIGHTH],
    [FOURTH_DOT, FOURTH, EIGHTH, EIGHTH, EIGHTH],
    [EIGHTH, EIGHTH, EIGHTH_PAUSE, EIGHTH],
    [EIGHTH, EIGHTH, EIGHTH_PAUSE, EIGHTH, EIGHTH, EIGHTH, EIGHTH_PAUSE, EIGHTH],
]

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

Но подождите, среди знакомых констант затесалась ранее не существовавшая EIGHTH_PAUSE! И как бы вроде бы понятно, что это пауза длительностью в одну восьмую такта, но какой константой мы ее выразили? Напомню, что длительности HALF, FOURTH, EIGHTH и т.д. у нас представлены числом, означающем длительность в секундах. Паузы тоже должны длиться секундах. Но как тогда отличить звуки от пауз, если и те и другие представлены секундами, а другой дополнительной информации у нас на руках нет, и ее не хочется вводить за счет дополнительных флагов, полей, классов? Я решил эту задачу достаточно быстро, заодно случайно введя антивремя:

const FOURTH_PAUSE = -FOURTH
const EIGHTH_PAUSE = -EIGHTH
const SIXTEENTH_PAUSE = -SIXTEENTH
const HALF_PAUSE = -HALF
const WHOLE_PAUSE = -WHOLE

const FOURTH_DOT_PAUSE = -FOURTH_DOT
const EIGHTH_DOT_PAUSE = -EIGHTH_DOT
const SIXTEENTH_DOT_PAUSE = -SIXTEENTH_DOT
const HALF_DOT_PAUSE = -HALF_DOT

Да-да, мы будем отличать длительности звуков от длительностей пауз знаком. Такой вот костыль. Но мы же пишем на JS в конце концов, кого нам стесняться.

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

Давайте проговорим, как это алгоритмически должно работать: генерируем 4 случайных power аккорда, затем каждый аккорд проигрываем сообразно выбранному ритмическому рисунку. И все это проигрываем 4 раза, чтобы слушатель получше запомнил последовательность. То есть намечается 3 вложенных цикла:

for bar in bars:
	for chors in chords:
		for note in rythm.notes:
			...

Это был псевдокод, теперь напишем код этих циклов:

RYTHMS = [
    [EIGHTH, EIGHTH, EIGHTH, EIGHTH],
    [EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH, EIGHTH],
    [FOURTH, EIGHTH, FOURTH, EIGHTH, EIGHTH, EIGHTH],
    [FOURTH_DOT, FOURTH, SIXTEENTH, EIGHTH_DOT, EIGHTH],
    [EIGHTH, EIGHTH, EIGHTH_PAUSE, EIGHTH],
    [EIGHTH, EIGHTH, EIGHTH_PAUSE, EIGHTH, EIGHTH, EIGHTH, EIGHTH_PAUSE, EIGHTH],
]

button.onclick = async () => {
    if (!context) {
        await init();
    }
    
    const chords = [
        createPowerChord(generateNote()),
        createPowerChord(generateNote()),
        createPowerChord(generateNote()),
        createPowerChord(generateNote())
    ];

    const rythm = RYTHMS[4]

    for(let bar = 0; bar < 4; ++bar) {
        for(let ch = 0; ch < chords.length; ++ch) {
            for(let i = 0; i < rythm.length; ++i) {
                const duration = rythm[i];
                if (duration >= 0) {
                    playSound(chords[ch], rythm[i])
                } else {
                    playPause(duration)
                }
            }
        }
    }
    timeline = 0
};

Какие новые методы нам предстоит реализовать исходя из написанного? Мы видим незнакомые нам playPause(), generateNote()и createPowerChord.

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

function playPause(duration) {
    timeline += duration
}

Теперь переходим к createPowerChord(generateNote()). Предполагается, что мы сначала выберем случайную ноту из нашего списка нот, а потом функция createPowerChord(...), беря эту ноту в качестве первой, построит правильный power аккорд. И казалось бы, что тут сложного — берешь и делаешь, но эта задача оказалась тем проблемным местом, из-за которого пришлось слегка переписать общий подход к описанию нот в коде.

Ведь как у нас до этого момента хранились ноты:

// Notes in Hz
const C2 = 65.41;
const Csh2 = 69.30;
const D2 = 73.42;
const Dsh2 = 77.78;
const E2 = 82.41;
const F2 = 87.31;
const Fsh2 = 92.50;
const G2 = 98.00;
const Gsh2 = 103.83;
const A2 = 110.00;
const Ash2 = 116.54;
const B2 = 123.47;

const C3 = 130.81;
const Csh3 = 138.59;
const D3 = 146.83;
const Dsh3 = 155.56;
const E3 = 164.81;
const F3 = 174.61;
const Fsh3 = 185.00;
const G3 = 196.00;
const Gsh3 = 207.65;
const A3 = 220.00;
const Ash3 = 233.08;
const B3 = 246.94;

...

И далее по списку — он длинный. Теперь вопрос — а как из этого набора констант выбрать случайную? А никак. По крайней мере пока ноты представлены исключительно в таком виде, точно никак. Нам нужен какой-то общий список, в котором будут все эти ноты. И вот тогда из списка можно будет выбрать случайный элемент.

Неприятно, да. Решение, на которое я в итоге переписал хранение нот, тоже оказалось не особо элегантным, я бы даже сказал костыльным, но мы же пишем на JS в конце концов, кого нам стесняться! Получилась такая жуть:

// Notes in Hz
const NOTES_HZ = [
    65.41,
    69.30,
    73.42,
    77.78,
    82.41,
    87.31,
    92.50,
    98.00,
    103.83,
    110.00,
    116.54,
    123.47,
    ...
]

const C2   = NOTES_HZ[0];
const Csh2 = NOTES_HZ[1];
const D2   = NOTES_HZ[2];
const Dsh2 = NOTES_HZ[3];
const E2   = NOTES_HZ[4]; // E string
const F2   = NOTES_HZ[5];
const Fsh2 = NOTES_HZ[6];
const G2   = NOTES_HZ[7];
const Gsh2 = NOTES_HZ[8];
const A2   = NOTES_HZ[9]; // A string
const Ash2 = NOTES_HZ[10];
const B2   = NOTES_HZ[11];
...

И на такой манер записаны 59 нот. Ндаа, такое себе. Я думаю, что если нормально посидеть и подумать, то можно было бы выйти из положения достойнее; хотя бы избавиться от захардкоженных индексов. Но этот проект точно не про красоту архитектуры кода, поэтому я оставил, как есть. Главное, что цель была достигнута — теперь можно выбрать случайную ноту из всего набора:

function generateNote() {
    const LOWEST_E_STRING = 4
    const HIGHEST_A_STRING = 16
    
    return Math.floor(Math.random() * HIGHEST_D_STRING) + LOWEST_E_STRING;
}

function createPowerChord(tonica) {
    return [NOTES_HZ[tonica], NOTES_HZ[tonica + 7], NOTES_HZ[tonica + 12]];
}

В generateNote я специально ограничил выбор случайной ноты так, чтобы она бралась либо на 6 гитарной струне, либо на 5 струне до 6 лада. Если вы не понимаете, о чем речь, не берите в голову — это скорее специфика, чтобы power аккорды получились адекватными по высоте и привычности уху. Ну а createPowerChord на основе полученной ноты составляет аккорд по правильным музыкальным интервалам.

А вот и результат такой генерации:

Ну вот мы и добились, чего хотели! Случайные аккорды генерируются, и даже есть ритмический рисунок. Звучит здорово. Точнее звучит-то оно паршивенько; а здорово, что в принципе мы добились основной цели, и браузер сам творит для нас музыку.

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

Настоящая гитара

Как водится, я пошел искать ответы на вопросы в интернетах и вскоре снова очутился на StackOverflow, где вопрошающему на схожий вопрос отвечали, что есть два пути:

  • Залезть в очень сложную математику и там сгинуть

  • Взять запись чисто звучащей гитарной струны и применять все эффекты на нее

Два стула — не иначе. Первый вариант пугал с порога, второй сулил очередное переписывание констант с частотами — что и как именно придется переписывать, я на тот момент еще не понимал в полной мере, но уже предвидел, что переписывать всяко придется.

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

А математика, надо сказать, совершенно несложная. Начать стоит со всеми нам знакомого эффекта: если мелодию ускорить в два раза, она не только ускорится, но еще станет выше, писклявее. Вспомнив про синусоиду, вы даже можете понять почему — сжимая синусоиду вы получаете бóльшую частоту звука. Теория музыки даже подскажет нам, что ускорение в два раза даст повышение звука ровно на одну октаву. Отсюда и можно вывести незамысловатую формулу для вычисления playback rate сэмпла, чтобы добиться любой желаемой ноты:

playbackRate=2^{(note-sampleNote)/12}

где:
note — нота, которую мы хотим сыграть,
sampleNote — нота, звучащая в сэмпле инструмента
12 — помним, что нот в октаве 12, и они равномерно отстоят друг от друга. Потому и делим на 12

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

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

Какой аудиофайл нам нужен? Я решил, что для того чтобы одним файлом со звучанием одной единственной струны можно было сносно покрыть весь гитарный диапазон, я за основу возьму звучание открытой четвертой струны.

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

В интернете в свободном доступе я нашел следующий звук, который меня устроил:

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

Так как это звук открытой четвертой струны, мы знаем, что это нота D3. Остальные ноты мы должны будем выстраивать относительно нее. А еще держим в уме. что теперь важны не герцы, а просто порядок нот относительно друг друга.

Так это что — снова переписывать наши нотные константы? Выходит, что так. Хорошая новость — теперь их запись станет предельно простой:

const C2   = 0
const Csh2 = 1
const D2   = 2
const Dsh2 = 3
const E2   = 4 // E string
const F2   = 5
const Fsh2 = 6
const G2   = 7
const Gsh2 = 8
const A2   = 9 // A string
const Ash2 = 10
const B2   = 11

const C3   = 12
const Csh3 = 13
const D3   = 14 // D string
const Dsh3 = 15
const E3   = 16
const F3   = 17
const Fsh3 = 18
const G3   = 19 // G string
const Gsh3 = 20
const A3   = 21
const Ash3 = 22
const B3   = 23 // B string

...

Да, все настолько просто. И наш костыль с массивом пропал — теперь мы сможем справиться без него.

Звуковой файл мы загружаем точно так же, как загружали impulse response для reverb-эффекта:

async function createGuitarSample() {
    return fetch("./guitar_d_string.wav")
        .then(response => response.arrayBuffer())
        .then(buffer => context.decodeAudioData(buffer))
}

А нода AudioBufferSourceNode заменит нам осцилляторную ноду. Ей мы скормим загруженный аудио-буффер и зададим нужный playbackRate по формуле, которую обсуждали выше:

let guitarSample

async function init() {
	...
	guitarSample = await createGuitarSample()
	...
}

...

const SAMPLE_NOTE = D3;

function createSampleSource(noteToPlay) {
    const source = context.createBufferSource()
    source.buffer = guitarSample
    source.playbackRate.value = 2 ** ((noteToPlay - SAMPLE_NOTE) / 12)
    return source
}

Очень важно, чтобы аудиофайл с сэмплом был правильно обрезан и имел адекватную нормализованную громкость. Например, изначально скачанный файл выглядел так:

Видите тишину в начале? Ее необходимо убрать, иначе каждая нота в вашем конечном звуке будет иметь задержку и прерывистость. Я отредактировал файл в Audacity, чтобы он выглядел так:

И вот теперь наконец-то, заменив осциллятор на аудио-дорожку в качестве источника первоначального звука мы можем насладиться звуком гитарной струны, обработанным всеми нашими эффектами: distortion, reverb и эквалайзерами:

Да, теперь это похоже на гитару. Теперь самое ожидаемое — воспроизведем power аккорд:

Now we're talking! Вот с таким звуком уже можно иметь дело. Заметили, как странный призвук в конце файла стал проигрываться три раза в разное время? Это отличная иллюстрация того, что playbackRate делает со звуком — он его замедляет или ускоряет во времени, ну и заодно, в качестве побочного эффекта (который в нашем конкретном случае не побочный, а очень даже основной) влияет на высоту звука. В последующем я этот хвост у аудио-дорожки на всякий случай обрезал, но он был забавным.

Теперь попробуем адаптировать эквалайзер под новый звук и получить все тот же punk riff generator, но уже с обновленным звуком. Признаюсь, я очень долго возился со звуком и не могу сказать, что добился идеального результата. Более того, я потом неоднократно эквализировал звук снова и снова, поэтому последующие звуковые примеры могут немного отличаться по звуку. Но в целом принципиальная схема всех Web Audio API нод в проекте стала выглядеть следующим образом:

А звучать генератор теперь стал вот так:

Ну как, дотянули мы до уровня Guitar Pro 5? Я считаю, что плюс-минус дотянули.

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

В последующем мы попробуем отвлечь внимание слушателя от роботической гитары. А пока мы этого не сделали, хочу показать нечто еще более роботическое. Я уже говорил, что экстремальные темпы в музыке начинаются где-то от 200 bpm и выше. Я как-то случайно опечатался, поставил темп на 1000 bpm и получил интересные результаты:

Любопытно, да? А как вам 10000 bpm?:

Я считаю, это новое слово в звуко-генерации.

Удобства

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

Останавливаем запись на replay

После того, как запускается воспроизведения oscillator-ноды или аудио-дорожки, она в последующем останавливается либо после того, как воспроизведется до самого конца, либо если у ноды явно вызвать node.stop(0).

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

Чтобы такого не было, каждую ноду-источник нужно запоминать в специальном массиве, по которому нужно в нужный момент, чтобы всем нодам вызывать stop(0):

let soundNodes = []

...

function playSound(notes, duration) {
    const startTime = context.currentTime + timeline
    const endTime = startTime + seconds

    for (let noteIndex = 0; noteIndex < notes.length; noteIndex++) {
        const sample = createSampleSource(notes[noteIndex])
        
        sample.connect(distortion)
        sample.start(startTime)
        sample.stop(endTime + DAMPING_DURATION)

        soundNodes.push(sample)
    }

    timeline += seconds
}

...

button.onclick = async () => {
    for (let i = 0; i < soundNodes.length; ++i) {
        soundNodes[i].stop(0)
    }
    soundNodes = []
    
	...
}

Да, иных механизмов сброса звука Web Audio API к сожалению не предоставляет.

Простой чейнинг эффектов

До этого момента мы соединяли ноды вызывая node1.connect(node2) столько раз подряд, сколько нам нужно соединений:

distortion.connect(cutHighs)
cutHighs.connect(gumDown)
gumDown.connect(cutSand)
cutSand.connect(cutSand2)
cutSand2.connect(boostLow)
boostLow.connect(peakMids)
peakMids.connect(reverb)
reverb.connect(gain)
gain.connect(context.destination)

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

-peakMids.connect(reverb)
+peakMids.connect(gain)

Запутаться очень легко.

Но если мы просто загоним все ноды в список, а потом их всех автоматически соединит for-цикл, мы получим очень удобный формат для манипуляций с нодами внутри списка:

async function init() {
    ...

    effectsChain = [
        distortion,
        cutHighs,
        gumDown,
        cutSand,
        cutSand2,
        boostLow,
        peakMids,
        reverb,
        gain,
        context.destination
    ]

    for (let i = 0; i < effectsChain.length - 1; ++i) {
        effectsChain[i].connect(effectsChain[i + 1])
    }
}

Исключение reverb из цепочки теперь выглядит вот так:

effectsChain = [
	distortion,
	cutHighs,
	gumDown,
	cutSand,
	cutSand2,
	boostLow,
	peakMids,
	//reverb,
	gain,
	context.destination
]

Барабаны

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

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

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

effectsChain = [
	distortion,
	cutHighs,
	gumDown,
	cutSand,
	cutSand2,
	boostLow,
	peakMids,
	guitarReverb,
	guitarGain,
	context.destination
]

drumsEffectsChain = [
	drumsReverb,
	drumsGain,
	context.destination
]

for (let i = 0; i < effectsChain.length - 1; ++i) {
	effectsChain[i].connect(effectsChain[i + 1])
}

for (let i = 0; i < drumsEffectsChain.length - 1; ++i) {
	drumsEffectsChain[i].connect(drumsEffectsChain[i + 1])
}

Заметим, что громкости гитары и барабанов индивидуальные, чтобы можно было тонко настраивать их громкость относительно друг друга. Эффект эха же наоборот — общий, чтобы не было каши из двух разных конфликтующих "окружений".

Слушаем результат:

Поздравляю, у нас рок.

Даем гитаре объем

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

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

Панорамирование создается достаточно просто — для этого у Web Audio API есть нода StereoPannerNode:

let panningLeft = context.createStereoPanner()
panningLeft.pan.setValueAtTime(-0.8, context.currentTime)

Здесь мы двумя строками кода вывели звук на 80% влево.

Более интересная задача — это как сделать левую и правую гитару так, чтобы они звучали слегка иначе ритмически. Для этого нам придется ввести некоторую погрешность в воспроизведении звука, чтобы сымитировать человеческий фактор — люди не могут играть как машины и придерживаться 100% верного ритма. Поэтому мы введем очень маленькую случайную задержку в воспроизведении каждого аккорда для каждой из гитар:

const GUITAR_PLAYING_ERRORS = 0.07
const randomFloat = (min, max) => Math.random() * (max - min) + min;

...

function playSound(notes, duration) {
	...

	for (let noteIndex = 0; noteIndex < notes.length; noteIndex++) {
        const sample = createGuitarSource(notes[noteIndex])

        sample.connect(guitarEffectsChain2[0])
        sample.start(startTime + randomFloat(0, GUITAR_PLAYING_ERRORS))
        sample.stop(endTime + randomFloat(0, GUITAR_PLAYING_ERRORS))

        soundNodes.push(sample)
    }
    
    ...
}

Т.е. начало и конец звука немного размазываются за счет случайно задержки. Наш виртуальный гитарист то слегка запаздывает, то наоборот, спешит. Не сильно, в рамках погрешности, едва заметно. Даже если GUITAR_PLAYING_ERRORS сделать катастрофически малой, эффект объема между левой и правой стороной будет сохраняться.

Еще я эквализировал правую гитару немного иначе, нежели левую, и к обеим гитарам применил разные формулы для distortion-преобразования. В итоге звук обогатился и стал разным — разница с предыдущим примером хорошо заметна:

Теперь я звуком очень даже доволен! Можно было бы попробовать еще лучше, но я предпочту остановиться в этой точке, ведь результат и так замечательный.

Вот так стала выглядеть принципиальная схема эффектов:

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

guitarEffectsChain = [
	distortion,
	cutHighs,
	gumDown,
	cutSand,
	cutSand2,
	boostLow,
	peakMids,
	panningLeft,
	guitarGain,
]

guitarEffectsChain2 = [
	distortion2,
	cutHighs2,
	cutSand2_2,
	cutSand22,
	boostLow2,
	peakMids2,
	panningRight,
	guitarGain,
]

guitarEffectsFinalChain = [
	guitarGain,
	reverb,
]

drumsEffectsChain = [
	drumsGain,
	reverb,
]

finalChain = [
	reverb,
	context.destination
]

for (const chain of [
	guitarEffectsChain,
	guitarEffectsChain2,
	guitarEffectsFinalChain,
	drumsEffectsChain,
	finalChain
]) {
	for (let i = 0; i < chain.length - 1; ++i) {
		chain[i].connect(chain[i + 1])
	}
}

Верстка приложения

Итак, я добился того функционала, какой изначально планировал. Мы с вами даже перевыполнили план и ввели разные красивости вроде барабанов и overdub-гитары, чем я очень доволен.

Однако на мне еще висела задача сделать репрезентативную веб-страницу, где Punk riff generator можно было бы пощупать в user-friendly манере. Сначала я сам пытался как-то верстать страницу. Но чтобы вы понимали, насколько я плохой дизайнер, моя версия страницы вышла такой:

Поэтому я обратился к профессионалу — моей жене, и она сделала убойный дизайн, радующий глаз, за что ей спасибо:

Макет был благополучно мною сверстан, и теперь результат всех наших с вами трудов можно лицезреть на демо-странице.

На этом наше маленькое приключение в мир музыки внутри браузера заканчивается, спасибо всем, кто осилил до конца. Возможно, когда-нибудь я обвешаю этот генератор дополнительными возможностями. А пока его главный смысл заключается в том, чтобы кликать кнопку play до тех пор, пока не вам не понравится какой-то рифф. После этого вы сможете записать его себе куда-нибудь и использовать в своих музыкальных изысканиях. В комментариях с удовольствием почитаю предложения, какие фичи можно было бы добавить в этот незамысловатый генератор. Те предложения, что покажутся мне хорошими и интересными, я запишу в "Deep Todo backlog" и, возможно, когда-нибудь реализую, когда у меня выдастся время. Ну и, конечно, вы всегда можете сделать форк GitHub-проекта или писать в Issues!

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 15: ↑15 и ↓0+19
Комментарии13

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань