基于WebAudio API的频率图实现

这个需求来自于小宇宙Studio的试听页面:试听页面本身除了需要支持播放之外,还有着倍速播放以及展示频率图的需求。好在WebAudio API本身内置的功能足以覆盖这些需求。

音频播放部分

浏览器内置播放音频有两种方式,一是使用html5标准中的<audio>标签内置的播放功能,二则是通过WebAudio API来播放。

WebAudio提供了一系列处理音频的能力。在音频处理方面,只要是在某个AudioContext中的Node就拥有着从上一个Node中拿到采样并且处理传递给下一个Node的能力;WebAudio API也有着多种AudioSourceNode,可以通过Stream、Buffer或者MediaElement创建出对应的AudioSourceNode。

在目前的场景下,需要播放的音频是通过URL下载的,因此我们可以通过<audio>标签来完成下载以及解码音频的工作,之后可以通过MediaElementAudioSourceNode来获取解码之后的数据。大致代码如下:

const audioElement: HTMLAudioElement = /* 获取到audio标签 */
const audioContext = new AudioContext()
const sourceNode = MediaElementSourceNode(audioElement)
sourceNode.connect(audioContext.destination)

这样以来,只要触发audio标签的播放,音频的数据不会直接流向正常的播放流程,而且发送到了我们的AudioContext中,这里我们直接将source node和AudioContext的destination相连,音频数据最终就输出到了声卡等其他地方变成声音播放了出来;使用audio标签作为音频的来源还有一个好处就是我们可以使用audio标签自带的诸如playback rate之类的控制,这样就不需要自己重复实现一次了。

获取频率数据

WebAudio API内置了AnalyserNode ,能够对输入的sample数据进行一些分析,例如能进行快速傅立叶变换从而获得输入sample的实时频率以及时域信息。只要指定了fftSize 然后调用对应API时传入一个Uint8Array 就可以获取这些数据了。简单的实现如下:

cconst audioElement: HTMLAudioElement = /* 获取到audio标签 */
const audioContext = new AudioContext()
const sourceNode = audioContext.createMediaElementSource(audioElement)
const analyserNode = audioContext.createAnalyser()
analyserNode.fftSize = 1024

// sourceNode -> analyserNode -> audiocContext.destination
sourceNode.connect(analyserNode)
analyserNode.connect(audioContext.destination)

const LENGTH = analyserNode.frequencyBinCount
const dataArray = new Uint8Array(LENGTH) 

// 每调用一次就会将当前的实时频率数据放入dataArray中
analyserNode.getByteFrequencyData(dataArray)

绘制频率图

有了频率数据后,就可以开始着手绘制频率图了。音频在播放时,每一个瞬间获取到的频率/时域数据都是不一样的,因此我们可以在requestAnimationFrame 函数的回调中通过getByteFrequencyData获取一次数据,并且基于获取到的Uint8Array来进行绘制;在音频领域一般频率信息是使用直方图进行绘制的,恰好小宇宙Studio的波形样式一定程度上也能看做直方图。

如同剪辑页面波形图在绘制上做的优化,这里也可以采用先绘制一堆矩形的path然后一次填充的方式来绘图,这样每一次绘制的步骤比较少;同时由于frequencyBinCount (其值为fftSize的一半)可能会比较大,而这里的频率图对精度没有要求,我们可以将数据分组求平均值然后使用平均值作为矩形的高度。

下面是绘制的代码:

const ctx = canvas.getContext('2d')
// BAR_WIDTH为矩形条的宽度,BAR_GAP为两个矩形条之间的间隔,均为定值
const barCount = Math.floor(CANVAS_WIDTH / (BAR_WIDTH + BAR_GAP))

const draw = () => {
  analyserNode.getByteFrequencyData(dataArray)
  const chunks = chunk(Array.from(dataArray), dataArray.length / barCount)
  
  ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
  ctx.fillStyle = BAR_COLOR
  ctx.beginPath()
  
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i]
    const x = i * (BAR_WIDTH + BAR_GAP)
    const avgFrequency = sum(chunk) / chunk.length
    const barHeight = calcBarHeight(avgFrequency, CANVAS_HEIGHT)
    const y = CANVAS_HEIGHT - barHeight
    ctx.rect(x, y / 2, BAR_WIDTH, barHeight)
  }
  
  ctx.closePath()
  ctx.fill()
  requestAnimationFrame(draw)
}

requestAnimationFrame(draw)

最后实际会发现效果不错,浏览器每一帧都会使用当前的实时绘制出图像,频率数据会随着音频实时变化,因此图像也会随着音频实时变化,自然又流畅。

附上这一套代码的架构图以及实际效果图:

试听页面频率图架构
频率图实际效果

如果你想亲自看看上面提到的频率图,可以访问这个页面 (密码是QUHY,点击右上角e可以切换到波形模式)。美中不足的是,由于部分WebKit内核WebAudio播放音频时会有杂音和卡顿,所以在这些浏览器(就不点名批评是谁了)上为了收听体验我们选择fallback到了通过audio播放,因此是没有显示波形图的。

最后, AnalyserNode 提供的能力除了上面提到的getByteFrequencyData之外还有其他很多可以使用的API,读到这里的大家可以试试使用 getByteTimeDomainData 获取时域信息,从而绘制类似示波器的图案。

SunskyXH

SunskyXH

Frontend Engineer
Earth