. Создание синтезатора на JavaScript
Создание синтезатора на JavaScript

Создание синтезатора на JavaScript

Идея сделать браузерный синтезатор у меня появилась достаточно давно, ещё когда Audio API был в весьма зачаточном состоянии и практически единственным шансом извлечь звук из браузера (кроме воспроизведения готовых файлов) была генерация WAV с его последующей кодировкой в base64 и записью в аудио-тег. И если синтез и кодирование удавались без проблем (WAV формат довольно прост), то с потоковым аудио для музицирования в реальном времени всё было хуже и никакими ухищрениями не удавалось добиться бесшовной буферизации, в связи с чем идея и заглохла, так не успев родиться. За прошедшие годы браузеры в поддержке Audio API заметно прибавили, что в свою очередь вдохновило меня на новые эксперименты в этой области. В данной статье шаг за шагом описывается процесс создания браузерного синтезатора средствами HTML5, начиная с генерации простой синусоиды, продолжая коммутацией и модуляцией сигналов и заканчивая аудиоэффектами. Как хобби-музыканта, но фулл-тайм программиста, меня часто настигают музыкальные идеи прямо на работе, когда под рукой нет музыкального инструмента, чтобы прикинуть реализацию, а в идеале ещё и записать. Таким образом сначала родилась идея онлайн-MIDI-секвенсера, который бы позволил набросать и сохранить большинство идей. Но какой же секвенсер без возможности наиграть и записать пришедшую в голову мелодию в реальном времени «не отходя от кассы», используя хотя бы мышь с клавиатурой? Как следствие в процессе работы над простейшим синтезатором закралась мыслишка, а не замахнуться ли нам на что-нибудь покрупнее. Конечно, идея JavaScript-синтезатора не нова и реализации разной степени убедительности то и дело возникали то тут, то там, но по крайней мере здесь, на хабре, я нашел всего лишь несколько статей по теме Audio API и ни одной, касающейся синтеза, что и побудило сесть за данный текст.

Как было кратко отмечено в предисловии, первым делом мне подумалось о синтезе и обработке звука полностью аналитически с последующим кодированием напрямую в формат WAV, однако в процессе борьбы с потоковым воспроизведением данного формата и прочёсывания документации по смежным темам вдруг пришла мысль попробовать, так ли хороша реализация Audio API браузерами, как это описывают в MDN. Audio API без лищних ухищрений и минимальными средствами даёт возможность создания виртуального аудио-тракта из функциональных блоков, настраивая и коммутируя их на свой вкус. Практически все нужные базовые элементы представлены в API: осцилляторы, усилители, разветвители и прочее, для ознакомления с полным списком и примерами использования рекомендую обратиться к соответствующему разделу MDN, таким образом главный вопрос состоит в правильной и удобной коммутации и создании и управлении эффектами.

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

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

У нас же это будет выглядеть так:

Начнём с простого синтезирования звука, для чего сконструируем объект AudioContext, в рамках которого созданим осциллятор, зададим ему частоту, подключим к аудиовыходу и затем заставим осциллировать, выдавая тем самым звук.

Если всё сделано правильно, в колонках/наушниках должен раздаться приятный звук всем знакомой частоты телефонного гудка (ну или камертона — тут уже кому что ближе). Остановить этот процесс можно вызовом соответствующего медота .stop() того же объекта. Кстати, параметр, передающийся функциям start и stop это время в секундах, по истечению которого указанные действия должны осуществиться по отношению к сигналу. Нам это пока не нужно, поэтому параметр выставляем в 0, а можно вообще не указывать. Необходимо также обратить внимание на методы .connect() и .disconnect(), которые являются частью общего для всех узлов интерфейса AudioNode и служат для коммутации их входов и выходов. Вызывая .connect() осциллятора, мы указываем направлять получившийся аудиосигнал переданному в качестве параметра узлу, в данном случае это audioContext.destination, который с точки зрения нашей программы является конечным пунктом, направляющий звук в операционную систему для дальнейшего воспроизведения.

