MIDI 教程:创建由 MIDI 硬件控制的基于浏览器的音频应用程序

已发表: 2022-03-11

虽然 Web Audio API 越来越受欢迎,尤其是在 HTML5 游戏开发人员中,但 Web MIDI API 在前端开发人员中仍然鲜为人知。 其中很大一部分可能与目前缺乏支持和可访问的文档有关。 Web MIDI API 目前仅在 Google Chrome 中受支持,前提是您为其启用了一个特殊标志。 浏览器制造商目前很少强调这个 API,因为它计划成为 ES7 标准的一部分。

MIDI(乐器数字接口的缩写)是 80 年代初由几位音乐行业代表设计的,是电子音乐设备的标准通信协议。 尽管从那时起已经开发了其他协议,例如 OSC; 三十年后,MIDI 仍然是音频硬件制造商事实上的通信协议。 您将很难找到一位现代音乐制作人在他的工作室中没有至少一台 MIDI 设备。

随着 Web Audio API 的快速开发和采用,我们现在可以开始构建基于浏览器的应用程序,以弥合云和物理世界之间的差距。 Web MIDI API 不仅允许我们构建合成器和音频效果,而且我们甚至可以开始构建基于浏览器的 DAW(数字音频工作站),其功能和性能与当前基于 Flash 的对应物相似(例如,查看 Audiotool )。

在这个 MIDI 教程中,我将引导您了解 Web MIDI API 的基础知识,我们将构建一个简单的单声道合成器,您可以使用您最喜欢的 MIDI 设备进行演奏。 完整的源代码在这里,您可以直接测试现场演示。 如果您没有 MIDI 设备,您仍然可以通过查看 GitHub 存储库的“键盘”分支来学习本教程,它为您的计算机键盘提供基本支持,因此您可以演奏音符和更改八度音阶。 这也是可作为现场演示使用的版本。 但是,由于计算机硬件的限制,当您使用计算机键盘控制合成器时,速度和失谐都会被禁用。 请参阅 GitHub 上的自述文件以了解键/音符映射。

Toptal 的 midi 教程

Midi 教程先决条件

对于本 MIDI 教程,您将需要以下内容:

  • 启用了#enable-web-midi标志的 Google Chrome(版本 38 或更高版本)
  • (可选)可以触发音符的 MIDI 设备,连接到您的计算机

我们还将使用 Angular.js 为我们的应用程序带来一些结构; 因此,框架的基本知识是先决条件。

入门

我们将从头开始模块化我们的 MIDI 应用程序,将其分为 3 个模块:

  • WebMIDI:处理连接到计算机的各种 MIDI 设备
  • WebAudio:为我们的合成器提供音频源
  • WebSynth:将 Web 界面连接到音频引擎

App模块将处理用户与 Web 用户界面的交互。 我们的应用程序结构可能看起来像这样:

 |- app |-- js |--- midi.js |--- audio.js |--- synth.js |--- app.js |- index.html

您还应该安装以下库来帮助您构建应用程序:Angular.js、Bootstrap 和 jQuery。 可能最简单的安装方法是通过 Bower。

WebMIDI 模块:连接现实世界

让我们通过将 MIDI 设备连接到我们的应用程序来开始了解如何使用 MIDI。 为此,我们将创建一个返回单个方法的简单工厂。 要通过 Web MIDI API 连接到我们的 MIDI 设备,我们需要调用navigator.requestMIDIAccess方法:

 angular .module('WebMIDI', []) .factory('Devices', ['$window', function($window) { function _connect() { if($window.navigator && 'function' === typeof $window.navigator.requestMIDIAccess) { $window.navigator.requestMIDIAccess(); } else { throw 'No Web MIDI support'; } } return { connect: _connect }; }]);

差不多就是这样!

