[Из песочницы] Создание синтезатора на JavaScript
Идея сделать браузерный синтезатор у меня появилась достаточно давно, ещё когда Audio API был в весьма зачаточном состоянии и практически единственным шансом извлечь звук из браузера (кроме воспроизведения готовых файлов) была генерирация WAV с его последующей кодировкой в base64 и записью в аудио-тег. И если синтез и кодирование удавались без проблем (WAV формат довольно прост), то с потоковым аудио для музицирования в реальном времени всё было хуже и никакими ухищрениями не удавалось добиться бесшовной буферизации, в связи с чем идея и заглохла, так не успев родиться. За прошедшие годы браузеры в поддержке Audio API заметно прибавили, что в свою очередь вдохновило меня на новые эксперименты в этой области. В данной статье шаг за шагом описывается процесс создания браузерного синтезатора средствами HTML5, начиная с генерации простой синусоиды, продолжая коммутацией и модуляцией сигналов и заканчивая аудиоэффектами.
Как хобби-музыканта, но фулл-тайм программиста, меня часто настигают музыкальные идеи прямо на работе, когда под рукой нет музыкального инструмента, чтобы прикинуть реализацию, а в идеале ещё и записать. Таким образом сначала родилась идея онлайн-MIDI-секвенсера, который бы позволил набросать и сохранить большинство идей. Но какой же секвенсер без возможности наиграть и записать пришедшую в голову мелодию в реальном времени «не отходя от кассы», используя хотя бы мышь с клавиатурой? Как следствие в процессе работы над простейшим синтезатором закралась мыслишка, а не замахнуться ли нам на что-нибудь покрупнее. Конечно, идея JavaScript-синтезатора не нова и реализации разной степени убедительности то и дело возникали то тут, то там, но по крайней мере здесь, на хабре, я нашел всего лишь несколько статей по теме Audio API и ни одной, касающейся синтеза, что и побудило сесть за данный текст.
Как было кратко отмечено в предисловии, первым делом мне подумалось о синтезе и обработке звука полностью аналитически с последующим кодированием напрямую в формат WAV, однако в процессе борьбы с потоковым воспроизведением данного формата и прочёсывания документации по смежным темам вдруг пришла мысль попробовать, так ли хороша реализация Audio API браузерами, как это описывают в MDN. Audio API без лищних ухищрений и минимальными средствами даёт возможность создания виртуального аудио-тракта из функциональных блоков, настраивая и коммутируя их на свой вкус. Практически все нужные базовые элементы представлены в API: осцилляторы, усилители, разветвители и прочее, для ознакомления с полным списком и примерами использования рекомендую обратиться к соответствующему разделу MDN, таким образом главный вопрос состоит в правильной и удобной коммутации и создании и управлении эффектами.
Для первой версии необходимо определимся с минимумом функциональности, которую мы хотели бы реализовать. Как по мне, абсолютно необходимым является выбор формы волны (синусоида, пилообразная, меандр), а также такие эффекты, как вибрато (колебание тона), тремоло (колебание громкости) и эхо. Кроме этого, из классических функций хотелось бы видеть настройку ADSR-огибающей, представляющую собой упрощенную аппроксимацию фаз интенсивности звучания ноты, сыгранной на реальном музыкальном инструменте.
Учитывая то, что тремоло и вибрато являются модулирующими эффектами (амплитуды и высоты тона соответственно), реализация универсального модулятора для любого из параметров системы представляется наиболее естественным и гибким решением. Вся соль модуляторов состоит в том, что ими можно влиять не только напрямую на параметры сигнала, но и на параметры других себеподобных модуляторов, получая ограниченные только фантазией возможности для формирования звуков. В своё время эта идея также лежала в основе аналоговых синтезаторов, одако тогда коммутация выглядела так:
У нас же это будет выглядеть так:
synth.connect(volume);
volume.connect(delay.input);
delay.connect(pan);
pan.connect(audioCtx.destination);
var vibrato = new SineModulator();
vibrato.modulate(synth, 'pitchShift');
Начнём с простого синтезирования звука, для чего сконструируем объект AudioContext, в рамках которого созданим осциллятор, зададим ему частоту, подключим к аудиовыходу и затем заставим осциллировать, выдавая тем самым звук.
audioContext = new AudioContext();
var oscillator = audioContext.createOscillator() ;
oscillator.frequency.value = 440;
oscillator.connect(audioContext.destination);
oscillator.start(0);
Если всё сделано правильно, в колонках/наушниках должен раздаться приятный звук всем знакомой частоты телефонного гудка (ну или камертона — тут уже кому что ближе). Остановить этот процесс можно вызовом соответствующего медота .stop() того же объекта. Кстати, параметр, передающийся функциям start и stop это время в секундах, по истечению которого указанные действия должны осуществиться по отношению к сигналу. Нам это пока не нужно, поэтому параметр выставляем в 0, а можно вообще не указывать. Необходимо также обратить внимание на методы .connect() и .disconnect(), которые являются частью общего для всех узлов интерфейса AudioNode и служат для коммутации их входов и выходов. Вызывая .connect() осциллятора, мы указываем направлять получившийся аудиосигнал переданному в качестве параметра узлу, в данном случае это audioContext.destination, который с точки зрения нашей программы является конечным пунктом, направляющий звук в операционную систему для дальнейшего воспроизведения.
В качестве первой функции нашего синтезатора реализуем выбор формы волны. Наиболее ходовые формы волн (например пила или меандр) доступны как часть API и могут быть использованы путём указания соответствующего параметра для осциллятора (напр. audioContext.createOscillator('square')). Для случаев поинтереснее существует интерфейс PeriodicWave, позволяющий задать форму волны произвольной формы. На вход функции передаются два массива с коэффициентами Фурье, процесс вычисления которых для сигнала произвольной формы без труда можно найти в литературе (например, кратко здесь). Так, скажем, для всё той же пилообразной волны, представляющей собой сумму всех гармоник сигнала с пропорциональным убыванием амплитуды, коэффициенты при косинусах (действительные в комплексной записи) будут 0, а при синусах (мнимые) 1/пn, т. е. функция может выглядеть вот так:
Естественно, для большей остроты пилы необходимо увеличить число складываемых гармоник. Процесс проиллюстрирован на анимации:
Следовательно рассчёт коэффициентов и передача их PeriodicWave для данного сигнала будет выглядеть следующим образом:
var context = new global.AudioContext();
var steps = 128;
var imag = new global.Float32Array(steps);
var real = new global.Float32Array(steps);
for (var i = 1; i < steps; i++) {
imag[i] = 1 / (i * Math.PI);
}
var wave = context.createPeriodicWave(real, imag);
module.exports = wave;
Следующим шагом осуществим возможность выбора высоты звучания. В настоящих синтезаторах это делается посредством клавиатуры, мы же сконструируем для начала эрзац-клавиатуру на экране монитора. Я не дизайнер и сделать качественный дизайн всё равно не смог бы, поэтому всю графику я рисую от руки, чтобы замаскировать скудость своей дизайнерской мысли. В итоге имеем клавиатуру с базовыми функциями, а также три формы волны на выбор:
Каждое нажатие клавиши должно отправить сигнал синтезатору с информацией о порядковом номере ноты, которую необходимо сыграть. То же действительно и для события отпускания клавиши. Хоть классические синтезаторы и имели поначалу один осциллятор, модулируемый по высоте тона нажатием клавиши, ввиду отсутствия технических ограничений я решил не усложнять жизнь и сделать столько осцилляторов, сколько нужно для одновременного воспроизведения произвольного количества нот. Для удобной коммутации с последующими модулями договоримся иметь один общий выходной узел, к которому и будут подключены все осцилляторы и который будет смешивать все их сигналы в один поток. Правило одной точки входа и одной точки выхода в дальнейшем будем применять для всех последующих модулей. В качестве такой точки применяется самый простой узел GainNode с коэффициэнтом усиления 1 (по умолчанию). Схематически это выглядит так:
А в коде так:
function Synth(context) {
this.audioContext = context;
this.output = context.createGain();
this._oscillators = {};
}
Synth.prototype.play = function(note) {
var oscillator;
oscillator = this._oscillators[note.pitch] = this.audioContext.createOscillator();
oscillator.frequency.value = note.frequency;
oscillator.connect(this.output);
oscillator.start(0);
return oscillator;
};
Synth.prototype.stop = function(note) {
this._oscillators[note.pitch].stop(0);
};
Одной из базовых функций, без которых нельзя представить ни один прибор, предназначенный издавать звуки, является регулировка громкости и баланса каналов, для этого в Audio API предусмотрены интерфейсы GainNode и StereoPanner соответственно. Добавим в цепь их, коммутируя при помощи всё того же метода connect:
var audioContext = new AudioContext();
var volume = audioContext.createGain();
var pan = audioContext.createStereoPanner();
volume.gain.value = 1;
pan.pan.value = 0;
synth.output.connect(volume);
volume.connect(pan);
pan.connect(audioContext.destination);
Для регулировки параметров создадим два поля ввода и напрямую подключим их к соответствующим узлам. Для чтения и передачи значений я создал простой объект controls, реализующий паттерн медиатор и рассылающий заинтересованным значения полей при их изменении. На его реализации останавливаться нет смысла, сконцентрируемся лучше на том, что происходит при изменении значения в каком-нибудь поле:
controls.on('volume-change', function(value) {
volume.gain.value = value;
});
controls.on('pan-change', function(value) {
pan.pan.value = value;
});
Думая наперёд о разработке эффекта вибрато, а также об управлении высотой тона посредством физического рычага на MIDI-устройстве, добавим возможность изменить высоту тона для всех генерируемых модулем Synth звуков. Функция реализована примесью к исходному модулю:
function PitchShifter() {
this._pitchShift = 0;
var oscillators = {};
Object.defineProperty(this, "pitchShift", {
set: function (ps) {
this._pitchShift = ps;
for(var pitch in oscillators) {
oscillators[pitch].frequency.value =
oscillators[pitch].baseFrequency * Math.pow(2, this._pitchShift/1200);
}
},
get: function() {
return this._pitchShift;
}
});
var old = {
play: this.play,
stop: this.stop
};
this.play = function(note) {
var osc = oscillators[note.pitch] = old.play.call(this, note);
osc.baseFrequency = note.frequency;
osc.frequency.value = osc.baseFrequency * Math.pow(2, this._pitchShift/1200);
return osc;
};
this.stop = function(note) {
delete oscillators[note.pitch];
old.stop.apply(this, arguments);
};
}
Вот мы и приблизились к реализации первого эффекта — вибрато, т.е. периодического изменения тона по высота. Для данных целей, как было упомянуто, состряпаем модулятор, причём, как и планировалось, модулятор должен быть реализован таким образом, чтобы он мог модулировать любой параметр системы, включая свойства других модуляторов, такие как амплитуда и частота. Первой проблемой, с которой есть риск столкнуться при изменений частоты «в лоб», является скачок между уровнями сигнала, который, к сожалению, так же отчётливо слышно, как и видно на следующей иллюстрации:
Причём разность в фазах предыдущей и последующей синусоиды зависит от текущего момента времени, что делает его совсем уж неприглядным. Для избежания этого нежелательного эффекта необходимо при изменении частоты сжимать или растягивать синусоиду не относительно нуля, а относительно текущей точки по оси X, для этого фаза сигнала будет постоянно храниться в объекте и использоваться в качестве точки отсчёта при вычислении дальнейших значений амплитуды.
Вторым важным моментом при реализации модуляторов является их аддитивность. Поскольку теоретически форма волны, получаемая в результате сложения произвольного количества синусоид не имеет ограничений, имея модуляторы, удовлетворяющие условию аддитивности, мы получаем дополнительный простор для творчества.
Принимая во внимания вышеозначенные условия, реализуем необходимый нам модулятор:
function SineModulator (options) {
options = options || {};
this._frequency = options.frequency || 0;
this._phaseOffset = 0;
this._startedAt = 0;
this._interval = null;
this._prevValue = 0;
this.depth = options.depth || 0;
Object.defineProperty(this, "frequency", {
set: function (frequency) {
frequency = parseFloat(frequency);
this._phaseOffset = this._phaseNow();
this._startedAt = Date.now();
this._frequency = frequency;
},
get: function() {
return this._frequency;
}
});
}
SineModulator.prototype.modulate = function(object, property) {
this._objToModulate = object;
this._propertyToModulate = property;
};
SineModulator.prototype.start = function() {
this._startedAt = Date.now();
var this_ = this;
this._interval = setInterval(function() {
var value = this_._modValueNow();
var diff = value - this_._prevValue;
this_._objToModulate[this_._propertyToModulate] += diff;
this_._prevValue = value;
}, 10);
};
SineModulator.prototype._phaseNow = function() {
var timeDiff = (Date.now() - this._startedAt) / 1000;
var phase = this._phaseOffset + timeDiff * this.frequency % 1;
return phase;
};
SineModulator.prototype._modValueNow = function() {
var phase = this._phaseNow();
return Math.sin((phase) * 2 * Math.PI) * this.depth;
};
SineModulator.prototype.stop = function() {
clearInterval(this._interval);
}
module.exports = SineModulator;
Теперь, имея модулятор, попробуем осуществить циклическое изменение высоты тона, одновременно добавив в интерфейс поля ввода параметров эффекта и привязав их к соответствующим свойствам модулятора.
var vibrato = new SineModulator();
vibrato.modulate(synth, 'pitchShift');
controls.on('vibrato-on-change', function(value) {
parseInt(value) ? vibrato.start() : vibrato.stop();
});
controls.on('vibrato-depth-change', function(value) {
vibrato.depth = value;
});
controls.on('vibrato-freq-change', function(value) {
vibrato.frequency = value;
});
Запускаем и анализируем получившийся сигнал, констатируем наличие вибрации. Эффект достигнут:
Следующий пункт в списке — тремоло — будет аналогичен по принципу, отличием является лишь модулируемый параметр, на этот раз это громкость. Код для этого эффекта получается так же минималистичен и практически идентичен:
var tremolo = new SineModulator();
tremolo.modulate(volume.gain, 'value');
controls.on('tremolo-on-change', function(value) {
parseInt(value) ? tremolo.start() : tremolo.stop();
});
controls.on('tremolo-depth-change', function(value) {
tremolo.depth = value;
});
controls.on('tremolo-freq-change', function(value) {
tremolo.frequency = value;
});
Имея две данных модуляции и совмещая, или же наоборот разводя, их частоты, уже можно получить достаточно интересные по звучанию эффекты, а делая периоды взаимопростыми, а амплитуты широкими, даже застать врасплох неподготовленного слушателя эффектом хаоса и непредсказуемости!
Следующим логическим шагом будет попытка проследовать уровнем выше и промодулировать один из параметров какого-нибудь из имеющихся модуляторов, например, частоту вибрато, в результате чего должен получиться эффект игры скоростью вибрации. Для достижения этого создадим два модулятора и назначим одному из них в качестве модулируемого параметра свойство frequency второго:
var vibrato = new SineModulator();
vibrato2.modulate(synth, 'pitchShift')
vibrato2.frequency = 5;
vibrato2.depth = 50;
vibrato.modulate(synth, 'pitchShift');
vibrato.start();
vibrato2 = new SineModulator();
vibrato2.modulate(vibrato, 'frequency');
vibrato2.frequency = 0.2;
vibrato2.depth = 3;
vibrato2.start();
Циклическое изменение частоты изменения тона слышно невооружённым ухом, получившийся сигнал, записанный и открытый в аудиоредакторе, выглядит соответствующе (участкам с большей плотностью соответствует звук с более высокой частотой):
На этом эксперименты с модуляциями можем считать успешными, а эффекты реализованными. Хотя модуляции по сигналу произвольного вида и можно добиться сложением синусоид, в перспективе было бы значительно удобнее иметь набор заготовленных модуляторов, как минимум для наиболее часто используемых форм волны (пила, меандр), для данного сценария можно как создать набор конструкторов по аналогии с уже имеющимся SineModulator, так и использовать механизм задания формы волны через коэффициенты Фурье, применённый нами при указании формы волны осциллятора. Задача эта уже не имеет прямого отношения к Audio API, поэтому пока предлагаю завершить эту тему и перейти к реализации первого немодулирующего эффекта, а именно эха.
В большинстве случаев данный эффект характеризуется тремя параметрами: количество откликов, время отклика и коэффициент затухания. Работа с количеством откликов отличным отличным от нуля и одного подразумевает ветвление сигнала и создание линий задержки для каждой ветви. Линия задержки представляет собой узел, задерживающий прохождение сигнала на определённый промежуток времени. Мы воспользуемся линиями задержки, предоставленными Audio API и создающимися функцией AudioContext.createDelay. Наличие коэффициента затухания превращает каждую из ветвей в цепь линия задержки — усилитель. Кроме того, нам наобходимо переключение между чистым сигналом и сигналом с эффектом, а также обеспечение возможности простой коммутации с предыдущими и последующими звеньями тракта (помним о договорённости иметь один вход и один выход), что в конечном итоге выливается в следующую схему:
К сожалению, я не нашёл способа создавать элементы, которые бы полноценно реализовывали интерфейс AudioNode и которые можно было бы напрямую использовать в качестве параметров для метода connect других узлов. Поиск в интернете результата также не дал, поэтому в итоге я последовал совету, данному предположительно знающими людьми в интернете, суть которого сводится к тому, что объект является контейнером для совокупности стандартных узлов, причём подключение к входу осуществляется не напрямую, а через свойство input, являющееся базовым узлом GainNode.
Тесты, реализация, запускаем. Имеем волну следующей формы:
Достигнутую вот таким кодом:
function Delay(audioCtx) {
this._audioCtx = audioCtx;
this.input = audioCtx.createGain();
this._delayLines = [];
this._gainNodes = [];
this._delayLinesInput = audioCtx.createGain();
this._output = audioCtx.createGain();
this._taps = 0;
this._latency = 0;
this._feedback = 0;
Object.defineProperty(this, "feedback", {
set: function (freq) {
this._feedback = freq;
this._applyParams();
},
get: function() {
return this._feedback;
}
});
Object.defineProperty(this, "latency", {
set: function (freq) {
this._latency = freq;
this._applyParams();
},
get: function() {
return this._latency;
}
});
Object.defineProperty(this, "taps", {
set: function (value) {
var prevTaps = this._taps;
var diff = value - this._taps;
for(var i = 0; i < diff; i++) {
diff < 0 ? this._popTap() : this._pushTap();
}
this._taps = value;
},
get: function() {
return this._taps;
}
});
this.input.connect(this._output);
}
Delay.prototype._applyParams = function() {
for(var i = 0; i < this._delayLines.length; i++) {
this._delayLines[i].delayTime.value = this._latency / 1000 * (i + 1);
this._gainNodes[i].gain.value = Math.pow(this._feedback, (1 + i))
}
};
Delay.prototype._pushTap = function() {
var delay = this._audioCtx.createDelay(10.0);
this._delayLines.push(delay);
var gainNode = this._audioCtx.createGain();
this._gainNodes.push(gainNode);
gainNode.connect(this._output);
delay.connect(gainNode);
this._delayLinesInput.connect(delay);
};
Delay.prototype._popTap = function() {
var lastDelayLine = this._delayLines.pop();
var lastGainNode = this._gainNodes.pop();
lastDelayLine.disconnect(lastGainNode);
lastGainNode.disconnect(this._output);
this._delayLinesInput.disconnect(lastDelayLine);
};
Delay.prototype.start = function() {
if (!this._started) {
this.input.connect(this._delayLinesInput);
this._started = true;
}
}
Delay.prototype.stop = function() {
if (this._started) {
this.input.disconnect(this._delayLinesInput);
this._started = false;
}
};
Delay.prototype.connect = function(target) {
this._output.connect(target);
};
module.exports = Delay;
Прогресс налицо: получающиеся в результате применения эффектов звуки в значительной мере обладают музыкальностью и духом старой школы. Единственным моментом, режущим слух, являются неэстетичные щелчки при выключении осцилляторов, они видны на предыдущем графике и особенно бросаются они в уши при применении эха, поскольку в таком случае каждый из них повторяется многократно, к тому же во время звучания других нот.
Побороть этот эффект в частности, но по своей основной функции придать динамики, нам поможет так называемая ADSR-огибающая (Attack-Decay-Sustain-Release), которая характеризует форму синтезированной волны во времени, приближённо описывая поведение звука, взятого на настоящем музыкальном инструменте.
Применяя такую огибающую к каждой воспроизводимой ноте, в результате плавного нарастания и затухания громкости, мы убираем скачкообразный срыв с вертикальным фронтом, которые и воспринимается ухом как высокочастотный щелчок. Реализация, как и в случае с pitch shift – примесью непосредственно к синтезатору. При создании каждого из осцилляторов мы вклиниваем усилитель между ним и выходным узлом, впоследствии управляя коэффициентом усиления в соответствии с заданными параметрами ADSR-огибающей:
function ADSR() {
this.ADSR = {
A: null,
D: null,
S: null,
R: null
};
var oscillators = {};
var gainNodes = {};
var old = {
play: this.play,
stop: this.stop
};
this.play = function(note) {
var osc = oscillators[note.pitch] = old.play.call(this, note);
var gain = gainNodes[note.pitch] = this.audioContext.createGain();
osc.disconnect(this.output);
osc.connect(gain);
gain.connect(this.output);
gain.gain.value = 0;
this.ADSR.A = parseInt(this.ADSR.A);
this.ADSR.D = parseInt(this.ADSR.D);
this.ADSR.S = parseFloat(this.ADSR.S);
this.ADSR.R = parseInt(this.ADSR.R);
var this_ = this;
var startedAt = Date.now();
var interval = setInterval(function() {
var diff = Date.now() - startedAt;
if (diff < this_.ADSR.A) {
gain.gain.value = diff / this_.ADSR.A;
} else if (diff < this_.ADSR.A + this_.ADSR.D) {
gain.gain.value = 1 - (diff - this_.ADSR.A) / (this_.ADSR.D / (1 - this_.ADSR.S));
} else {
gain.gain.value = this_.ADSR.S;
clearInterval(interval);
}
}, 10);
return osc;
};
this.stop = function(note) {
var releasedAt = Date.now();
var this_ = this;
var arguments_ = arguments;
var gain = gainNodes[note.pitch];
var osc = oscillators[note.pitch];
var gainOnRelease = gain.gain.value;
var interval = setInterval(function() {
var diff = Date.now() - releasedAt;
if (diff < this_.ADSR.R) {
gain.gain.value = gainOnRelease * (1 - diff / this_.ADSR.R);
} else {
clearInterval(interval);
gain.gain.value = 0;
old.stop.apply(this_, arguments_);
osc.disconnect(gainNodes[note.pitch]);
gain.disconnect(this.output);
delete oscillators[note.pitch];
delete gain[note.pitch];
}
}, 20);
};
}
module.exports = ADSR;
Итак, внеся этот штрих, мы получили вполне функциональный базовый синтезатор, генерирующий звуки, пригодные для прослушивания человеком. Дальнейшими шагами по улучшению могли бы стать такие интерфейсные изменения, как, например визуальное создание, настройка и коммутация осцилляторов и модуляторов, а касаемо непосредственно синтеза — добавление фильтров, внесение гармоник, нелинейных искажений и прочего, однако это уже тема для дальнейших изысканий. В следующих статьях планируется подключить к получившемуся синтезатору MIDI-инструменты, в частности клавиатуру и гитару, а также перейти к записи звука и настоящим аудио-эффектам. Всё это, конечно же, в браузере!
Демонстрация работы программы доступна по следующей ссылке: miroshko.github.io/Synzer
Весь исходный код доступен на github: github.com/miroshko/Synzer, буду рад звёздочкам, форкам и пулл-реквестам.