Pull to refresh

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

Reading time 15 min
Views 30K


Идея сделать браузерный синтезатор у меня появилась достаточно давно, ещё когда 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 звуков. Функция реализована примесью к исходному модулю:

Код PitchShifter'а
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); 
  }; 
}

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

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

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



Причём разность в фазах предыдущей и последующей синусоиды зависит от текущего момента времени, что делает его совсем уж неприглядным. Для избежания этого нежелательного эффекта необходимо при изменении частоты сжимать или растягивать синусоиду не относительно нуля, а относительно текущей точки по оси 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, буду рад звёздочкам, форкам и пулл-реквестам.
Tags:
Hubs:
+41
Comments 9
Comments Comments 9

Articles