用代码实现自动播放音乐的钢琴

455 阅读5分钟

闲话

作为从来没学成任何乐器的文艺中年,最近又心血来潮买了电子琴在家里玩,想着弹上一曲顺便能给娃娃熏陶些艺术气息(其实就是放着吃灰)。结果断断续续学些时间也还是弹奏生日快乐的水平……

后来有一天我突发奇想,既然我谈不好钢琴,不如让代码来帮我实现梦想吧。于是有了做这么个玩意的想法。

大致原理

  • 使用 Vue 渲染 DOM 外观
  • 使用 Audio API 实现声音的播放
  • 找到钢琴简谱、配合定时器来进行钢琴的自动弹奏。
  • 动态创建 DOM 通过 CSS 动画实现小球的滑动

项目示例

乐理知识

这里拿一张网上的《生日快乐》简谱来做个例子。

image.png

可以看到简朴中都是各种数字,但是有些数字上会有小点,这说明他们不在一个度上。再找一张我老婆学钢琴是用来贴钢琴的键贴就能知道意思了。

screenshot-20231018-112809.png

其实看到键贴就能理解,就是照着谱子顺序按相应按键就好了。我们的按钮是选的是从中间三组键位。

至于节拍、和弦这些高级的咱先不讨论。

代码实现

界面搭建

image.png

界面很简单,分为上下两部分。上半部分显示音轨,下半部分是钢琴的按键。

    <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)
  }

可以在上面的演示示例中进行尝试。

小球滑动

最后是音轨部分,在音乐播放的同时音轨上的小球会自上而下移动,当小球移动到黄线时发出响应的声音。

image.png

这部分是通过 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,也算很有意义的一次经历了。

参考资料