- Notifications
You must be signed in to change notification settings - Fork153
🎹Play the piano with the keyboard - 用键盘8个键演奏一首蒲公英的约定送给自己或月亮代表我的心送给她
Wscats/piano
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
体验地址:http://wscats.gitee.io/piano/build/ 或者https://wscats.github.io/piano/build/
用键盘8个键演奏一首蒲公英的约定送给996的自己或者一首月亮代表我的心给七夕的她,非常简单~
这个项目仅仅用了几个简单的前端技术实现,献给每一位挚爱音乐的代码家🎹
如果你喜欢或者对你有帮助,给我点个赞支持下吧😊
开发,构建和运行。
# 获取远程仓库代码git clone https://github.com/Wscats/piano# 进入目录cd piano# 安装依赖npm install# 启动项目npm start# 在浏览器访问 http://localhost:3000
使用 npm 包管理器安装。
npm install omi-piano
运行或者发布属于自己的演奏版本。
# 进入目录cd omi-piano# 安装依赖npm install# 启动项目npm start# 发布自已的演奏版本npm run build
项目中没有使用市面主流的框架(React,Vue 和 Angular )和热门的技术,而用的是 Omi 框架(JSX+WebComponents
),还有Omil
的单文件组件SFCs
加载器,组件通讯基于Proxy
特性,并结合了 VScode 的插件Eno-Snippets
基于AST
和正则
实时编译.eno或.omi
后缀组件减轻部分的Webpack
的局部编译压力,当然其他同学们熟知的技术这里就不提及了。
- src
- assets
- element
- app-piano
- songs 钢琴简谱目录
- app-piano.eno 单文件组件
- app-piano.js 组件编译后的JS文件
- notes.js 键盘按键和音符的映射
- app-piano
- index.js 组件根容器,配置
Proxy
的通信方法
- public
- samples/piano 钢琴单音符素材
app-piano.eno | 开发中你需要编写的单文件组件 |
---|---|
app-piano.js | 经过Eno-Snippets 修改或者保存文件Hello.eno 后经过插件转化的js 文件 |
如右图,左边的代码是我们编写的.eno
后缀的单文件组件,右边是经过Eno Snippets
生成的.js
后缀文件。
首先我们先补习点音乐基础,提前收集好最基本的钢琴单音素材,每个音符对应一份.mp3
文件,用一个对象记录起来,类似下面这样,举个例子这里的A
指的是CDEFGAB
音名中A
也就是La
,这是最基本的乐理,有没有让你想起小时候上音乐课,画板上的五线谱。
exportdefault{A2:"./samples/piano/a54.mp3",A3:"./samples/piano/a69.mp3",A4:"./samples/piano/a80.mp3",A5:"./samples/piano/a74.mp3",A6:"./samples/piano/a66.mp3",'A#3':"./samples/piano/b69.mp3",'A#4':"./samples/piano/b80.mp3",'A#5':"./samples/piano/b74.mp3",'A#6':"./samples/piano/b66.mp3",// other...}
当然这里我们使用数字来等价替代,降低初学者的难度,看下表1
等价于C
中音也就是Do
,由于很多歌都会用到钢琴更密集的中间部分按键,所以我们默认中音对应数字键:
1 === C4 === Do
数字键 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
音名 | C4 | D4 | E4 | F4 | G4 | A4 | B4 |
音符 | Do | Re | Mi | Fa | Sol | La | Si |
这里专门制作一张图方便我们理解,请看右图:
当然实际情况还有全音和半音的区分,比如A
的半音就是A#
,还有中音,高音和倍高音,我们这里用A4
表示中音,A5
表示高音,A6
表示倍高音,所以表格可以继续整理得更清晰,当我们要弹奏中音A4
,只需要按键盘上的数字键6
,如果要弹奏高音A5
,只需要用组合键Option+6
,我们只需要举一反三,就可以知道每个音符对应的键盘按键。
倍低音 | C2 | D2 | E2 | F2 | G2 | A2 | B2 |
---|---|---|---|---|---|---|---|
Shift键+(1-7) | Shift+1 | Shift+2 | Shift+3 | Shift+4 | Shift+5 | Shift+6 | Shift+7 |
低音 | C3 | D3 | E3 | F3 | G3 | A3 | B3 |
Ctrl键+(1-7) | Ctrl+1 | Ctrl+2 | Ctrl+3 | Ctrl+4 | Ctrl+5 | Ctrl+6 | Ctrl+7 |
中音 | C4 | D4 | E4 | F4 | G4 | A4 | B4 |
数字键1-7 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
高音 | C5 | D5 | E5 | F5 | G5 | A5 | B5 |
Option键+(1-7) | Option+1 | Option+2 | Option+3 | Option+4 | Option+5 | Option+6 | Option+7 |
倍高音 | C6 | D6 | E6 | F6 | G6 | A6 | B6 |
Command键+(1-7) | Command+1 | Command+2 | Command+3 | Command+4 | Command+5 | Command+6 | Command+7 |
音符 | Do | Re | Mi | Fa | Sol | La | Si |
上面是全音表,这里附上半音表:
倍低半音 | C#2 | D#2 | F#2 | G#2 | A#2 |
---|---|---|---|---|---|
Shift+ | Shift+q | Shift+w | Shift+e | Shift+r | Shift+t |
低半音 | C#3 | D#3 | F#3 | G#3 | A#3 |
Ctrl+ | Ctrl+q | Ctrl+w | Ctrl+e | Ctrl+r | Ctrl+t |
中半音 | C#4 | D#4 | F#4 | G#4 | A#4 |
字母键 | q | w | e | r | t |
高半音 | C#5 | D#5 | F#5 | G#5 | A#5 |
Option+ | Option+q | Option+w | Option+e | Option+r | Option+t |
倍高半音 | C#6 | D#6 | F#6 | G#6 | A#6 |
Command+ | Command+q | Command+w | Command+e | Command+r | Command+t |
那么我们现在只需要用键盘上的5个字母键(q,w,e,r,t)
+ 4个功能键(Shift,Control,Option和Command)
+ 7个数字键(1,2,3,4,5,6,7)
总共16个键,演奏钢琴60个单音(35个全音+25个半音),实际情况一首简单的钢琴曲可以不需要用到那么多,用几个简单的和弦即可。
有上面的前期准备,下面就是转化为我们的编程知识了,我们需要使用 HTML 来绘制我们的钢琴界面,我们可以参考codepen 和codesandbox 的素材,这里我用了flex
布局配合阴影和过度实现钢琴的黑白键,里面用了 React 的 JSX 语法去遍历渲染黑白键。
<divclass="piano"> {this.data.pianoKeys.map((item)=>{return(<divclass="piano-key"><divdata-type="white"ref={e=>{ this[item.white.name] = e }} onClick={this.playNote.bind(this,item.white.name)} data-key={item.white.keyCode} data-note={item.white.name}><spanclass="piano-note">{item.white.name}</span><audiopreload="auto"src={this.data.notes[item.white.name]}hidden='true'data-note={item.white.name}class='audioEle'></audio></div><divdata-type="black"ref={e=>{ this[item.black.name] = e }} style={{ display: item.black.name ? 'block' : 'none' }} onClick={this.playNote.bind(this,item.black.name)} data-key={item.black.keyCode} data-note={item.black.name}><spanclass="piano-note"style="color:#fff">{item.black.name}</span><audiopreload="auto"src={this.data.notes[item.white.name]}hidden='true'data-note={item.white.name}class='audioEle'></audio></div></div> )})}</div>
可以观察 CSS 的源代码,分别对应写黑键和白键的样式,还可以另外写多一个样式,用于键盘或者鼠标点击琴键时候的效果,可以简单给它加一个背景色即可,整体实现不会太复杂,具体可以调整样式的参数来打造属于自己的钢琴风格。
.piano {margin:0200px;background:linear-gradient(-65deg,#000,#222,#000,#666,#22275%);border-top:.8rem solid#282828;box-shadow: inset0-1px1pxhsla(0,0%,100%,.5), inset-0.4rem0.4rem#282828;display: flex;height:80vh;height:20vh;justify-content: center;overflow: hidden;padding-bottom:2%;padding-left:2.5%;padding-right:2.5%;}.piano-key {color: blue;flex:1;margin:0.1rem;max-width:8.8rem;position: relative;}.piano-key__white {display: flex;flex-direction: column-reverse;background:linear-gradient(-30deg,#f8f8f8,#fff);box-shadow: inset01px0#fff, inset0-1px0#fff, inset1px00#fff, inset-1px00#fff,04px3pxrgba(0,0,0,.7), inset0-1px0#fff, inset1px00#fff, inset-1px-1px15pxrgba(0,0,0,.5),-3px4px6pxrgba(0,0,0,.5);height:100%;position: relative;}.piano-key__black {display: flex;flex-direction: column-reverse;background:linear-gradient(-20deg,#222,#000,#222);box-shadow: inset0-1px2pxhsla(0,0%,100%,.4),02px3pxrgba(0,0,0,.4);border-width:.2rem.4rem1.2rem;border-style: solid;border-color:#666#222#111#555;height:60%;left:100%;position: absolute;transform:translateX(-50%);top:0;width:70%;z-index:1;}
当我们实现完钢琴界面,我们就需要为每个按键匹配声音,这里使用 HTML5 的<audio>
标签,它可以装载着钢琴的音符,当我们触发鼠标点击事件或者键盘点击事件的时候,我们就让它播放,在钢琴没播放之前我们使用属性值preload="auto"
让其预加载。
<audiopreload="auto"src={this.data.notes[item.white.name]}hidden='true'data-note={item.white.name}class='audioEle'></audio>
播放只要用ref
属性获取琴音的节点,然后对其触发方法控制播放逻辑,audio.currentTime = 0
重置播放进度和audio.play()
执行播放,当触发播放的同时可以用延时器实现按键动画。
playNote(name){letaudio=this[name].childNodes[1]this[name].style.background=`linear-gradient(-20deg, #3330fb, #000, #222)`lettimer=setTimeout(()=>{this[name].getAttribute('data-type')==='white' ?this[name].style.background=`linear-gradient(-30deg, #f8f8f8, #fff)` :this[name].style.background=`linear-gradient(-20deg, #222, #000, #222)`clearTimeout(timer)},1000)audio.currentTime=0;audio.play();}
完成<audio>
的音频处理之后,就需要让键盘事件与其绑定逻辑了,这里需要了解键盘的keycode
,键盘每个实体按键都会对应有一个按键码,根据按键码用JS
键盘事件监听来判断按键是否被摁住。
我们使用window.document.onkeydown
来监听页面全局的键盘事件,然后判断事件对象e.altKey
,e.ctrlKey
,e.metaKey
和e.shiftKey
这四个功能键是否被触发,再判断数字键是否被触发,最后判断字母键是否被触发。
document.onkeydown=(event)=>{vare=event||window.event||arguments.callee.caller.arguments[0];letplayNote=(key)=>{if(e.shiftKey===true){this.playNote(`${key}2`)}elseif(e.altKey===true){this.playNote(`${key}5`)}elseif(e.ctrlKey===true){this.playNote(`${key}3`)}elseif(e.metaKey===true){this.playNote(`${key}6`)e.returnValue=false;}else{this.playNote(`${key}4`)}}if(e&&49<=e.keyCode&&e.keyCode<=55){switch(e.keyCode){case49:playNote('C')break;case50:playNote('D')break;case51:playNote('E')break;case52:playNote('F')break;case53:playNote('G')break;case54:playNote('A')break;case55:playNote('B')break;}}if(e&&(81===e.keyCode||e.keyCode===87||e.keyCode===69||e.keyCode===82||e.keyCode===84)){switch(e.keyCode){case81:playNote('C#')break;case87:playNote('D#')break;case69:playNote('F#')break;case82:playNote('G#')break;case84:playNote('A#')break;}}};
每自动按一个钢琴键,可以看到音符在下面跳动并自动高亮,这里面涉及钢琴组件和底部文字组件的通信。我们使用的是 Omi 自带的 Store 功能来实现组件的通信,本质上它是基于 Proxy 对数据进行劫持,当我们改变一个数据的时候,可以实时映射最新的状态到另外一个组件,从而完成组件的通信,这里我设置了一个count
和song
作为两个组件的通信值,count
记录的是点击到了第几个音符,而song
是正在播放的钢琴曲谱。
render(<my-app/>,'#root',{data:{count:0,song:[]},sub(){this.data.count--},add(){this.data.count++},setSong(song){// 构建新的数组,给它下标值来做索引letmelody=[];song.map((item,index)=>{melody.push({ ...item, index})})// 处理成每30个音符一个数组,自动播放时候自动显示按键for(letj=0;j<melody.length;j+=30){this.data.song.push(melody.slice(j,j+30))}}})
以下就是关于如何自动播放的逻辑,如果要演奏复杂的歌曲,特别是多和弦的情况下,我们可以编写好歌谱,然后交给编程自动演奏,下面是周杰伦《蒲公英的约定》
的钢琴简谱,我们用数组把每个按键的音符记录下来,然后只要用定时器或者递归把每个音符取出来给函数识别,然后再触发对应的<audio>
标签播放即可,这里解释下数组里面的每一项,如果字符串里面是数字的话就对应中音,也就是如果是'3'
,那就只需要按键盘的3
,如果是'+3'
那就是高音,那就是前面提到的用组合键option + 3
,如果是+1..
,那就是告诉编程,这里要停顿两个节拍,我们自己实际演奏的时候就在这里稍微停顿下控制旋律即可。
constsong=['3','4','5','5','5','6','7','+1..','+1','+1','7','+2','6','5','5','5','+2','+1','+1','+3','+3..','+1','+2','+3','+3','+4','+3','+2','+3','+1','+1','6','6','6','7','+1','+2','+2','+1','7','6','+4','+2',// 将愿望...'+2..','3','4','5',// 折飞机寄成信...'5','5','5','6','7','+1..','+1','+1','7','+2','6','5','5','5','+2','+1','+1','+3','+3..','+1','+2','+3','+3','+4','+3','+2','+3','+1','+1','6','6','6','7','+1','+2','+2','+1','7','6','+4','+2..',// 一起长大的约定...'3','5','+1','+3','+3.','+4','+2..','+2','+5','7','+1..','+3','+4','+5','+1','+1','+2','+3','+3..',// 说好要一起旅行...'3','5','+1.','+3','+3.','+4','+2..',// 是你如今...'+2','+5','7','+1..',// 唯一坚持的任性'+3','+4','+5','+1','+1','+2.','+1','+1',// 在走廊...'3','4','5','5','5','6','7','+1..','+1','+1','7','+2','6','5','5','5','+2','+1','+1','+3','+3..','+1','+2','+3','+3','+4','+3','+2','+3','+1','+1','6','6','6','7','+1','+2','+2','+1','7','6','+4','+2',// 一起长大的约定...'3','5','+1','+3','+3.','+4','+2..','+2','+5','7','+1..','+3','+4','+5','+1','+1','+2','+3','+3..',// 说好要一起旅行...'3','5','+1.','+3','+3.','+4','+2..',// 是你如今...'+2','+5','7','+1..',// 唯一坚持的任性...'+3','+4','+5','+1','+1','+2.','+1','+1',// 一起长大的约定...'+6','+5','+3','+2','+1','+3.','+4','+2..','+6','+5','7','+1..',// 与你聊不完的曾经...'+3','+4','+5','+1','+1','+2','+3','+3..',// 而我已经分不清...'3','5','+1','+3','+3.','+2','+2','+2..','+2','+5','7','+2','+1','+1',// 还是错过的爱情...'+3','+4','+5','+1','+1','+2.','+1','+1..']exportdefault[...song]
有了上面的数组,我们只需要编写一个递归函数函数来遍历数组,然后根据这种类数字的简谱,把它转为音符CDEFGAB
,一开始的时候我用了定时器实现读谱函数,后来发现,用定时器比较难控制,音符之间的停顿时间,相反用递归会比较容易实现,但是递归同样很难实现暂停播放功能,因为从外部中断递归函数也比较复杂,所以同学们如果要自己实现钢琴的话,在这个地方要稍微注意一下。下面代码中出现的Promise
配合await, async
和定时器就是接受一个时间变量,来控制音符之间的停顿时间,而外层if(offset < song.length && this.store.data.song.length > 0)
判断条件左边的条件是判断索引值要小于简谱数组的长度,右边就是外层传入的判断值作为递归函数的终止边界条件。
playSong(song){this.setSong([...song])letoffset=0lettime=0letplaySong=async()=>{// 右边是从外部来中断递归if(offset<song.length&&this.store.data.song.length>0){switch(typeofsong[offset]){// 简谱2演奏方法 根据 ++12345--6. 简单旋律情况case'string':letletters=song[offset].match(/[0-9]/g)switch(letters.length){case1:time=this.handleString(song,offset)breakdefault:time=this.handleStrings(song,offset)break}break// 简谱1演奏方法 根据 CDEFGAB,复杂旋律情况,比如有和弦case'object':console.log(song[offset]['note'])time=song[offset]['time'];this.playNote(song[offset]['note'])break;case'number':// 休止符switch(song[offset]){case0:time=1000break}break}awaitnewPromise((resolve)=>{lettimer=setTimeout(()=>{clearInterval(timer)resolve()},time)})offset++// 自定义事件,跟下面底部的音符自动跳动结合this.add()playSong()}else{// 暂停播放clearTimeout(this.timer)this.store.data.song=[]this.store.data.count=0return}}playSong()}
看完上面的数组简谱当然肯定会有同学问,上文的数组里面不止运用到8个键吧,如果仔细观察,就会发现这里只用了中音和高音,也就是纯数字键(1-7)
和Option
键的配合,连半音都没用到,所以实际止用到了8个键而已,所以上面给编程识别的简谱,转化我们人类识别的键盘谱,只需要稍微调整为下面的按键组合即可。
'3','4','5','5','5','6','7','Option+1..','Option+1','Option+1','7','Option+2','6','5','5','5','Option+2','Option+1','Option+1','Option+3','Option+3..','Option+1','Option+2','Option+3','Option+3','Option+4','Option+3','Option+2','Option+3','Option+1','Option+1','6','6','6','7','Option+1','Option+2','Option+2','Option+1','7','6','Option+4','Option+2',// 将愿望...'Option+2..','3','4','5',// 折飞机寄成信...'5','5','5','6','7','Option+1..','Option+1','Option+1','7','Option+2','6','5','5','5','Option+2','Option+1','Option+1','Option+3','Option+3..','Option+1','Option+2','Option+3','Option+3','Option+4','Option+3','Option+2','Option+3','Option+1','Option+1','6','6','6','7','Option+1','Option+2','Option+2','Option+1','7','6','Option+4','Option+2..',// 一起长大的约定...'3','5','Option+1','Option+3','Option+3.','Option+4','Option+2..','Option+2','Option+5','7','Option+1..','Option+3','Option+4','Option+5','Option+1','Option+1','Option+2','Option+3','Option+3..',// 说好要一起旅行...'3','5','Option+1.','Option+3','Option+3.','Option+4','Option+2..',// 是你如今...'Option+2','Option+5','7','Option+1..',// 唯一坚持的任性'Option+3','Option+4','Option+5','Option+1','Option+1','Option+2.','Option+1','Option+1',// 在走廊...'3','4','5','5','5','6','7','Option+1..','Option+1','Option+1','7','Option+2','6','5','5','5','Option+2','Option+1','Option+1','Option+3','Option+3..','Option+1','Option+2','Option+3','Option+3','Option+4','Option+3','Option+2','Option+3','Option+1','Option+1','6','6','6','7','Option+1','Option+2','Option+2','Option+1','7','6','Option+4','Option+2',// 一起长大的约定...'3','5','Option+1','Option+3','Option+3.','Option+4','Option+2..','Option+2','Option+5','7','Option+1..','Option+3','Option+4','Option+5','Option+1','Option+1','Option+2','Option+3','Option+3..',// 说好要一起旅行...'3','5','Option+1.','Option+3','Option+3.','Option+4','Option+2..',// 是你如今...'Option+2','Option+5','7','Option+1..',// 唯一坚持的任性...'Option+3','Option+4','Option+5','Option+1','Option+1','Option+2.','Option+1','Option+1',// 一起长大的约定...'Option+6','Option+5','Option+3','Option+2','Option+1','Option+3.','Option+4','Option+2..','Option+6','Option+5','7','Option+1..',// 与你聊不完的曾经...'Option+3','Option+4','Option+5','Option+1','Option+1','Option+2','Option+3','Option+3..',// 而我已经分不清...'3','5','Option+1','Option+3','Option+3.','Option+2','Option+2','Option+2..','Option+2','Option+5','7','Option+2','Option+1','Option+1',// 还是错过的爱情...'Option+3','Option+4','Option+5','Option+1','Option+1','Option+2.','Option+1','Option+1..'
我们还可以演奏另一首耳熟能详的的钢琴曲《月亮代表我的心》。
'Ctrl+5','1','3','5','1','Ctrl+7','3','5','5','6','7','Option+1','6','5','3','2','1','1','1','3','2','1','1','1','2','3','2','1','Ctrl+6','2','3','2','Ctrl+5','1','3','5','1','Ctrl+7','3','5','5','6','7','Option+1','6','5','3','2','1','1','1','3','2','1','1','1','2','3','2','1','Ctrl+6','2','3','2','3','5','3','2','1','5','Ctrl+7','Ctrl+6','Ctrl+7','Ctrl+6','Ctrl+7','Ctrl+6','Ctrl+5','3','5','3','2','1','5','Ctrl+7','Ctrl+6','Ctrl+7','1','1','1','2','3','2','Ctrl+5','1','3','5','1','Ctrl+7','3','5','5','6','7','Option+1','6','5','3','2','1','1','1','3','2','1','1','1','2','3','2','Ctrl+6','Ctrl+7','1','2','1','Ctrl+5','1','3','5','1','Ctrl+7','3','5','5','6','7','Option+1','6','5','3','2','1','1','1','3','2','1','1','1','2','3','2','1','Ctrl+6','2','3','2','Ctrl+5','1','3','5','1','Ctrl+7','3','5','5','6','7','Option+1','6','5','3','2','1','1','1','3','2','1','1','1','2','3','2','1','Ctrl+6','2','3','2','3','5','3','2','1','5','Ctrl+7','Ctrl+6','Ctrl+7','Ctrl+6','Ctrl+7','Ctrl+6','Ctrl+5','3','5','3','2','1','5','Ctrl+7','Ctrl+6','Ctrl+7','1','1','1','2','3','2','Ctrl+5','1','3','5','1','Ctrl+7','3','5','5','6','7','Option+1','6','5','3','2','1','1','1','3','2','1','1','1','2','3','2','Ctrl+6','Ctrl+7','1','2','1'
感谢音乐和编程的陪伴!也致敬各位奋斗于996的代码家,欢迎分享,也期待您贡献代码,提 PR ,在issue 中讨论问题,或者说说您的建议,音乐不曾辜负任何人,正如 Leehom Wang 歌曲中唱到:
如果世界太危险,只有音乐最安全,带着我进梦里面,让歌词都实现! —— 《我们的歌》
This project exists thanks to all the people who contribute.
Become a financial contributor and help us sustain our community.
Support this project with your organization. Your logo will show up here with a link to your website.
Omi Piano is released under theMIT