声音的分析和处理
刀刀
1/8/2025
0 字
0 分钟
效果展示
现有一个需求:在用户播放音频文件时,有相应的声波动画效果,如下图所示:
前置知识
首先需要了解 audio.api
的相关知识,请注意,这不是写一个 audio
标签调用 play()
方法,这个方法是专门处理音频数据的。
想要了解这个 API 方法,需要了解两个核心概念:
- 音频上下文
AudioContent
(即所有节点的集合,是一个环境,用于管理节点) - 处理节点(即处理音频数据的环节)
requestAnimationFrame()
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
📝 备注
若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用 requestAnimationFrame()
。requestAnimationFrame()
是一次性的。
当你准备更新在屏动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame()
运行在后台标签页或者隐藏的 `` 里时,requestAnimationFrame()
会被暂停调用以提升性能和电池寿命。
DOMHighResTimeStamp
参数会传入回调方法中,它指示当前被 requestAnimationFrame()
排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位为毫秒,最小精度为 1ms(1000μs)。
⚠️ 警告
请确保总是使用第一个参数(或其他一些获取当前时间的方法)来计算动画在一帧中的进度,否则动画在高刷新率的屏幕中会运行得更快。请参考下面示例的做法。
语法
requestAnimationFrame(callback)
参数:callback
。当你的动画需要更新时,为下一次重绘所调用的函数。该回调函数会传入 DOMHighResTimeStamp
参数,该参数与 performance.now()
的返回值相同,它表示 requestAnimationFrame()
开始执行回调函数的时刻。
返回值:一个 long
整数,请求 ID,是回调列表中唯一的标识。是个非零值,没有别的意义。你可以传这个值给 window.cancelAnimationFrame()
以取消回调函数请求。
AudioContext.createAnalyser()
AudioContext
的createAnalyser()
方法能创建一个AnalyserNode
,可以用来获取音频时间和频率数据,以及实现数据可视化。
语法
var audioCtx = new AudioContext();
var analyser = audioCtx.createAnalyser();
返回值
AnalyserNode.fftSize
AnalyserNode
接口的 fftSize
属性的值是一个无符号长整型的值,表示(信号)样本的窗口大小。当执行快速傅里叶变换(Fast Fourier Transfor (FFT))时,这些(信号)样本被用来获取频域数据。
fftSize
属性的值必须是从 32 到 32768 范围内的 2 的非零幂; 其默认值为 2048.
📝 备注
如果其值不是 2 的幂,或者它在指定范围之外,则抛出异常 INDEX_SIZE_ERR.
语法
var audioCtx = new AudioContext();
var analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
值:一个无符号长整型。
AnalyserNode.getByteFrequencyData()
AnalyserNode
接口的 getByteFrequencyData()
方法将当前频率数据复制到传入的 Uint8Array(无符号字节数组)中。
如果数组的长度小于 AnalyserNode.frequencyBinCount
, 那么 Analyser 多出的元素会被删除。如果是大于,那么数组多余的元素会被忽略。
语法
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
标签才能播放音频,因此去监听其播放事件,监听事件做以下几件事情:
- 判断变量是否初始化,如果已经初始化则返回,不用再创建
- 创建音频上下文
- 使用
createAnalyser()
方法创建一个音频分析器节点 - 调用
getByteFrequencyData(数组)
方法,该方法可以获取附近一小段时间内音频的频率,因此需要不断调用该方法,把该方法提取出去。最后分析出来的结果会放到括号内的数组里 - 调用
fftSize
属性控制音频获取的频率精细程度 - 使用
createMediaElementSource()
方法建立音频来源节点 - 连接音频来源节点和音频分析节点
- 初始化变量设置为
true
- 最后拓展完善,把音频频率数组空的部分去掉,再通过计算让其视觉效果连贯
代码如下所示:
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
标签的音频来源,也可以连接用户的麦克风数据。
基本步骤如下:
- 调用内置 API 方法
navigator.mediaDevices.getUserMedia()
获取麦克风数据(如果是第一次使用需要事先获取用户的麦克风权限) - 修改音频来源连接方法
createMediaElementSource()
的参数为麦克风
修改后的代码如下:
navigator.mediaDevices.getUserMedia({audio: true}).then(stream => {
// ...
const source = audioCtx.createMediaElementSource(stream)
source.connect(analyser)
// analyser.connect(aduioCtx.destination)
isInit = true
})
// ...