我们在听音乐的时候,可能会注意到音乐播放器里面总是会有各种各样,跟随着音乐节拍律动的特效。但是有没有思考过它是如何实现的呢?今天就来研究研究这个主题——音频可视化。

音频可视化的应用非常广泛,凡是将音频的信息通过视觉方式呈现出来的,都是音频可视化。比如我们使用录音软件时,呈现的曲线

或者测试设备麦克风🎤的时候显示的音量

甚至一些大型的音乐喷泉,舞台灯光效果等都可以算是音频可视化。

web 音频可视化依赖于 web audio 提供的 api,我在《Web Audio 概览》中已经分享过常用 api 的概念和用法,这篇文章就不再赘述。

声音信号

在写代码之前,我们先了解一下声音信号,以及可视化依赖的基础理论知识。

我们都知道,声音是通过物体振动产生的,它在空气中以波的形式传播,也称为声波。声音有三个要素:响度音调音色

原始的声音信号可以看作是一个连续的信号,我们可以通过一个余弦函数来研究声音信号。下面是余弦函数 y = cos(x)时域图像

上面的图像中,标出了周期和振幅,它们分别对应了三要素中的音调和响度:

  1. 周期(通常转换成频率使用)越短,频率越高则音调越高
  2. 振幅越大,响度就越大

还有一个音色,从时域分析(后面会从频域分析),它与波形有关。比如下面这个函数,他的波形和前一个看起来不太一样,对应的播放出来音色也会有区别。

声音信号的三个要素都携带了特定的信息,而当我们听音乐的时候,优美的旋律通常都是音调的组合、变化,所以对于音乐来说,声音信号的频率携带了音乐的关键信息。

所以在音频可视化的应用场景中,以频率的可视化居多,随着音乐节奏律动的效果也大都是基于声音的频率制作的。这篇文章也主要基于频率来介绍音频可视化。

那么我们应该如何使用这个频率呢?上面的图例是一个基本的模型,他只有一个频率,仅依靠这个数据是无法实现丰富的动效的,而声音的波形也会非常复杂,并且随时间变化。这时候就需要使用傅立叶变换了。

傅立叶变换

对于傅立叶变换,有兴趣的可以深入学习,这里我们只需要知道它是做什么的即可。

傅立叶变换(Fourier transform)是一种线性积分变换,用于信号在时域时域(或空域)和频域之间的变换,在(物理学)和(工程学)中有许多应用。

下面的图片可以看到,一个方波信号被拆分成多个正弦信号的叠加,这实际上涉及到了傅立叶变换的前置知识——傅里叶级数。

🔼图片来自:维基百科

上面引用了维基百科的例子,同时我找了一个静态的图片来理解傅立叶变换。它的作用就是通过一系列数学变换,得到一个函数的频域图像(频谱),频域图像即以频率为横坐标,系数为纵坐标的图形。

🔼图片来自:维基百科

通过傅立叶变换,我们就可以获得声音信号中包含的不同成分(谐波分量)的频率,也就是频谱,然后就可以使用频谱来制作动效了。

扩展:前面说到的音色,从频域分析,除了基波频率外,其它谐波分量不同,导致了音色的差异。它反映到时域就是波的形状差别。

获取音频的频谱

大概了解了傅立叶变换后,我们来看一下如何获取一个音频的频谱数据。web audio 提供了一个 AnalyserNode,它是 AudioNode 中的一种,我们可以使用 audioContext.createAnalyser() 来创建这个节点。

const ac = new AudioContext();
// 创建音频分析节点
const analyser = ac.createAnalyser();
analyser.fftSize = 2048;

// 使用正弦波做示例
const source = ac.createOscillator();
source.type = 'sine'
source.frequency = 440;

source.connect(analyser);

const bufferLength = this._analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);

// 获取频域数据
analyser.getByteFrequencyData(dataArray);

上面的代码中,analyser.fftSize 指定音频数据采样窗口大小,它的值必须是2的整数次方。analyser.frequencyBinCountfftSize 的一半,也是用来获取频谱数据的长度,因为信号的频谱具有对称性,我们只需要获取单边数据即可。

频谱数据分析

上面的代码通过一个 Uint8Array 来获取频谱数据,当然它还提供了支持 Float32Array 的 API ,我们可以根据需求选择。我们拿到的数据是一个数组,里面的每一项都是0-255的数字,它代表对应频率的幅值(响度,单位是 db),而数组下标则对应了频率范围。需要注意的是,下标不一定等于频率,它与采样率 audioContext.sampleRate 有关。

采样率

采样率又叫采样频率,指每秒的采样数量,它是模/数转换过程。我们存储的音频是以数字信号的形式存储的,需要将连续的模拟信号转换成离散的数字信号才能存储,这里面有一个重要的处理就是采样。下面是一个采样的示意图,将一个离散的单位脉冲序列与连续的信号相乘,就能得到一个保留了原始信号的波形的离散序列。

🔼图片来自:维基百科

可以看出来,脉冲序列越密集,保留的波形就越接近原始信号。也就是采样率越高,对声音的还原度就越高,音质也就越好。

在数字音频领域,常用的采样率有:

8,000 Hz - 电话所用采样率, 对于人的说话已经足够

11,025 Hz-AM调幅广播所用采样率

22,050 Hz和24,000 Hz- FM调频广播所用采样率

32,000 Hz - miniDV 数码视频 camcorder、DAT (LP mode)所用采样率

