我今天要和大家分享的是一个我自己写的音乐网页小程序,这个网页程序主要分为两个部分--即时演奏(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()方法,关闭音调;如果乐器是不连续的,我们可以规定一个时间,只要按下键盘或鼠标,即打开音调,时间到了自动停止,要使它再次打开需要再次激发。