闲话
作为从来没学成任何乐器的文艺中年,最近又心血来潮买了电子琴在家里玩,想着弹上一曲顺便能给娃娃熏陶些艺术气息(其实就是放着吃灰)。结果断断续续学些时间也还是弹奏生日快乐的水平……
后来有一天我突发奇想,既然我谈不好钢琴,不如让代码来帮我实现梦想吧。于是有了做这么个玩意的想法。
大致原理
- 使用 Vue 渲染 DOM 外观
- 使用 Audio API 实现声音的播放
- 找到钢琴简谱、配合定时器来进行钢琴的自动弹奏。
- 动态创建 DOM 通过 CSS 动画实现小球的滑动
项目示例
乐理知识
这里拿一张网上的《生日快乐》简谱来做个例子。
可以看到简朴中都是各种数字,但是有些数字上会有小点,这说明他们不在一个度上。再找一张我老婆学钢琴是用来贴钢琴的键贴就能知道意思了。
其实看到键贴就能理解,就是照着谱子顺序按相应按键就好了。我们的按钮是选的是从中间三组键位。
至于节拍、和弦这些高级的咱先不讨论。
代码实现
界面搭建
界面很简单,分为上下两部分。上半部分显示音轨,下半部分是钢琴的按键。
<div id="app">
<div class="piano">
<div class="piano-timeline">
<div
v-for="cfg in PIANO_MAPS"
:id="cfg.key"
class="piano-timeline-item"
>
<div
class="piano-timeline-item-line"
:class="cfg.key === pressKey ? 'piano-timeline-item-line-active':''"
></div>
<!-- <div class="piano-timeline-item-ball"></div> -->
</div>
<div class="piano-timeline-item-fire"></div>
</div>
<div class="piano-area">
<div
v-for="cfg in PIANO_MAPS"
class="piano-area-item"
:class="cfg.key === pressKey ? 'piano-area-item-key-active':''"
@click="clickKey(cfg.freq)"
>
<span class="piano-area-item-key">{{ cfg.key }}</span>
</div>
</div>
</div>
<button @click="playBirthday">生日快乐</button>
<button @click="playDark">可达鸭</button>
</div>
这里使用了 Vue 便于做列表渲染,代码中的 PIANO_MAPS 是按键的键位名及其音调(后面会用)。
const PIANO_MAPS = [
{ key: '1-', freq: 130.0 },
{ key: '2-', freq: 147.0 },
{ key: '3-', freq: 165.0 },
{ key: '4-', freq: 175.0 },
{ key: '5-', freq: 196.0 },
{ key: '6-', freq: 220.0 },
{ key: '7-', freq: 246.0 },
{ key: '1', freq: 262.0 },
{ key: '2', freq: 294.0 },
{ key: '3', freq: 330.0 },
{ key: '4', freq: 349.0 },
{ key: '5', freq: 392.0 },
{ key: '6', freq: 440.0 },
{ key: '7', freq: 494.0 },
{ key: '1+', freq: 523.0 },
{ key: '2+', freq: 587.0 },
{ key: '3+', freq: 659.0 },
{ key: '4+', freq: 698.0 },
{ key: '5+', freq: 784.0 },
{ key: '6+', freq: 880.0 },
{ key: '7+', freq: 988.0 },
]
键位音调信息从这里找到的。
钢琴键声音
既然有了钢琴那么就得有声音吧,于是用到了 Web Audio API 来播放声音。
// 创建新的音频上下文接口
const audioCtx = new AudioContext()
function clickKey(frequency) {
if (!frequency) {
return
}
// 创建一个OscillatorNode, 它表示一个周期性波形(振荡),基本上来说创造了一个音调
var oscillator = audioCtx.createOscillator()
// 创建一个GainNode,它可以控制音频的总音量
var gainNode = audioCtx.createGain()
// 把音量,音调和终节点进行关联
oscillator.connect(gainNode)
// audioCtx.destination返回AudioDestinationNode对象,表示当前audio context中所有节点的最终节点,一般表示音频渲染设备
gainNode.connect(audioCtx.destination)
// 指定音调的类型,其他还有square|triangle|sawtooth
oscillator.type = 'sine'
// 设置当前播放声音的频率,也就是最终播放声音的调调
oscillator.frequency.value = frequency
// 当前时间设置音量为0
gainNode.gain.setValueAtTime(0, audioCtx.currentTime)
// 0.01秒后音量为1
gainNode.gain.linearRampToValueAtTime(
1,
audioCtx.currentTime + 0.01,
)
// 音调从当前时间开始播放
oscillator.start(audioCtx.currentTime)
// 1秒内声音慢慢降低,是个不错的停止声音的方法
gainNode.gain.exponentialRampToValueAtTime(
0.001,
audioCtx.currentTime + 1,
)
// 1秒后完全停止声音
oscillator.stop(audioCtx.currentTime + 1)
}
如此就可以使用鼠标点击钢琴键来发出声音啦,试了试音调没有大问题。如此一个简单的钢琴算是完成了。
音乐播放
既然已经钢琴,那必然要弹奏一些曲子啊。这里我找了两首歌的曲谱,并将他们转化了一下。
生日快乐
const BIRTHDAY = [
'5',
'5',
'6',
'5',
'1+',
'7',
'',
'',
'5',
'5',
'6',
'5',
'2+',
'1+',
'',
'',
'5',
'5',
'5+',
'3+',
'1+',
'7',
'6',
'',
'',
'4+',
'4+',
'3+',
'1+',
'2+',
'1+',
]
可达鸭
const DARK = [
'5-',
'6-',
'1',
'',
'1',
'6-',
'1',
'2',
'',
'2',
'3',
'5',
'',
'5',
'3',
'5',
'3',
'',
'2',
'3',
'1',
'',
'1',
'6-',
'1',
'2',
'',
'2',
'1',
'6-',
]
转化方式就是网上找到钢琴简谱,将曲谱中的音调转成字符串即可。中间的空格用于音乐停顿。
而音乐播放的原理其实就是通过定时器逐步播放曲谱。
function play(song) {
const intervalId = setInterval(() => {
const value = song[time.value]
// 按下钢琴键,播放声音
setTimeout(() => {
pressKey.value = value
time.value++
clickKey(map.get(pressKey.value))
}, 800)
// 重置钢琴键
setTimeout(() => {
pressKey.value = ''
}, 1200)
if (time.value >= song.length) {
clearInterval(intervalId)
time.value = 0
pressKey.value = ''
}
}, 500)
}
可以在上面的演示示例中进行尝试。
小球滑动
最后是音轨部分,在音乐播放的同时音轨上的小球会自上而下移动,当小球移动到黄线时发出响应的声音。
这部分是通过 CSS 动画来实现的。
- 首先为每个音轨容器标注上 ID,ID 就是键位名称。
<div
v-for="cfg in PIANO_MAPS"
:id="cfg.key"
class="piano-timeline-item"
>
<div
class="piano-timeline-item-line"
:class="cfg.key === pressKey ? 'piano-timeline-item-line-active':''"
></div>
<!-- <div class="piano-timeline-item-ball"></div> -->
</div>
- 然后在音乐播放前 1 秒开始在目标音轨上创建小球 DOM 元素,并且在 2 秒后删除小球 DOM
function createBall(value) {
const el = document.getElementById(value)
if (!el) {
return
}
const ball = document.createElement('div')
ball.className = 'piano-timeline-item-ball'
el.appendChild(ball)
setTimeout(() => {
el.removeChild(ball)
}, 1800)
}
- 小球 DOM 自带 CSS 动画,创建后就会自动向下移动。
.piano-timeline-item-ball {
position: absolute;
left: calc(50% - 5px);
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #f56c6c;
animation: movingball 2s linear;
}
@keyframes movingball {
0% {
opacity: 0;
top: 0;
}
50% {
opacity: 1;
top: 100px;
}
100% {
opacity: 0;
top: 200px;
}
}
- 最后在播放音乐的时候,提前创建小球。
function play(song) {
const intervalId = setInterval(() => {
const value = song[time.value]
createBall(value)
setTimeout(() => {
pressKey.value = value
time.value++
clickKey(map.get(pressKey.value))
}, 800)
setTimeout(() => {
pressKey.value = ''
}, 1200)
if (time.value >= song.length) {
clearInterval(intervalId)
time.value = 0
pressKey.value = ''
}
}, 500)
}
最后
通过代码将自己想做的各种东西做出来的还是很有成就感的,每次写这种有趣的东西都乐此不疲。同时学习了一下 audio API,也算很有意义的一次经历了。