requestMIDIAccess方法返回一个 Promise,所以我们可以直接返回它并在我们应用的控制器中处理 Promise 的结果:

 angular .module('DemoApp', ['WebMIDI']) .controller('AppCtrl', ['$scope', 'Devices', function($scope, devices) { $scope.devices = []; devices .connect() .then(function(access) { if('function' === typeof access.inputs) { // deprecated $scope.devices = access.inputs(); console.error('Update your Chrome version!'); } else { if(access.inputs && access.inputs.size > 0) { var inputs = access.inputs.values(), input = null; // iterate through the devices for (input = inputs.next(); input && !input.done; input = inputs.next()) { $scope.devices.push(input.value); } } else { console.error('No devices detected!'); } } }) .catch(function(e) { console.error(e); }); }]);

如前所述, requestMIDIAccess方法返回一个 promise,将一个对象传递给then方法,具有两个属性:输入和输出。

在早期版本的 Chrome 中,这两个属性是允许您直接检索输入和输出设备数组的方法。 但是,在最新的更新中,这些属性现在是对象。 这有很大的不同,因为我们现在需要在输入或输出对象上调用values方法来检索相应的设备列表。 此方法充当生成器函数,并返回一个迭代器。 同样,这个 API 是 ES7 的一部分; 因此,实现类似生成器的行为是有意义的,即使它不像原始实现那样直接。

最后,我们可以通过迭代器对象的size属性检索设备数量。 如果至少有一个设备,我们只需通过调用迭代器对象的next方法来迭代结果,并将每个设备推送到 $scope 上定义的数组中。 在前端,我们可以实现一个简单的选择框,它将列出所有可用的输入设备,并让我们选择我们想要使用哪个设备作为活动设备来控制网络合成器:

 <select ng-model="activeDevice" class="form-control" ng-options="device.manufacturer + ' ' + device.name for device in devices"> <option value="" disabled>Choose a MIDI device...</option> </select>

我们将此选择框绑定到一个名为activeDevice的 $scope 变量,稍后我们将使用该变量将这个活动设备连接到合成器。

将此有源设备连接到合成器

WebAudio 模块:制造噪音

WebAudio API 不仅允许我们播放声音文件,还允许我们通过重新创建合成器的基本组件(如振荡器、滤波器和增益节点等)来生成声音。

创建一个振荡器

振荡器的作用是输出波形。 有多种类型的波形,其中 WebAudio API 支持四种:正弦波、方波、三角波和锯齿波。 据说波形以特定频率“振荡”,但如果需要,也可以定义自己的自定义波表。 人类可以听到一定范围的频率——它们被称为声音。 或者,当它们以低频振荡时,振荡器也可以帮助我们构建 LFO(“低频振荡器”),这样我们就可以调制我们的声音(但这超出了本教程的范围)。

要创建一些声音,我们需要做的第一件事是实例化一个新的AudioContext

 function _createContext() { self.ctx = new $window.AudioContext(); }

从那里,我们可以实例化 WebAudio API 提供的任何组件。 由于我们可能会为每个组件创建多个实例,因此创建服务以便能够为我们需要的组件创建新的、唯一的实例是有意义的。 让我们首先创建服务以生成新的振荡器:

 angular .module('WebAudio', []) .service('OSC', function() { var self; function Oscillator(ctx) { self = this; self.osc = ctx.createOscillator(); return self; } });

我们现在可以随意实例化新的振荡器,将我们之前创建的 AudioContext 实例作为参数传递。 为了让事情变得更容易,我们将添加一些包装方法 - 仅仅是语法糖 - 并返回 Oscillator 函数:

 Oscillator.prototype.setOscType = function(type) { if(type) { self.osc.type = type } } Oscillator.prototype.setFrequency = function(freq, time) { self.osc.frequency.setTargetAtTime(freq, 0, time); }; Oscillator.prototype.start = function(pos) { self.osc.start(pos); } Oscillator.prototype.stop = function(pos) { self.osc.stop(pos); } Oscillator.prototype.connect = function(i) { self.osc.connect(i); } Oscillator.prototype.cancel = function() { self.osc.frequency.cancelScheduledValues(0); } return Oscillator;

创建多通滤波器和音量控制

我们还需要两个组件来完成我们的基本音频引擎:一个多通滤波器,为我们的声音赋予一点形状,一个增益节点来控制我​​们的声音音量并打开和关闭音量。 为此,我们可以以与振荡器相同的方式进行操作:创建返回带有一些包装器方法的函数的服务。 我们需要做的就是提供 AudioContext 实例并调用适当的方法。

我们通过调用 AudioContext 实例的createBiquadFilter方法来创建一个过滤器:

 ctx.createBiquadFilter();

同样,对于增益节点,我们调用createGain方法:

 ctx.createGain();

WebSynth 模块:连接事物

现在我们几乎准备好构建我们的合成器接口并将 MIDI 设备连接到我们的音频源。 首先,我们需要将我们的音频引擎连接在一起并准备好接收 MIDI 音符。 要连接音频引擎,我们只需创建所需组件的新实例,然后使用可用于每个组件实例的connect方法将它们“连接”在一起。 connect方法接受一个参数,它只是您想要将当前实例连接到的组件。 可以编排更精细的组件链,因为connect方法可以将一个节点连接到多个调制器(从而可以实现交叉淡入淡出等功能)。

 self.osc1 = new Oscillator(self.ctx); self.osc1.setOscType('sine'); self.amp = new Amp(self.ctx); self.osc1.connect(self.amp.gain); self.amp.connect(self.ctx.destination); self.amp.setVolume(0.0, 0); //mute the sound self.filter1.disconnect(); self.amp.disconnect(); self.amp.connect(self.ctx.destination); }

我们刚刚构建了音频引擎的内部布线。 您可以尝试一下并尝试不同的接线组合,但请记住调低音量以避免耳聋。 现在我们可以将 MIDI 接口连接到我们的应用程序并将 MIDI 消息发送到音频引擎。 我们将在设备选择框上设置一个观察器,以虚拟地将其“插入”到我们的合成器中。 然后,我们将收听来自设备的 MIDI 消息,并将信息传递给音频引擎:

 // in the app's controller $scope.$watch('activeDevice', DSP.plug); // in the synth module function _onmidimessage(e) { /** * e.data is an array * e.data[0] = on (144) / off (128) / detune (224) * e.data[1] = midi note * e.data[2] = velocity || detune */ switch(e.data[0]) { case 144: Engine.noteOn(e.data[1], e.data[2]); break; case 128: Engine.noteOff(e.data[1]); break; } } function _plug(device) { self.device = device; self.device.onmidimessage = _onmidimessage; }

在这里,我们正在监听来自设备的 MIDI 事件,分析来自 MidiEvent 对象的数据,并将其传递给适当的方法; noteOnnoteOff ,基于事件代码(noteOn 为 144,noteOff 为 128)。 我们现在可以在音频模块的各个方法中添加逻辑来实际生成声音:

 function _noteOn(note, velocity) { self.activeNotes.push(note); self.osc1.cancel(); self.currentFreq = _mtof(note); self.osc1.setFrequency(self.currentFreq, self.settings.portamento); self.amp.cancel(); self.amp.setVolume(1.0, self.settings.attack); } function _noteOff(note) { var position = self.activeNotes.indexOf(note); if (position !== -1) { self.activeNotes.splice(position, 1); } if (self.activeNotes.length === 0) { // shut off the envelope self.amp.cancel(); self.currentFreq = null; self.amp.setVolume(0.0, self.settings.release); } else { // in case another note is pressed, we set that one as the new active note self.osc1.cancel(); self.currentFreq = _mtof(self.activeNotes[self.activeNotes.length - 1]); self.osc1.setFrequency(self.currentFreq, self.settings.portamento); } }

这里正在发生一些事情。 在noteOn方法中,我们首先将当前笔记推送到一个笔记数组中。 即使我们正在构建一个单音合成器(意味着我们一次只能演奏一个音符),我们仍然可以同时在键盘上使用多个手指。 因此,我们需要将所有这些音符排队,这样当我们释放一个音符时,就会播放下一个音符。 然后我们需要停止振荡器来分配新的频率,我们将它从一个 MIDI 音符(从 0 到 127 的范围)转换为一个实际的频率值,并进行一些数学运算:

 function _mtof(note) { return 440 * Math.pow(2, (note - 69) / 12); }

noteOff方法中,我们首先在活动音符数组中找到音符并将其删除。 然后,如果它是阵列中唯一的音符,我们只需关闭音量。

setVolume方法的第二个参数是转换时间,表示增益达到新的音量值需要多长时间。 用音乐术语来说,如果音符打开,则相当于起音时间,如果音符关闭,则相当于释音时间。

WebAnalyser 模块:可视化我们的声音

我们可以添加到合成器中的另一个有趣的功能是分析器节点,它允许我们使用画布显示声音的波形来渲染它。 创建分析器节点比其他 AudioContext 对象要复杂一些,因为它还需要创建一个 scriptProcessor 节点来实际执行分析。 我们首先选择 DOM 上的 canvas 元素:

 function Analyser(canvas) { self = this; self.canvas = angular.element(canvas) || null; self.view = self.canvas[0].getContext('2d') || null; self.javascriptNode = null; self.analyser = null; return self; }

然后,我们添加一个connect方法,我们将在其中创建分析器和脚本处理器:

 Analyser.prototype.connect = function(ctx, output) { // setup a javascript node self.javascriptNode = ctx.createScriptProcessor(2048, 1, 1); // connect to destination, else it isn't called self.javascriptNode.connect(ctx.destination); // setup an analyzer self.analyser = ctx.createAnalyser(); self.analyser.smoothingTimeConstant = 0.3; self.analyser.fftSize = 512; // connect the output to the destination for sound output.connect(ctx.destination); // connect the output to the analyser for processing output.connect(self.analyser); self.analyser.connect(self.javascriptNode); // define the colors for the graph var gradient = self.view.createLinearGradient(0, 0, 0, 200); gradient.addColorStop(1, '#000000'); gradient.addColorStop(0.75, '#ff0000'); gradient.addColorStop(0.25, '#ffff00'); gradient.addColorStop(0, '#ffffff'); // when the audio process event is fired on the script processor // we get the frequency data into an array // and pass it to the drawSpectrum method to render it in the canvas self.javascriptNode.onaudioprocess = function() { // get the average for the first channel var array = new Uint8Array(self.analyser.frequencyBinCount); self.analyser.getByteFrequencyData(array); // clear the current state self.view.clearRect(0, 0, 1000, 325); // set the fill style self.view.fillStyle = gradient; drawSpectrum(array); } };

首先,我们创建一个 scriptProcessor 对象并将其连接到目的地。 然后,我们创建分析器本身,我们将振荡器或滤波器的音频输出提供给它。 请注意,我们仍然需要将音频输出连接到目的地,这样我们才能听到它! 我们还需要定义图形的渐变颜色——这是通过调用 canvas 元素的createLinearGradient方法来完成的。

最后,scriptProcessor 将在一个时间间隔内触发一个 'audioprocess' 事件; 当这个事件被触发时,我们计算分析器捕获的平均频率,清除画布,并通过调用drawSpectrum方法重新绘制新的频率图:

 function drawSpectrum(array) { for (var i = 0; i < (array.length); i++) { var v = array[i], h = self.canvas.height(); self.view.fillRect(i * 2, h - (v - (h / 4)), 1, v + (h / 4)); } }

最后但同样重要的是,我们需要稍微修改音频引擎的接线以适应这个新组件:

 // in the _connectFilter() method if(self.analyser) { self.analyser.connect(self.ctx, self.filter1); } else { self.filter1.connect(self.ctx.destination); } // in the _disconnectFilter() method if(self.analyser) { self.analyser.connect(self.ctx, self.amp); } else { self.amp.connect(self.ctx.destination); }

我们现在有一个很好的可视化器,它允许我们实时显示合成器的波形! 这涉及到一些设置工作,但它非常有趣且富有洞察力,尤其是在使用过滤器时。

建立在我们的合成器上:添加速度和失谐

在我们的 MIDI 教程中,我们有一个非常酷的合成器 - 但它以相同的音量播放每个音符。 这是因为我们没有正确处理速度数据,而是简单地将音量设置为固定值 1.0。 让我们从修复它开始,然后我们将了解如何启用您在最常见的 MIDI 键盘上找到的失谐轮。

启用速度

如果您不熟悉它,“速度”与您敲击键盘上的键的力度有关。 基于此值,创建的声音似乎更柔和或更响亮。

在我们的 MIDI 教程合成器中,我们可以通过简单地使用增益节点的音量来模拟这种行为。 为此,我们首先需要做一些数学运算,将 MIDI 数据转换为介于 0.0 和 1.0 之间的浮点值,以传递给增益节点:

 function _vtov (velocity) { return (velocity / 127).toFixed(2); }

MIDI 设备的力度范围是从 0 到 127,因此我们只需将该值除以 127 并返回一个带两位小数的浮点值。 然后,我们可以更新_noteOn方法将计算的值传递给增益节点:

 self.amp.setVolume(_vtov(velocity), self.settings.attack);

就是这样! 现在,当我们演奏我们的合成器时,我们会注意到音量会根据我们敲击键盘上的键的力度而变化。

在 MIDI 键盘上启用失谐轮

大多数 MIDI 键盘都有失谐轮; 滚轮允许您稍微改变当前正在播放的音符的频率,从而产生一种称为“失谐”的有趣效果。 当您学习如何使用 MIDI 时,这很容易实现,因为失谐轮还使用自己的事件代码 (224) 触发 MidiMessage 事件,我们可以通过重新计算频率值和更新振荡器来收听并采取行动。

首先,我们需要在我们的合成器中捕捉事件。 为此,我们在_onmidimessage回调中创建的 switch 语句中添加了一个额外的 case:

 case 224: // the detune value is the third argument of the MidiEvent.data array Engine.detune(e.data[2]); break;

然后,我们在音频引擎上定义detune方法:

 function _detune(d) { if(self.currentFreq) { //64 = no detune if(64 === d) { self.osc1.setFrequency(self.currentFreq, self.settings.portamento); self.detuneAmount = 0; } else { var detuneFreq = Math.pow(2, 1 / 12) * (d - 64); self.osc1.setFrequency(self.currentFreq + detuneFreq, self.settings.portamento); self.detuneAmount = detuneFreq; } } }

默认失谐值为 64,这意味着没有应用失谐,因此在这种情况下,我们只需将当前频率传递给振荡器。

最后,我们还需要更新_noteOff方法,以在另一个音符排队时考虑失谐:

 self.osc1.setFrequency(self.currentFreq + self.detuneAmount, self.settings.portamento);

创建接口

到目前为止,我们只创建了一个选择框来选择我们的 MIDI 设备和一个波形可视化器,但我们无法通过与网页交互直接修改声音。 让我们使用常见的表单元素创建一个非常简单的界面,并将它们绑定到我们的音频引擎。

为界面创建布局

我们将创建各种表单元素来控制合成器的声音:

  • 用于选择振荡器类型的无线电组
  • 启用/禁用过滤器的复选框
  • 用于选择过滤器类型的单选组
  • 两个范围来控制滤波器的频率和谐振
  • 两个范围控制增益节点的攻击和释放

为我们的界面创建一个 HTML 文档,我们最终应该是这样的:

 <div class="synth container" ng-controller="WebSynthCtrl"> <h1>webaudio synth</h1> <div class="form-group"> <select ng-model="activeDevice" class="form-control" ng-options="device.manufacturer + ' ' + device.name for device in devices"> <option value="" disabled>Choose a MIDI device...</option> </select> </div> <div class="col-lg-6 col-md-6 col-sm-6"> <h2>Oscillator</h2> <div class="form-group"> <h3>Oscillator Type</h3> <label ng-repeat="t in oscTypes"> <input type="radio" name="oscType" ng-model="synth.oscType" value="{{t}}" ng-checked="'{{t}}' === synth.oscType" /> {{t}} </label> </div> <h2>Filter</h2> <div class="form-group"> <label> <input type="checkbox" ng-model="synth.filterOn" /> enable filter </label> </div> <div class="form-group"> <h3>Filter Type</h3> <label ng-repeat="t in filterTypes"> <input type="radio" name="filterType" ng-model="synth.filterType" value="{{t}}" ng-disabled="!synth.filterOn" ng-checked="synth.filterOn && '{{t}}' === synth.filterType" /> {{t}} </label> </div> <div class="form-group"> <!-- frequency --> <label>filter frequency:</label> <input type="range" class="form-control" min="50" max="10000" ng-model="synth.filterFreq" ng-disabled="!synth.filterOn" /> </div> <div class="form-group"> <!-- resonance --> <label>filter resonance:</label> <input type="range" class="form-control" min="0" max="150" ng-model="synth.filterRes" ng-disabled="!synth.filterOn" /> </div> </div> <div class="col-lg-6 col-md-6 col-sm-6"> <div class="panel panel-default"> <div class="panel-heading">Analyser</div> <div class="panel-body"> <!-- frequency analyser --> <canvas></canvas> </div> </div> <div class="form-group"> <!-- attack --> <label>attack:</label> <input type="range" class="form-control" min="50" max="2500" ng-model="synth.attack" /> </div> <div class="form-group"> <!-- release --> <label>release:</label> <input type="range" class="form-control" min="50" max="1000" ng-model="synth.release" /> </div> </div> </div>

在这个基本的 MIDI 教程中,我不会将用户界面装饰得看起来很花哨。 相反,我们可以将其保存为练习,以备日后完善用户界面,可能看起来像这样:

抛光的 MIDI 用户界面

将接口绑定到音频引擎

我们应该定义一些方法来将这些控件绑定到我们的音频引擎。

控制振荡器

对于振荡器,我们只需要一个允许我们设置振荡器类型的方法:

 Oscillator.prototype.setOscType = function(type) { if(type) { self.osc.type = type; } }

控制过滤器

对于滤波器,我们需要三个控件:一个用于滤波器类型,一个用于频率,一个用于谐振。 我们还可以将_connectFilter_disconnectFilter方法连接到复选框的值。

 Filter.prototype.setFilterType = function(type) { if(type) { self.filter.type = type; } } Filter.prototype.setFilterFrequency = function(freq) { if(freq) { self.filter.frequency.value = freq; } } Filter.prototype.setFilterResonance = function(res) { if(res) { self.filter.Q.value = res; } }

控制攻击和共振

为了稍微塑造我们的声音,我们可以改变增益节点的起音和释放参数。 为此,我们需要两种方法:

 function _setAttack(a) { if(a) { self.settings.attack = a / 1000; } } function _setRelease(r) { if(r) { self.settings.release = r / 1000; } }

设置观察者

最后,在我们应用的控制器中,我们只需要设置一些观察者并将它们绑定到我们刚刚创建的各种方法:

 $scope.$watch('synth.oscType', DSP.setOscType); $scope.$watch('synth.filterOn', DSP.enableFilter); $scope.$watch('synth.filterType', DSP.setFilterType); $scope.$watch('synth.filterFreq', DSP.setFilterFrequency); $scope.$watch('synth.filterRes', DSP.setFilterResonance); $scope.$watch('synth.attack', DSP.setAttack); $scope.$watch('synth.release', DSP.setRelease);

结论

这个 MIDI 教程涵盖了很多概念; 大多数情况下,我们发现了如何使用 WebMIDI API,除了 W3C 的官方规范之外,它还没有被记录在案。 Google Chrome 的实现非常简单,尽管切换到输入和输出设备的迭代器对象需要使用旧实现对遗留代码进行一些重构。

至于 WebAudio API,这是一个非常丰富的 API,我们在本教程中只介绍了它的一些功能。 与 WebMIDI API 不同,WebAudio API 有很好的文档记录,特别是在 Mozilla 开发者网络上。 Mozilla 开发人员网络包含大量代码示例以及每个组件的各种参数和事件的详细列表,这将帮助您实现自己的基于浏览器的自定义音频应用程序。

随着这两个 API 的不断增长,它将为 JavaScript 开发人员打开一些非常有趣的可能性; 使我们能够开发功能齐全、基于浏览器的 DAW,能够与 Flash 同类产品竞争。 对于桌面开发人员,您还可以使用 node-webkit 等工具开始创建自己的跨平台应用程序。 希望这将为发烧友带来新一代的音乐工具,通过弥合物理世界和云之间的差距为用户提供支持。