跳转到内容

声音的分析和处理

刀刀

1/8/2025

0 字

0 分钟

效果展示

现有一个需求:在用户播放音频文件时,有相应的声波动画效果,如下图所示:

音频播放效果

前置知识

首先需要了解 audio.api 的相关知识,请注意,这不是写一个 audio 标签调用 play() 方法,这个方法是专门处理音频数据的。

想要了解这个 API 方法,需要了解两个核心概念:

  1. 音频上下文 AudioContent (即所有节点的集合,是一个环境,用于管理节点)
  2. 处理节点(即处理音频数据的环节)

requestAnimationFrame()

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

📝 备注

若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用 requestAnimationFrame()requestAnimationFrame() 是一次性的。

当你准备更新在屏动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame() 运行在后台标签页或者隐藏的 `` 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。

DOMHighResTimeStamp 参数会传入回调方法中,它指示当前被 requestAnimationFrame() 排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位为毫秒,最小精度为 1ms(1000μs)。

⚠️ 警告

请确保总是使用第一个参数(或其他一些获取当前时间的方法)来计算动画在一帧中的进度,否则动画在高刷新率的屏幕中会运行得更快。请参考下面示例的做法。

语法

js
requestAnimationFrame(callback)

参数:callback。当你的动画需要更新时,为下一次重绘所调用的函数。该回调函数会传入 DOMHighResTimeStamp 参数,该参数与 performance.now() 的返回值相同,它表示 requestAnimationFrame() 开始执行回调函数的时刻。

返回值:一个 long 整数,请求 ID,是回调列表中唯一的标识。是个非零值,没有别的意义。你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数请求。

AudioContext.createAnalyser()

AudioContextcreateAnalyser()方法能创建一个AnalyserNode,可以用来获取音频时间和频率数据,以及实现数据可视化。

语法

js
var audioCtx = new AudioContext();
var analyser = audioCtx.createAnalyser();

返回值

AnalyserNode对象

AnalyserNode.fftSize

AnalyserNode 接口的 fftSize 属性的值是一个无符号长整型的值,表示(信号)样本的窗口大小。当执行快速傅里叶变换(Fast Fourier Transfor (FFT))时,这些(信号)样本被用来获取频域数据。

fftSize 属性的值必须是从 32 到 32768 范围内的 2 的非零幂; 其默认值为 2048.

📝 备注

如果其值不是 2 的幂,或者它在指定范围之外,则抛出异常 INDEX_SIZE_ERR.

语法

js
var audioCtx = new AudioContext();
var analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;

值:一个无符号长整型。

AnalyserNode.getByteFrequencyData()

AnalyserNode接口的 getByteFrequencyData() 方法将当前频率数据复制到传入的 Uint8Array(无符号字节数组)中。

如果数组的长度小于 AnalyserNode.frequencyBinCount, 那么 Analyser 多出的元素会被删除。如果是大于,那么数组多余的元素会被忽略。

语法

js
var audioCtx = new AudioContext();
var analyser = audioCtx.createAnalyser();
var dataArray = new Uint8Array(analyser.frequencyBinCount); // Uint8Array 的长度应该和 frequencyBinCount 相等
analyser.getByteFrequencyData(dataArray); // 调用 getByteFrequencyData 方法填充 Uint8Array

返回值:一个 Uint8Array (en-US)(无符号字节数组).

效果实现

这个效果,是通过 canvas 画布画一个圆,然后通过绘制线的长度即可实现效果,如下图所示:

初步效果

现在重点是如何获取音频的数据。需要先点击 audio 标签才能播放音频,因此去监听其播放事件,监听事件做以下几件事情:

  1. 判断变量是否初始化,如果已经初始化则返回,不用再创建
  2. 创建音频上下文
  3. 使用 createAnalyser() 方法创建一个音频分析器节点
  4. 调用 getByteFrequencyData(数组) 方法,该方法可以获取附近一小段时间内音频的频率,因此需要不断调用该方法,把该方法提取出去。最后分析出来的结果会放到括号内的数组里
  5. 调用 fftSize 属性控制音频获取的频率精细程度
  6. 使用 createMediaElementSource() 方法建立音频来源节点
  7. 连接音频来源节点和音频分析节点
  8. 初始化变量设置为 true
  9. 最后拓展完善,把音频频率数组空的部分去掉,再通过计算让其视觉效果连贯

代码如下所示:

js
let isInit = false // 是否初始化
const analyser, buffer

audioElm.onplay = function() {
  // 如果已经初始化则不用继续往下执行
  if(isInit) return
  
  // 创建音频上下文
  const audioCtx = new AudioContent()
  // 创建分析节点
  analyser = audioCtx.createAnalyser()
  analyser.fftSize = 512
  // 创建保存音频频率的数组
  buffer = new Uinit8Array(analyser.frequencyBinCount)
  
  // 建立音频来源的信息,连接音频来源节点和音频分析节点
  const source = audioCtx.createMediaElementSource(audioElm)
  source.connect(analyser)
  
  // 音频播放连接到喇叭,播放给用户听
  analyser.connect(audioCtx.destination)
}

function update() {
  requestAnimationFrame(update)
  if(!isInit) return
  
  analyser.getByteFrequencyData(buffer)
  
  // 稍做处理,把音频数组内部分空的部分去除掉;并让数组整体长度为2倍,保存时对应首尾都保存,做对称保存
  const offest = Math.floor(buffer.length * 2 / 3)
  const datas = new Array(offest * 2)
  for(let i = 0; i < offest; i++) {
      datas[i] = datas[datas.length - 1 - i] = buffer[i]
  }
  console.log(buffer)
}
update()

拓展:获取麦克风数据

实际上 createMediaElementSource() 方法连接音频数据来源不仅仅可以连接 audo 标签的音频来源,也可以连接用户的麦克风数据。

基本步骤如下:

  1. 调用内置 API 方法 navigator.mediaDevices.getUserMedia() 获取麦克风数据(如果是第一次使用需要事先获取用户的麦克风权限)
  2. 修改音频来源连接方法 createMediaElementSource() 的参数为麦克风

修改后的代码如下:

js
navigator.mediaDevices.getUserMedia({audio: true}).then(stream => {
  // ...
  const source = audioCtx.createMediaElementSource(stream)
  source.connect(analyser)
  
  // analyser.connect(aduioCtx.destination)
  isInit = true
})

// ...