Когда-то давно, может быть лет пять назад, мне захотелось воспроизвести в браузере звук. Уж не помню, какая конкретно у меня была задача, и чего я хотел добиться — скорее всего просто поиграться с разными семплами; может, запрограммировать трек. Пошел в гугл с вопросом, как это сделать, попал на 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)
}
Двухстрочный код, но из понятного — разве что присутствие синуса. И теперь, чтобы полностью разобраться в нем, нам нужна пояснительная бригада и нырок в теорию физики звука и тонкостей его оцифровки.
Физика (и математика) звука
Все мы слышали, что звук — это колебания, или волна с определенной частотой. Это, конечно, очень абстрактное объяснение, и у нас в голове остается за кадром много вопросов: почему именно волна, а что это конкретно за волна, а можно ли ее увидеть, потрогать?
Почему звук — волна, это в первую очередь вопросы к нашему уху. Барабанная перепонка улавливает колебания, передающиеся по воздуху (или воде) и умеет их интерпретировать, как некоторое ощущение, чувство — то, что мы называем слухом. Поэтому тут скорее справедливее сказать, что не звук — это волна; а волна, дошедшая до уха — это для мозга звук.
Как нам визуализировать такое колебание? Самый простой способ — представить себе график синуса; ну или косинуса, если хотите — они в разрезе нашей задачи не особо отличаются. Синус в каком-то смысле — пример чистейшего звука в вакууме: нота без тембра, окраса, примесей. Попробуем построить каноничную синусоиду :
Скорее всего вам этот график знаком еще со школы, и вы помните, что он бесконечно простирается влево и вправо по оси — тут у нас идет время в секундах. По оси — амплитуда колебания волны. Если совсем грубо, то ось можно считать за громкость сигнала. С этой амплитудой немного сложно, потому что непонятно, с чем соотнести ее величины, так что для простоты давайте будем считать, что если звук лежит в диапазоне , то это такой полновесный, эталонный по громкости звук. Буду признателен, если в комментариях кто-нибудь сможет как-то более формально охарактеризовать значения по оси .
График синусоиды пусть и бесконечен, но у него есть четко выраженный повторяющийся паттерн — период. Для период равен секунды:
И вот здесь с волной возникает проблема: если одно колебание этого сигнала длится секунд, следовательно частота такого сигнала равна . Человек же при этом способен услышать звук лишь в частотном диапазоне от 20Hz до 20000Hz в лучшем случае. Т.е. наш сигнал — это какой-то экстремальный случай инфразвука, и вряд ли какое-либо живое существо способно его услышать.
Что можно сделать, чтобы волна стала слышимой? Вообще синусоиду можно растягивать по обеим осям, формула синуса для этого слегка обогащается коэффициентами:
где
— скейл по оси . Чем больше, тем синусоида выше и, соответственно, громче.
— скейл по оси . Чем больше, тем период синусоиды короче, график сжатее, а частота выше (звук писклявее).
Для того, чтобы наш коэффициент можно было измерять сразу в герцах (то есть в колебаниях в секунду), чисто для удобства, его неплохо бы сделать кратным :
Вот теперь с этим можно работать. Посмотрим на пример с двумя синусоидами:
Что мы можем сказать о этих графиках, имея на руках ту теорию, что мы только что освоили? Сможем ли мы из любопытства подобрать для них точные формулы? Давайте начнем с простого — с амплитуды. Зеленый график имеет вертикальный диапазон , т.е. амплитуда равна единице, прямо как у обычной синусоиды без явных коэффициентов. Синий график сжат по вертикали, и его экстремумы лежат на -0.75 и 0.75, следовательно его .
Теперь посмотрим на горизонтальные свойства графиков. Можем обратить внимание на точку . В отрезке умещается ровно один период синего графика и четыре периода графика зеленого. Иными словами за 0.01 секунды две волны успевают совершить 1 и 4 колебания соответственно. Нехитрая математика подскажет, нам, что это равно и колебаниям в секунду соответственно.
И вот теперь мы знаем, как эти две синусоиды выглядят формульно:
Не забываем, что здесь делает — это период колебания, и он дает числам 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)
}
Последняя строчка теперь нам плюс-минус понятна, поскольку:
ожидание
реальность Math.sin(2 * Math.PI * tone * time)
tone
- в данном случае частота в герцах, time
— это наш , ведь не забываем, что ось отвечает за время. Но как мы сформировали переменную 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
. Собственно, чтобы выйти на время , мы должны знать номер сэмпла, для которого мы хотим взять значение нашей синусоиды: 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
, и в браузере будет слышен звук. Но я бы на вашем месте остерегался его слушать, поскольку здесь закрался нюанс. Громкость осциллятора очень высокая, потому что выдаваемый им сигнал лежит в знакомом нам диапазоне , который означает что-то вроде "полновесной громкости".
Громкость сигнала нужно значительно убавить, и сама нода осциллятора не дает никаких настроек для этого. Потому что за громкость отвечает отдельная нода GainNode
. У нее есть буквально одна настройка — gain
. Это громкость в диапазоне . Я остановился на .
Итого, схематично мы должны получить какой-то такой результат:
Выразим это в коде:
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 и проследовать за каждой нотой на нотном стане. Ставь класс, если вышло. А теперь разберемся, как, что и зачем здесь записано.
Начнем с любопытной записи после скрипичного ключа. Она означает, что под эту песню вы можете топать ногами, хлопать руками и циклично кричать "раз, два, три, четыре! раз, два, три, четыре!". И если вы поймаете грув и будете делать это в нужном темпе, то вам будет очень весело.
наверху как раз обозначает темп песни, который подскажет с какой скорость нужно хлопать и кричать. Темп измеряется в bpm или beats per minute (удары в минуту). Дословно 110 bpm нужно трактовать так: если непрерывно играть ноту длительностью ♩, то за одну минуту их будет проиграно ровно 110 штук.
Но кто такой этот ваш ♩?! Здесь проще будет сначала показать, как все типы нот умещаются в такт :
Итак, разные ноты по-разному помещаются в такт "один, два, три, четыре!". В первом такте мы видим целую ноту — она звучит весь такт, все 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 через преобразующую математическую функцию, которая была бы (осторожно, математический сленг!):
гладкой
монотонной
возрастающей
нелинейной
Почему так, рассказано, например, в этой статье. В этой же статье дана подсказка, что таким характеристикам отлично соответствует сигмоид или сигмоида. Это целое семейство математических функций, которые описываются совершенно разными формулами, но всех их объединяет одно — сходство графика с буквой .
Выбирайте, какая сигмоида вам больше по душе:
Какой бы сложности формулу вы не выбрали, их графики будут выглядеть достаточно схоже, разница лишь в нюансах формы и поведения при изменении переменной :
Как нам применить эти преобразующие функции на наш сигнал? Хотелось бы получить какую-то формулу, график которой мы могли бы отобразить и ради интереса посмотреть, что происходит с сигналом при наложении эффекта distortion.
Ради упрощения давайте представим, что мы имеем дело со звуком, который представлен каноничной синусоидой . В таком случае нам нужно каким-то образом наложить на синусоиду сигмоидное преобразование. Звучит страшно. Забористая википедийная алгебра подсказывает нам, что итоговую формулу искаженного сигнала можно получить через композицию двух функций: в нашем случае сигмоида и синуса, и математически это записывается как , если принять сигмоиду за . Тут же в Википедии нас успокаивают, что запись с кружочком эквивалентна . А это уже лично мне понятно: где раньше в формуле сигмоиды был , теперь должна быть формула нашего сигнала, т.е. . Делаем простейшие подстановки и получаем искомое искажение синусоидного сигнала:
Вот мы и увидели distortion наяву — он оказался синусоидой, стремящейся к периодической прямоугольной функции.
Web Audio API предоставляет ноду WaveShaperNode
, которая позволяет применить к любому сигналу преобразовательную функцию. С ее помощью мы и будем искажать звук, выдаваемый осцилляторами.
Ноде можно задать любой график в дискретном виде: массивом Float32Array
. Работает это так:
График всегда расценивается как функция с , лежащим в диапазоне
Массив
Float32Array
при этом может быть любой длины. Чем длиннее массив, тем больше частота дискретизации вашего преобразующего графика. Нода будет расценивать первый элемент массива, как , средний элемент как , а последний какСами числа в массиве — значения по оси
Нода сама применит этот преобразующий график к тому сигналу, который ей придет. Точно так же, как мы с вами вручную применили сигмоиду к синусоидальному сигналу на графике
В цепочку из нод должна будет вклиниться еще одна нода:
Я остановился на сигмоиде вида
и сделал функцию, которая возвращает преобразование в том виде, который нужен ноде 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 сэмпла, чтобы добиться любой желаемой ноты:
где:
— нота, которую мы хотим сыграть,
— нота, звучащая в сэмпле инструмента
— помним, что нот в октаве 12, и они равномерно отстоят друг от друга. Потому и делим на 12
И главное, что нам нужно иметь в виду — и не будут измеряться в герцах, это просто порядковые номера нот, идущих друг за другом и равномерно отстоящих друг от друга.
А где же тогда будут герцы? А они нам больше в явном виде не понадобятся, ровно как и осцилляторы — теперь мы будем брать звучание гитары из аудиофайла и ориентироваться на него как на эталонную ноту.
Какой аудиофайл нам нужен? Я решил, что для того чтобы одним файлом со звучанием одной единственной струны можно было сносно покрыть весь гитарный диапазон, я за основу возьму звучание открытой четвертой струны.
Почему именно четвертой? Это сама тонкая басовая струна, покрытая оплеткой. Три из четырех струн, на которых играются 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!