[Из песочницы] Создание синтезатора на JavaScript

facbacda06cf4c42afaf7868600804fa.png

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

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

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

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

3b0c0662c4e8405fbb4d5e1100d64194.jpg

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

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, т. е. функция может выглядеть вот так:

dd50dcf9172f49268a98211892add37a.png

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

6b8f8bb7f3164ae2a33857379b9610ec.gif

Следовательно рассчёт коэффициентов и передача их 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;


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

29edc056800f4ecd93cf2c52961c0782.png

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

56eb8b8e3c1f4bf9bf589864c873cfb8.png

А в коде так:

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); 
  }; 
}


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

94278f8a25a0429d8c6f75bad60ef030.gif

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

d7a497f1f2be4b168e57a6696e1f3330.gif

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

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

Код модулятора
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;
});


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

f5db6bb07a87417f8283196c414086cb.png

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

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();


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

94862ca7d1ed46038c20583212317737.png

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

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

a15d2b0d97a442ccb7cb053f718fae5a.png

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

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

331601c49f874eb392fa4d68caac6de4.png

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

Реализация эха
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), которая характеризует форму синтезированной волны во времени, приближённо описывая поведение звука, взятого на настоящем музыкальном инструменте.

1fd8ae6e18f142c5b059f97ba5f5ab13.png

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



a33a592e0a5f4ce09bb2d0acaafc8631.png

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

Демонстрация работы программы доступна по следующей ссылке: miroshko.github.io/Synzer

Весь исходный код доступен на github: github.com/miroshko/Synzer, буду рад звёздочкам, форкам и пулл-реквестам.

© Habrahabr.ru