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 上的自述文件以了解鍵/音符映射。
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 對象的數據,並將其傳遞給適當的方法; noteOn
或noteOff
,基於事件代碼(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 教程中,我不會將用戶界面裝飾得看起來很花哨。 相反,我們可以將其保存為練習,以備日後完善用戶界面,可能看起來像這樣:
將接口綁定到音頻引擎
我們應該定義一些方法來將這些控件綁定到我們的音頻引擎。
控制振盪器
對於振盪器,我們只需要一個允許我們設置振盪器類型的方法:
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 等工具開始創建自己的跨平台應用程序。 希望這將為發燒友帶來新一代的音樂工具,通過彌合物理世界和雲之間的差距為用戶提供支持。