44,100 Hz - 音频 CD, 也常用于 MPEG-1 音频(VCD, SVCD, MP3)所用采样率

47,250 Hz - 商用 PCM 录音机所用采样率

48,000 Hz - miniDV、数字电视、DVD、DAT、电影和专业音频所用的数字声音所用采样率

50,000 Hz - 商用数字录音机所用采样率

96,000 或者 192,000 Hz - DVD-Audio、一些 LPCM DVD 音轨、BD-ROM(蓝光盘)音轨、和 HD-DVD (高清晰度 DVD)音轨所用所用采样率

2.8224 MHz - Direct Stream Digital 的 1 位 sigma-delta modulation 过程所用采样率。

我们在创建 AudioContext 的时候,可以指定采样率

const ac = new AudioContext({ sampleRate: 48000 });
console.log(ac.sampleRate); // 48000

audioContext.sampleRate 是只读属性,音频上下文创建后就不能再改变。我们可以在 Chrome 的devtool 中查看音频上下文的信息

频谱数据

了解完采样频率,我们回到前面的 dataArray ,调用 getByteFrequencyData(dataArray) 的时候,它会从 0 到 1/2 sampleRate 来填充数据。当 dataArray 的长度和 1/2 采样率不相等时,会进行缩放填充,我们拿到的数据都是包含了 0 Hz - 1/2 sampleRate Hz 的频率数据,dataArray 太短会丢失部分数据,太长的话会有多余的空间。具体的缩放算法可以查看 blink 源码

因此我们想要从获取到的 dataArray 中获取到特定频率的数据,需要进行简单的转换,比如下面的代码,我们要拿到1000 Hz的数据

const frequency = 1000; // 我们要获取 1000 Hz 的数据
const index = (fftSize * 0.5) * (1000 / (sampleRate * 0.5));
console.log(dataArray[index]);

到这里我们基本知道了如何获取音频的频谱数据,以及它的具体含义和转换关系。接下来就是怎么使用这个数据去做可视化。

我们拿到的数值是 8 位无符号整型,也即是 0 - 255 这个范围,在实际开发过程中会发现,有很大一部分频率的数值会恒定在255,尤其是低频部分。这是因为在取值的时候,响度范围太小导致的,AnalyserNode 中有两个属性 maxDecibel (默认值为 -30)和 minDecibel (默认值为 -100),它用来设置音量的上下限,单位是分贝(db)。大于 maxDecibel 的部分都是255,小于 minDecibel 的部分都是0,最大值为0。

如果遇到大部分数据都是255,或者0的情况,可以设置 maxDecibelminDecibel 来调整。

实例

可视化实际上非常简单,它的基本原理就是在音频播放的时候,每一帧画面都去获取音频此刻的频谱数据,然后再使用这些数据来绘制动画,或者控制某些元素。

常用 requestAnimationFrame来进行每一帧的处理,下面是一段简单的伪代码,跟着音乐节奏改变按钮的大小

const dataArray = new Uint8Array(bufferLength)
function update() {
  getByteFrequencyData(dataArray)
  const db = getDb(dataArray, 100) // 获取100赫兹的频率,也可以根据需求取一个范围
  button.style.transform = `scale(${1 + db / 255})`

  requestAnimationFrame()
}

requestAnimationFrame(update)

示波器

我在学习 web audio 的时候,为了方便理解,写了一个示波器组件,获取到数据后,以数组下标为横坐标,数值为纵坐标在 canvas 中绘制出每一帧的曲线,就得到了实时的波形图。

实现上需要一点 canvas 绘图的基础,以频谱图为例,按照前面的伪代码,可以先写出一个大致的框架

function update() {
  analyser.getByteFrequencyData(dataArray)
  const gap = 1; // 间隙
  const barWidth = 2; // 宽度
  canvas.width = (maxFrequency - minFrequency) * (gap + barWidth) // 根据频率范围计算宽度
  canvas.height = 300

  clearCanvas() // 清楚画布

  let x = 0;
  for (let i = minFrequency; i < maxFrequency; i++) {
    let barHeight = ((canvas.height - 40) * (dataArray[i] || 1)) / 255; // 计算高度
    canvasCtx.fillRect(x, (canvas.height - barHeight) / 2, barWidth, barHeight) // 绘制矩形
    x += barWidth + gap
  }
}

requestAnimationFrame(update)

核心的逻辑在于 canvas 绘图和数据的处理,根据场景的复杂程度,计算和绘图的逻辑也会有一些差异,比如下面的音乐播放动效。

音乐播放动效

基于上面的例子,把所有矩形改为小圆点(或者连成一条线),然后围个圈,就可以做一个简单的音乐动效,也是常见的音乐播放器动效的基本形态。

实现的难点主要在于粒子的绘制,遍历频谱数据后就是一顿计算,然后用 canvas 绘制出来,篇幅有限,就不讲具体的绘制逻辑了,有兴趣的朋友可以尝试一下自己实现。可以点击访问上面的例子。

总结

从应用层面看,音频可视化的原理并不复杂,复杂的是可视化的过程,也就是各种绘图、数据转换。其核心还是属于数据可视化领域,只不过依赖于音频知识和一些动画领域的知识。当然,如果想要更深入的话,音频涉及到的理论基础还是很复杂的,而可视化的终极目的就是化繁为简,把一堆枯燥的公式变成赏心悦目的特效。

参考资料

https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode

https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/AudioContext

https://yangwc.com/2020/04/11/Sampling1/