我今天要和大家分享的是一個我自己寫的音樂網頁小程序,這個網頁程序主要分為兩個部分--即時演奏(LivePlay)和編曲(Arranger)。即時演奏就是指按下鼠標/鍵盤/手機屏幕就可以即刻發聲,編曲是指提前寫好“譜子”然后播放。
這個音樂程序現在僅有網頁版,由於我使用Javascript(和HTML,CSS)寫成,所以理論上將來它可以移植到Android和iOS上,也可以改成電腦程序,當然也可以改裝成微信小程序!
我是一個Javascript和Web初學者,這個音樂小程序並不復雜,所以如果有喜歡音樂,或在學習Web前端,學習canvas繪圖的朋友,大家可以一起探討程序的機理,體悟美妙的音樂!
網頁示范: https://sien75.github.io/MusicMaker/liveplay , 在瀏覽器中打開就可以啦(ie,edge除外,手機記得橫屏)
我的github主頁就是 https://github.com/sien75,看完整代碼來這里就可以,歡迎加星,不勝感激^ >< ^
最初,學校的C++課程有寫程序的課題任務,我萌生了做一個編曲&即時演奏的音樂程序的念頭,於是我在網上不斷查找,找到了MIDI(Musical Instrument Digital Interface,樂器的數字接口)這玩意。使用Windows的MIDI消息api--midiOutShortMSG(...),可以發送MIDI消息,然后Windows利用自帶的MIDI音色庫生成聲音。我花費了一個月的時間,用MFC實現了一個簡陋的音樂程序。之后,我想進一步把這個程序寫下去,使程序更完善,但是我發現自己寫的爛代碼自己根本不願回顧……
而且MFC是一個比較老的東西了,所以我想丟掉之前的代碼,重新寫一個程序(話說在我不停地“備份-格式化磁盤-換系統”中,那份原始代碼終於被我刪掉了……)。我想我不是已經會c++了嘛,所以我最初嘗試用Qt寫。然而我發現Qt沒有關於MIDI的api,我也在網上搜索了好一陣子,也沒有找到合適的第三方庫,於是就不了了之了。
還有我想實現跨平台的程序,既然Qt & C++不能用了,我想繼續用C#寫下去。原因如下:1 C#看起來和C++挺像的,應該容易學習;2 VisualStudio + C#號稱天下無敵宇宙第一,且跨平台很輕松;3 C#也可以使用Windows的MIDI api,我不用再愁發不出聲的問題了;4 看看“C#”這名字,命名人肯定很喜歡音樂,這個語言寫音樂程序肯定很適合。
然而之后再次放棄,具體原因忘記了,可能是我一直想學習Web安全領域,所以我迫不及待要開始前端之路了。於是花費了一些時間學習HTML,學習CSS,學習Javascript(強烈推薦《Javascript高級程序設計》)。
聽說w3c有個Web MIDI Api,我想:何不用這個東西實現音樂程序呢?而且這個瀏覽器本身就是跨平台的,這樣正好符合我的要求。然而Web MIDI Api是為了在瀏覽器上使用MIDI硬件設備的,並不能直接解決我的問題。與是我又花了很長時間,不停地找,無數次想放棄,但是最終,我找到了一個perfect的東西(大神的東西……) https://github.com/surikov/webaudiofont。
這不是MIDI,MIDI發聲原理是主控器(比如MIDI鍵盤)發送信號,經音序器(Sequencer)處理,使內置音樂播放器調用音源,進而使揚聲器發聲。所以MIDI傳輸的是數字符號,用來表示音樂的起伏。這個庫就是模仿的這一過程,我們可以通過鍵盤鼠標手機觸摸屏(相當於主控器)進行編輯,然后通過html5的Web Audio Api(相當於Windows的內置音樂播放器)播放音源發聲,這里的音源文件,那位大神也已經准備好了,https://github.com/surikov/webaudiofontdata,這里面有一百多種樂器的音源(即MIDI的那些標准樂器,比如鋼琴吉他貝斯尺八)。而這個庫就是一個Javascript版的音序器,它已經可以實現發出不同聲調不同音色的聲音的功能。
於是,我就開始寫代碼,之后的事情有章可循,比之前的迷茫要好一些了。
接下來我就說一下這個程序的具體代碼,閱讀前確保您已掌握HTML,CSS,Javascript,HTML5 canvas繪圖和一些音樂基本知識。
程序分為兩個部分,即時演奏(LivePlay)和編曲(Arranger),目前只實現了LivePlay模塊,Arranger正在碼代碼中。來看一下LivePlay模塊的使用,放圖片:
如圖,界面中心是五個鍵組,每個鍵組有7個白鍵,所以一共有35個白鍵,分別代表音調C2 D2 E2 F2 G2 A2 B2 C3 D3 ... A6 B6。其中C4~B4即是通常所說的do re mi fa so la si啦。除了白鍵,還有25個黑鍵,這些就是相應的半調C# D# F# G# A#了。用鼠標點擊黑白鍵,或點擊后拖動,皆可發出聲音。用鍵盤控制方法如下:
按下對應的鍵,就可以發聲。K鍵或左方向鍵可以向左切換鍵組,同理L鍵或右方向鍵可以向右切換鍵組。鍵組從小字一組切換到小字二組的示意圖如下:
切換鍵組后鍵盤上相應的12個鍵就可以控制當前鍵組的12個音調了。
由於手機沒有鍵盤,所以不存在切換鍵組的問題,但是使用的時候記得橫屏。
界面上部有4個下拉框,分別可以改變音色,八度升降,鍵盤控制的鍵組和鍵組數目,這些改變是顯而易見的,大家自己試一試吧。
最后,右上角的swith to arranger可以跳轉到本程序的編曲(Arranger)部分(正在施工中)。
這是本程序的代碼根目錄,其中arranger和liveplay即為程序的兩個主要模塊,sound存放音源,browser.js用於檢測客戶端類型(主要看看是不是在用手機瀏覽本站),index.html是程序主頁(當然這個主頁現在沒什么用,會自動跳轉到liveplay/index.html),webaudiofontplayer.js是js音序器。
在liveplay里,有7個文件:
首先,index.html是網頁入口。main.js的功能是定義頁面總體設置函數和初始化函數,三個“eventhandler”文件是處理事件(比如下拉框的選項選擇啦,鍵盤按下啦……)的,然后myAudio.js和myCanvas.js分別定義了MyAudio()和MyCanvas()兩個構造函數,分別用於處理聲音和繪圖部分。網頁運行流程如下:
剛打開時會運行main.js中的init()函數,該函數進行總體設置的初始化,並分別調用myAudio.js和myCanvas.js中的初始化函數進行聲音部分和繪圖部分的初始化,初始化完畢后,程序等待用戶事件的發生。如果用戶在電腦端按下鍵盤或用鼠標點擊琴鍵,會觸發PCEventHandlers.js中的響應函數;如果用戶在手機端觸摸琴鍵區,會觸發mobileEventHandlers.js中的響應函數;如果用戶操作下拉框,會觸發eventHandlers.js中的響應函數。所有響應函數會實際上調用main.js,myAudio.js或myCanvas.js中的函數進行具體的操作,以完成所需效果。
大家想,這個程序顯示上最重要的就是canvas區域,而聲音不需要顯示區域,所以,index.html文件還是非常簡短的。在index.html中,主要的就有四個select標簽控制音色,八度,鍵盤所控鍵組和鍵組數目,和一個canvas標簽。
1 <!DOCTYPE html> 2 <html> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, 6 maximum-scale=1.0, user-scalable=no"> 7 <title>LivePlay 即時演奏</title> 8 </head> 9 <body style="user-select:none; margin:0; overflow:hidden; background:#222; 10 font-family:'Lucida Console',Monaco,monospace"> 11 <div style="margin-bottom:5px"> 12 <h1 style="font-size:25px; color:#888; display:inline-block; margin:4px 0 0 5px; 13 border:3px solid; border-radius:5px">MusicMaker</h1> 14 <h1 style="font-size:28px; display:inline-block; color:#888; margin:6px 20px 0 0;">LivePlay</h1> 15 <select id="selectInstruments"> 16 <option value="0000_Aspirin:n">piano</option> 17 <option value="0390_Aspirin:y">bass</option> 18 </select> 19 <select id="octive"> 20 <option value="0" id="o0">八度:0</option> 21 <option value="1" id="o1">八度:+1</option> 22 <option value="-1" id="on1">八度:-1</option> 23 <option value="2" id="o2">八度:+2</option> 24 <option value="-2" id="on2">八度:-2</option> 25 </select> 26 <select id="keyboardGroup"> 27 <option value="0" id="k1">鍵盤控制小字一組</option> 28 <option value="-2" id="k4">鍵盤控制大字組</option> 29 <option value="-1" id="k2">鍵盤控制小字組</option> 30 <option value="1" id="k3">鍵盤控制小字二組</option> 31 <option value="2" id="k5">鍵盤控制小字三組</option> 32 </select> 33 <select id="groupNum"> 34 <option value="5" id="g5">鍵組數目:5</option> 35 <option value="4" id="g4">鍵組數目:4</option> 36 <option value="3" id="g3">鍵組數目:3</option> 37 <option value="2" id="g2">鍵組數目:2</option> 38 <option value="1" id="g1">鍵組數目:1</option> 39 </select> 40 <span id="loading" style="font-size:20px; color:#f22; margin-left:10px; display:none ">Loading...</span> 41 <a href="../arranger/index.html" style="font-size:15px; color:#888; 42 float:right; margin-top:20px" id="switch">switch to arranger</a> 43 </div> 44 <canvas id="canvas"></canvas> 45 <script type="text/javascript" src="../browser.js"></script> 46 <script type="text/javascript" src="../webAudiofontPlayer.js"></script> 47 <script type="text/javascript" src="myCanvas.js"></script> 48 <script type="text/javascript" src="myAudio.js"></script> 49 <script type="text/javascript" src="main.js"></script> 50 <script type="text/javascript" src="eventHandlers.js"></script> 51 <script type="text/javascript" src="PCEventHandlers.js"></script> 52 <script type="text/javascript" src="mobileEventHandlers.js"></script> 53 </body> 54 </html>
index.html非常簡單,第5,6行是禁止手機瀏覽器雙擊放大和雙指放大的。
第9行的user-select:none,是禁止鼠標選取內容的,本程序使用過程中會拖動鼠標,所以我們必須禁止默認的拖動選中。
第44行就是一個canvas畫布,我們會在js里對其進行設置,我們接下來的很大一部分工作就是針對這個畫布的。
接下來我們就來分析js文件。
main.js
剛才說過,main.js有兩個部分,初始化函數的定義和總體設置函數的定義。圖示,這兩部分,分別有3個函數:
這是一個初始化的大體的流程圖,紅色箭頭代表初始化流程進行路線,黑色箭頭代表初始化函數的調用情況。
handleOctive()和handleKeyboardGroup()兩個函數要按照當時的鍵組數目進行調整,先討論handleOctive()。
我們一共有60個鍵,分別對應音值24~83這60個音調。如果調整鍵組數目為4個,那么就會有12*4 = 48個鍵,它們對應24~71這48個音調,所以這時可以升八度,使其對應於36~83這48個音調。如果調整鍵組數目為3個,那么就會一共有12*3 = 36個鍵,初始對應於36~71這36個音調,所以既可以升八度到48~83,也可以降八度到24~59。
那么,鍵組數目與可以升降八度的情況有如下對應:
5 ~ 無; 4 ~ (+1); 3 ~ (-1, +1); 2 ~ (+2, -1, +1); 1 ~ (-2, +2, -1, +1)
所以我們定義如下數組:
var octs = ['n2', '2', 'n1', '1'];
實現按照順序隱藏或顯示相應的八度調整選項。
再討論handleKeyboardGroup()。
這個就更好理解了,有幾個鍵組,電腦鍵盤就可以控制幾個鍵組。(注:這五個鍵組名字依次為“大字組”,“小字組”,“小字一組”,“小字二組”。“小字三組”)
handleOctive()和handleKeyboardGroup()主要是在調整下拉框的內容,比如鍵組數目為4時,那么屏幕上有“大字組”,“小字組”,“小字一組”和“小子二組”,這時屏幕上並沒有“小字三組”,控制鍵盤所選鍵組下拉框里再顯示“鍵盤控制小字三組”,就不合適了。
eventHandlers.js
這個文件包含4個下拉框的響應函數。另外,它還包含一些全局變量和全局函數的定義,用於PCEventHandlers.js和mobileEventHandlers.js中的響應函數。
4個onchange響應函數很簡單,沒什么好說的。
我把這些全局變量和全局函數集中到這里,是為了方便管理與查看,由於是全局的,所以另外兩個文件(PCEventHandlers.js和mobileEventHandlers.js)的響應函數照樣可以使用。
clickOn:鼠標按到琴鍵上,值變為true;鼠標抬起,值變為false。當鼠標拖動時,利用該值可以判斷用戶是否在“按着琴鍵拖動”
positionListener:當鼠標按下並拖動時,positionListener.a用於記錄上一個位置的對應音調值,以判斷當前位置相對於上一個位置是否變化了琴鍵(把它定義為Object是為了按引用傳遞^~^)
noteRecord,rectRecord:當前鼠標點擊或拖動的位置會有對應音調和對應琴鍵區域的兩個值,記錄於這兩個變量,這兩個值分別傳遞到聲音和繪圖相關函數即可發出聲音和顏色變換
noteOnJudge:這是記錄鍵盤上12個音調鍵按下或抬起的變量,抬起則為0,按下則為1
keyUpAndDownTable:這里面的十二個值記錄着鍵盤上A,W,S,E,D,F,T,G,Y,H,U和J的鍵盤碼,按照順序,分別代表C,C#,D,D#,E,F,F#,G,G#,A,A#和B這12個音調
computerKeyboardGroup:記錄當前電腦鍵盤控制的鍵組,中央C鍵所在鍵組為0,中央鍵組左鄰居鍵組為-1,再往左為-2,右邊為正,當有4個鍵組時,相應鍵組值如圖所示:
noteRecordRect:用於觸控時,記錄某音調對應的琴鍵區域
getPos:轉換坐標
PCEventHandlers.js
這個文件包含着3個鼠標響應函數,和2個鍵盤響應函數。
對於3個鼠標事件(按下,拖動和抬起),我們希望:按下時打開音調,琴鍵區域塗成彩色;按住並拖動致變換琴鍵區域時,關閉上一個音調,打開當前音調,將前一個區域塗成黑色或白色,當前區域塗成彩色;抬起時關閉音調,並將當前琴鍵區域塗回黑色或白色。
打開音調和將當前琴鍵區繪制成彩色的兩個函數如下:
1 myAudio.startNote(note); 2 myCanvas.paintKey(rect, 'click');
關閉音調和將當前琴鍵區塗回黑色或白色的函數如下:
1 myAudio.stopNote(note); 2 myCanvas.paintKey(rect, 'release');
在以上幾個函數中,參數note是一個整形值,范圍是24~83,代表音調;參數rect是一個對象,里面包含了記錄琴鍵的區域的數值,和顏色數值,這個對象的結構我們要到myCanvas()中具體說。
以下語句
1 clickOn = true;
2 clickOn = false;
第1行是在onmousedown()中的語句,第二行是在onmouseup()中的語句。clickOn就是前面eventHandlers.js中的全局變量,clickOn為true時,代表鼠標已經按下並且按到了琴鍵區域,這時只要鼠標掃過不同的琴鍵區域,就會發聲。
下面第一個函數可以將鼠標的位置點轉換成音調值,而第二個函數可以將音調值轉換成相應的琴鍵區域。
1 myCanvas.positionToNote(pos.x, pos.y), 2 myCanvas.noteToRect(note);
下面這個函數是檢測拖動時鼠標位置是否在改變琴鍵區域,比如鼠標點擊到了C鍵,再拖動到了D鍵,在鼠標剛剛到達D鍵時,此時下面的函數返回true,其他時候返回false。按在C鍵而只在C鍵區域內移動,並不是真正的移動,此時下面的函數時時返回false。此外,當鼠標點移出琴鍵區或從“外面”移到琴鍵區域時,也視為改變了琴鍵區域,下面的函數也會在改變的瞬刻返回true。
1 myCanvas.ifPositionChanged(pos.x, pos.y, positionListener);
對於2個鍵盤事件(按下和抬起),我們希望按下時打開音調,將當前琴鍵區塗成彩色;抬起時關閉音調,將琴鍵區域塗回黑白色。
在eventHandlers.js中,我們定義了keyUpAndDownTable用於按順序從0~11存放了A,W,S,E,D,F,T,G,Y,H,U和J這些“音調鍵”的鍵盤碼;還定義了noteOnjudge,在這里noteOnJudge(0) = 1代表A鍵處於按下的狀態,noteOnJudge(4) = 0代表D鍵處於抬起的狀態。noteOnJudge用處是這樣的:在有音調鍵按下時,不允許切換鍵組,即此時按“K”,“L”,左方向鍵或右方向鍵不起作用。這樣做的目的是防止“卡鍵“--鍵組移走了,音調就無法關閉了。
onkeydown函數有3部分,按下“K“或左方向鍵,且所有音調鍵抬起,向左切換鍵組;按下”L“或右方向鍵,且所有音調鍵抬起,向右切換鍵組;按下音調鍵,打開音調,琴鍵區域繪成彩色。
其中的
1 myCanvas.paintIndicator(computerKeyboardGroup);
是繪制指示符的。指示符就是屏幕上當前鍵組上方的三個紅綠藍色的四分之三圓,用來指示當前鍵組。
onkeyup函數只有1個部分,抬起音調鍵,關閉音調,琴鍵區域恢復到黑色或白色。
mobileEventHandlers.js
這個文件包含着3個觸摸響應函數。
上面的兩個preventDefault是分別為了阻止手機瀏覽器上滾動事件和長按彈出菜單事件,這兩個事件都會影響使用效果。
3個響應函數分別處理觸摸開始,滑動和觸摸結束。當然,觸摸開始的時候打開音調,琴鍵塗成彩色;觸摸結束時關閉音調,琴鍵塗成黑色或白色。
重點看一下canvas.ontouchmove這個函數,我覺得這是響應函數中最難實現的一個。先貼代碼:
1 canvas.ontouchmove = function() { 2 var pos, trues = new Array(); 3 for (var i = 0; i < event.targetTouches.length; i++) { 4 pos = getPos(event.targetTouches[i]); 5 var n = myCanvas.positionToNote(pos.x, pos.y), 6 r = myCanvas.noteToRect(n); 7 if(trues.indexOf(n) < 0) trues.push(n); 8 if(!noteRecordRect[n]) { 9 noteRecordRect[n] = true; 10 myAudio.startNote(n); 11 myCanvas.paintKey(r, 'click'); 12 } 13 } 14 for( var i=24; i < 84; i++) 15 if(noteRecordRect[i] && trues.indexOf(i) < 0) { 16 myAudio.stopNote(i); 17 myCanvas.paintKey(myCanvas.noteToRect(i), 'release'); 18 noteRecordRect[i] = false; 19 } 20 };
函數有兩個大部分,分別是4~14行和15~20行的for語句。
event.targetTouches代表屏幕區域的所有觸摸點(此外event.changedTouches代表變化的觸摸點,注意區分),trues數組會記錄這次拖動事件的所有手指激活的琴鍵的音調,而此前在eventHandlers.js中定義的noteRecordRect數組則是記錄的直到上次拖動事件所有手指激活的琴鍵的音調。那么,第8行的意思是:上次拖動事件手指未到達本琴鍵區域,但是這次到達了——這就是說手指剛剛觸摸本琴鍵,所以這時打開音調,琴鍵繪制彩色。第15行的意思是:雖然上次手指觸摸了本琴鍵區域,但是這次卻沒有——這就是說手指剛剛離開本琴鍵,所以這時關閉音調,琴鍵繪制回黑色或白色。
這個“觸摸拖動”響應函數,和“鼠標拖動”響應函數不同的一點在於可以多點拖動。這里不是很好懂,我也不太好敘述出來,大家可以自己琢磨琢磨^ >< ^。
myAudio.js
這個文件里存的就是管理聲音的構造函數了,小伙伴們可以看一下,如何借助webAudiofontPlayer庫的api,進行聲音操作。
大家都知道,js可以用構造函數生成對象,在這里就可以用
1 var myAudio = new MyAudio();
這句來實現。
構造函數內部有一些內部變量,和一些函數。this.init函數就是在main.js中init()調用的聲音部分初始化函數,this.importScript會調用this.loadScript,完成引入並解碼音源文件的任務,this.setOrGetOctive可以設置或獲得當前的八度值,this.startNote和this.stopNote則是打開和關閉單一音調的。
最簡單的情況下,webAudiofontPlayer以下列方式實現音調的打開和關閉。
1 var AudioContextFunc = window.AudioContext || window.webkitAudioContext; 2 var audioContext = new AudioContextFunc(); 3 var player=new WebAudioFontPlayer(); 4 player.loader.decodeAfterLoading(audioContext,' 5 _tone_0250_SoundBlasterOld_sf2);//解碼 6 var a = player.queueWaveTable(audioContext, audioContext.destination 7 , _tone_0250_SoundBlasterOld_sf2, 0, 12*4+7, 2);//打開音調,最后面三個參數分別是起始播放時間,音調高低,音量
8 a.cancel();//關閉音調
音源文件的加載過程有可能花費一些時間,this.loadScript函數會在url指向的音源文件加載完成后再調用callback函數。我們可以再this.importScript函數中看到下面這段代碼:
1 this.loadScript('../sound/'+ tag + '_sf2_file.js', function() { 2 player.loader.decodeAfterLoading(audioContext, '_tone_' + tag + '_sf2_file'); 3 loadedInstruments[loadedInstruments.length] = tag; 4 document.getElementById('loading').style.display = 'none'; 5 });
在這里我們在this.importScript中調用了this.loadScript函數,在加載完成" '../sound/' + tag + '_sf2_file.js' "文件后執行后面的函數。后面的函數中,第一句是解碼剛剛加載的音源文件;第二句是將已經加載的樂器音源文件記錄在loadedInstruments數組中,待下次需要使用該樂器時避免重復加載;第三句是隱藏掉頁面上的loading標志,告知用戶資源加載完畢,可以使用了。
在myAudio.js中還有一個continuousTable,這個數組用來表示樂器的連續性問題。比如鼓,打擊一下只會相對瞬時響一聲,並且存在回聲;但要是口琴就會有一個時間延續問題。所以,如果樂器是連續的,我們可以先將播放時間設置為999秒,帶用戶抬起鼠標或鍵盤時使用cancel()方法,關閉音調;如果樂器是不連續的,我們可以規定一個時間,只要按下鍵盤或鼠標,即打開音調,時間到了自動停止,要使它再次打開需要再次激發。