В качестве первой функции нашего синтезатора реализуем выбор формы волны. Наиболее ходовые формы волн (например пила или меандр) доступны как часть API и могут быть использованы путём указания соответствующего параметра для осциллятора (напр. audioContext.createOscillator('square')). Для случаев поинтереснее существует интерфейс PeriodicWave, позволяющий задать форму волны произвольной формы. На вход функции передаются два массива с коэффициентами Фурье, процесс вычисления которых для сигнала произвольной формы без труда можно найти в литературе (например, кратко здесь). Так, скажем, для всё той же пилообразной волны, представляющей собой сумму всех гармоник сигнала с пропорциональным убыванием амплитуды, коэффициенты при косинусах (действительные в комплексной записи) будут 0, а при синусах (мнимые) 1/пn, т. е. функция может выглядеть вот так:

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

Следовательно рассчёт коэффициентов и передача их PeriodicWave для данного сигнала будет выглядеть следующим образом:

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

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

Одной из базовых функций, без которых нельзя представить ни один прибор, предназначенный издавать звуки, является регулировка громкости и баланса каналов, для этого в Audio API предусмотрены интерфейсы GainNode и StereoPanner соответственно. Добавим в цепь их, коммутируя при помощи всё того же метода connect:

Для регулировки параметров создадим два поля ввода и напрямую подключим их к соответствующим узлам. Для чтения и передачи значений я создал простой объект controls, реализующий паттерн медиатор и рассылающий заинтересованным значения полей при их изменении. На его реализации останавливаться нет смысла, сконцентрируемся лучше на том, что происходит при изменении значения в каком-нибудь поле:

Думая наперёд о разработке эффекта вибрато, а также об управлении высотой тона посредством физического рычага на MIDI-устройстве, добавим возможность изменить высоту тона для всех генерируемых модулем Synth звуков. Функция реализована примесью к исходному модулю:

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

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

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

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

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

Принимая во внимания вышеозначенные условия, реализуем необходимый нам модулятор:

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

Запускаем и анализируем получившийся сигнал, констатируем наличие вибрации. Эффект достигнут:

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

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

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

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

На этом эксперименты с модуляциями можем считать успешными, а эффекты реализованными. Хотя модуляции по сигналу произвольного вида и можно добиться сложением синусоид, в перспективе было бы значительно удобнее иметь набор заготовленных модуляторов, как минимум для наиболее часто используемых форм волны (пила, меандр), для данного сценария можно как создать набор конструкторов по аналогии с уже имеющимся SineModulator, так и использовать механизм задания формы волны через коэффициенты Фурье, применённый нами при указании формы волны осциллятора. Задача эта уже не имеет прямого отношения к Audio API, поэтому пока предлагаю завершить эту тему и перейти к реализации первого немодулирующего эффекта, а именно эха.

В большинстве случаев данный эффект характеризуется тремя параметрами: количество откликов, время отклика и коэффициент затухания. Работа с количеством откликов отличным отличным от нуля и одного подразумевает ветвление сигнала и создание линий задержки для каждой ветви. Линия задержки представляет собой узел, задерживающий прохождение сигнала на определённый промежуток времени. Мы воспользуемся линиями задержки, предоставленными Audio API и создающимися функцией AudioContext.createDelay. Наличие коэффициента затухания превращает каждую из ветвей в цепь линия задержки — усилитель. Кроме того, нам наобходимо переключение между чистым сигналом и сигналом с эффектом, а также обеспечение возможности простой коммутации с предыдущими и последующими звеньями тракта (помним о договорённости иметь один вход и один выход), что в конечном итоге выливается в следующую схему:

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

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

Достигнутую вот таким кодом:

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

Побороть этот эффект в частности, но по своей основной функции придать динамики, нам поможет так называемая ADSR-огибающая (Attack-Decay-Sustain-Release), которая характеризует форму синтезированной волны во времени, приближённо описывая поведение звука, взятого на настоящем музыкальном инструменте.

Применяя такую огибающую к каждой воспроизводимой ноте, в результате плавного нарастания и затухания громкости, мы убираем скачкообразный срыв с вертикальным фронтом, которые и воспринимается ухом как высокочастотный щелчок. Реализация, как и в случае с pitch shift – примесью непосредственно к синтезатору. При создании каждого из осцилляторов мы вклиниваем усилитель между ним и выходным узлом, впоследствии управляя коэффициентом усиления в соответствии с заданными параметрами ADSR-огибающей:

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

📎📎📎📎📎📎📎📎📎📎