200行JS代码实现音程训练功能

236 阅读4分钟

为什么要开发

之前用苹果手机下载过一个叫Tenuto的app上面有一些关于音乐方面的训练,我经常在上面玩音程训练,后面换了安卓手机没有找到类似好的软件,老早就想自己实现这么一个功能, 拖延症晚期患者一直拖着没搞,最近一段时间空闲了,告诉自己一定的排上日程开发,查资料花了两天左右, 大致功能开发就花了一天,而这件事情拖了1年多o(///▽///),下面是微信小程序的音程训练页面

音程训练.png

什么是音程训练

音程,顾名思义:即音与音之间的距离,或是说高低关系,所谓的音程训练,就是训练辨别音与音之间的距离或者高低, 实操方面简单来说就是给你两个音,你需要分辨他们之间的距离,音乐上用来描述距离, 比如C~D之间是大二度, 知乎上这篇讲概念的文章不错链接

音程在数学或者物理上的基本规律

上面说到音程是音与音之间的距离,音乐上的音高是物理上音的频率高低,用物理的语言来说音程就是一个音的频率与另一个频率的差值,一个八度里面有12个半音, 升一个八度频率翻倍,比如频率上C4=2C3C_4 = 2C_3, 同时这12个半音均匀分到相应的频率,这就是巴赫的12平均律,他们成等比数列C4=2C3=C3 q12C_4 = 2C_3=C_3~q^{12} 可以计算出q=2112q=2^{\frac{1}{12}}, 现在如果已知某个音的半音数pitch1和音高h1,同时知道另外一个音的半音数pitch2, 如何计算另外一个音的音高h2

综上所述可以可以得到这样一个公式:

h2=h1(2112)pitch2pitch1h2=h1*(2^{\frac{1}{12}})^{pitch2 - pitch1}

代码如何实现

代码实现层面比较简单,大致分为2步, 第一步获取样本音把样本音放到audioContext的buffer里面,第二步根据样本音变换成需要的目标音,怎么变换成目标音可以根据上面的公式改变样本音的频率进行播放, 比如A4A^4是样本音频率为440HZ,半音数为48个,目标音的频率为:

h2=440(2112)pitch2124h2=440*(2^{\frac{1}{12}})^{pitch2 - 12 * 4}

下面说一下涉及的核心逻辑:

第一步:解析样本音

我这个的样本音是base64的数据, 下面是核心代码, zone是个对象, zone.sample为base64数据

WxAudioPlayer.prototype.adjustZone = function(audioContext, zone) {
  if (zone.buffer == null) {
    zone.delay = 0
    if (zone.sample) {
    // 把base64转二进制数据
      var decoded = atob(zone.sample)
    // 创建一个buffer空间, 数据只取一半  
      zone.buffer = audioContext.createBuffer(
        1, // channel的数量
        decoded.length / 2,
        zone.sampleRate
      )
    //  获取频道的数据空间, 这个里面的数据为 [-1, 1]之间, 
      var float32Array = zone.buffer.getChannelData(0)
      var b1, b2, n
    // 二进制的ASCII的范围为 0~256, 下面进行插值, 大致逻辑可以想象成 [-1, 1]对应成 [-128, 128]  
      for (var i = 0; i < decoded.length / 2; i++) {
        b1 = decoded.charCodeAt(i * 2)
        b2 = decoded.charCodeAt(i * 2 + 1)
        b1 = b1 < 0 ? b1 + 256 : b1
        b2 = b2 < 0 ? b2 + 256 : b2
        n = b2 * 256 + b1
        // 放到 256倍, 
        n = n >= (256 * 256) / 2 ? n - 256 * 256 : n
        // 转换成 [-1, 1]之间的数据
        float32Array[i] = n / (256 * 256)
      }
    }
  }
// ...
  zone.originalPitch = this.numValue(zone.originalPitch, 6000)
  zone.sampleRate = this.numValue(zone.sampleRate, 44100)
// ...
}

第二步: 根据样本音计算目标音

主要用到了audioBufferSourceNode.playbackRate这个对象, 这个是用来调整频率的, 比如设置成2, 就是把原来的频率升2倍

WxAudioPlayer.prototype.queueWaveTable = function(
  audioContext,
  target,
  preset,
  when,
  pitch,
  duration,
  volume
) {
  this.resumeContext(audioContext)
  volume = this.limitVolume(volume)
  var zone = this.findZone(audioContext, preset, pitch)
  /***
   * baseDetune代表原始音频的pitch, 这个pitch已经放大一百倍了
   *  乐理常识是一个8度是上一个8度的2倍, 一个8度里面有12个半音, 整个8度会均匀分到, 使得它成等比数列
   *  C*q12 = 2C, q = Math.pow(2, 1 / 12)
   * playRate为频率的倍数, 比如2倍,原始为440HZ, 现在为 880HZ
   * playRate = Math.pow(2, (pitch - originalPitch) / 12)
   * pitch - originalPitch为中间相隔的半音数量
   */
  var baseDetune = zone.originalPitch - 100.0 * zone.coarseTune - zone.fineTune
  var playbackRate = 1.0 * Math.pow(2, (100.0 * pitch - baseDetune) / 1200.0)
  // ...  
  envelope.audioBufferSourceNode = audioContext.createBufferSource()
  envelope.audioBufferSourceNode.playbackRate.setValueAtTime(playbackRate, 0)

  envelope.audioBufferSourceNode.buffer = zone.buffer

  envelope.audioBufferSourceNode.connect(envelope)
  envelope.audioBufferSourceNode.start(startWhen, zone.delay)
  envelope.audioBufferSourceNode.stop(startWhen + waveDuration)
  // ...
  return envelope
}

最后

我坦白了,老实交代 上面的代码不是我写, 是我在github上抄的,因为要做这么一个功能,我对audioContext都不是很熟练, 哈哈哈 ^-^ 下面是地址感兴趣的可以去看,大概2000多行代码,我抄了其中200多行代码

github.com/surikov/web…