本文有两个关键词:音频可视化
和Web Audio
。前者是实践,后者是其背后的技术支持。Web Audio 是很大的知识点,本文会将重点放在 如何获取音频数据 这块,对于其 API 的更多内容,可以查看 MDN。
另外,要将音频数据转换成可视化图形,除了了解 Web Audio 之外,还需要对 Canvas(特指 2D,下同),甚至 WebGL(可选)有一定了解。如果读者对它们没有任何学习基础,可以先从以下资源入手:
- Canvas Tutorial
- WebGL Tutorial
什么是音频可视化
通过获取频率、波形和其他来自声源的数据,将其转换成图形或图像在屏幕上显示出来,再进行交互处理。
云音乐有不少跟音频动效相关的案例,但其中有些过于复杂,又或者太偏业务。因此这里就现找了两个相对简单,但有代表性的例子。
第一个是用 Canvas 实现的音频柱形图。
↑点击播放↑
第二个是用 WebGL 实现的粒子效果。
↑点击播放↑
在具体实践中,除了这些基本图形(矩形、圆形等)的变换,还可以把音频和自然运动、3D 图形结合到一起。
点击查看:pinterest 上的一些视觉效果
什么是 Web Audio
Web Audio 是 Web 端处理和分析音频的一套 API。它可以设置不同的音频来源(包括
<audio>
节点、ArrayBuffer、用户设备等),对音频添加音效,生成可视化图形等。
接下来重点介绍 Web Audio 在可视化中扮演的角色,见下图。
简单来说,就是 取数据 + 映射数据 两个过程。我们先把“取数据”这个问题解决,可以按以下 5 步操作。
1. 创建 AudioContext
在音频的任何操作之前,都必须先创建 AudioContext。它的作用是关联音频输入,对音频进行解码、控制音频的播放暂停等基础操作。
创建方式如下:
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
2. 创建 AnalyserNode
AnalyserNode 用于获取音频的频率数据(FrequencyData)和时域数据(TimeDomainData)。从而实现音频的可视化。
它只会对音频进行读取,而不会对音频进行任何改变。
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
关于 fftSize,在 MDN 上的介绍可能很难理解,说是快速傅里叶变换的一个参数。
可以从以下角度理解:
1. 它的取值是什么?
fftSize 的要求是 2 的幂次方,比如 256、512 等。数字越大,得到的结果越精细。
对于移动端网页来说,本身音频的比特率大多是 128Kbps,没有必要用太大的频率数组去存储本身就不够精细的源数据。另外,手机屏幕的尺寸比桌面端小,因此最终展示图形也不需要每个频率都采到。只需要体现节奏即可,因此 512 是较为合理的值。
2. 它的作用是什么?
fftSize 决定了 frequencyData 的长度,具体为 fftSize 的一半。
至于为什么是 1 / 2,感兴趣的可以看下这篇文章:Why is the FFT“mirrored”?
3. 设置 SourceNode
现在,我们需要将音频节点,关联到 AudioContext 上,作为整个音频分析过程的输入。
在 Web Audio 中,有三种类型的音频源:
-
MediaElementAudioSourceNode 允许将
<audio>
节点直接作为输入,可做到流式播放。 - AudioBufferSourceNode 通过 xhr 预先将音频文件加载下来,再用 AudioContext 进行解码。
-
MediaStreamAudioSourceNode 可以将用户的麦克风作为输入。即通过
navigator.getUserMedia
获取用户的音频或视频流后,生成音频源。
这 3 种音频源中,除了 MediaStreamAudioSourceNode 有它不可替代的使用场景(比如语音或视频直播)之外。MediaElementAudioSourceNode 和 AudioBufferSourceNode 相对更容易混用,因此这里着重介绍一下。
MediaElementAudioSourceNode
MediaElementAudioSourceNode 将 <audio>
标签作为音频源。它的 API 调用非常简单。
// 获取 <audio> 节点
const audio = document.getElementById('audio');
// 通过 <audio> 节点创建音频源
const source = ctx.createMediaElementSource(audio);
// 将音频源关联到分析器
source.connect(analyser);
// 将分析器关联到输出设备(耳机、扬声器)analyser.connect(ctx.destination);
AudioBufferSourceNode
有一种情况是,在安卓端,测试了在Chrome/69
(不含)以下的版本,用 MediaElementAudioSourceNode 时,获取到的 frequencyData 是全为 0 的数组。
因此,想要兼容这类机器,就需要换一种预加载的方式,即使用 AudioBufferSourceNode,加载方式如下:
// 创建一个 xhr
var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/audio.mp3', true);
// 设置响应类型为 arraybuffer
xhr.responseType = 'arraybuffer';
xhr.onload = function() {var source = ctx.createBufferSource();
// 对响应内容进行解码
ctx.decodeAudioData(xhr.response, function(buffer) {
// 将解码后得到的值赋给 buffer
source.buffer = buffer;
// 完成。将 source 绑定到 ctx。也可以连接 AnalyserNode
source.connect(ctx.destination);
});
};
xhr.send();
如果将 AnalyserNode 类比中间件,会不会好理解一些?
可以对比一下常规的 <audio>
播放,和 Web Audio 中的播放流程:
4. 播放音频
对于 <audio>
节点,即使用 MediaElementAudioSourceNode 的话,播放相对比较熟悉:
audio.play();
但如果是 AudioBufferSourceNode,它不存在 play 方法,而是:
// 创建 AudioBufferSourceNode
const source = ctx.createBufferSource();
// buffer 是通过 xhr 获取的音频文件
source.buffer = buffer;
// 调用 start 方法进行播放
source.start(0);
5. 获取 frequencyData
到此,我们已经将音频输入关联到一个 AnalyserNode,并且开始播放音频。对于 Web Audio 这部分来说,它只剩最后一个任务:获取频率数据。
关于频率,Web Audio 提供了两个相关的 API,分别是:
analyser.getByteFrequencyData
analyser.getFloatFrequencyData
两者都是返回 TypedArray,唯一的区别是精度不同。
getByteFrequencyData 返回的是 0 – 255 的 Uint8Array。而 getFloatFrequencyData 返回的是 0 – 22050 的 Float32Array。
相比较而言,如果项目中对性能的要求高于精度,那建议使用 getByteFrequencyData。下图展示了一个具体例子:
关于数组的长度(256),在上文已经解释过,它是 fftSize 的一半。
现在,我们来看下如何获取频率数组:
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
需要注意的是,getByteFrequencyData 是对已有的数组元素进行赋值,而不是创建后返回新的数组。
它的好处是,在代码中只会有一个 dataArray 的引用,不用通过函数调用和参数传递的方式来重新取值。
可视化的两种实现方案
在了解 Web Audio 之后,已经能用 getByteFrequencyData 取到一个 Uint8Array 的数组,暂时命名为 dataArray。
从原理上讲,可视化所依赖的数据可以是音频,也可以是温度变化,甚至可以是随机数。所以,接下来的内容,我们只需要关心如何将 dataArray 映射为图形数据,不用再考虑 Web Audio 的操作。
(为了简化 Canvas 和 WebGL 的描述,下文提到 Canvas 特指 Canvas 2D
。)
1. Canvas 方案
点击查看:第 1 个示例的源码
Canvas 本身是一个序列帧的播放。它在每一帧中,都要先清空 Canvas,再重新绘制。
以下是从示例代码中摘取的一段:
function renderFrame() {requestAnimationFrame(renderFrame);
// 更新频率数据
analyser.getByteFrequencyData(dataArray);
// bufferLength 表示柱形图中矩形的个数
for (var i = 0, x = 0; i < bufferLength; i++) {
// 根据频率映射一个矩形高度
barHeight = dataArray[i];
// 根据每个矩形高度映射一个背景色
var r = barHeight + 25 * (i / bufferLength);
var g = 250 * (i / bufferLength);
var b = 50;
// 绘制一个矩形,并填充背景色
ctx.fillStyle = "rgb(" + r + "," + g + "," + b + ")";
ctx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
renderFrame();
对于可视化来说,核心逻辑在于:如何把频率数据映射成图形参数。在上例中,只是简单地改变了柱形图中每一个矩形的高度和颜色。
Canvas 提供了丰富的绘制 API,仅从 2D 的角度考虑,它也能实现很多酷炫的效果。类比 DOM 来说,如果只是 <div>
的组合就能做出丰富多彩的页面,那么 Canvas 一样可以。
2. WebGL 方案
点击查看:第 2 个示例的源码
Canvas 是 CPU 计算,对于 for 循环计算 10000 次,而且每一帧都要重复计算,CPU 是负载不了的。所以我们很少看到用 Canvas 2D 去实现粒子效果。取而代之的,是使用 WebGL,借助 GPU 的计算能力。
在 WebGL 中,有一个概念相对比较陌生——着色器。它是运行在 GPU 中负责渲染算法的一类总称。它使用 GLSL(OpenGL Shading Language)编写,简单来说是一种类 C 风格的语言。以下是简单的示例:
void main()
{gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
关于着色器更详细的介绍,可以查看这篇文章。
WebGL 的原生 API 是非常复杂的,因此我们使用 Three.js
作为基础库,它会让业务逻辑的编写变得简单。
先来看下整个开发流程中做的事情,如下图:
在这个过程中,uniforms 的类型是简单 Object,我们会将音频数组作为 uniforms 的一个属性,传到着色器中。至于着色器做的事情,可以简单理解为,它将 uniforms 中定义的一系列属性,映射为屏幕上的顶点和颜色。
顶点着色器和片元着色器的编写往往不需要前端开发参与,对于学过 Unity3D 等技术的游戏同学可能会熟悉一些。读者可以到 ShaderToy 上寻找现成的着色器。
然后介绍以下 3 个 Three.js 中的类:
1. THREE.Geometry
可以理解为形状。也就是说,最后展示的物体是球体、还是长方体、还是其他不规则的形状,是由这个类决定的。
因此,你需要给它传入一些顶点的坐标。比如三角形,有 3 个顶点,则传入 3 个顶点坐标。
当然,Three.js 内置了很多常用的形状,比如 BoxGeometry、CircleGeometry 等。
2. THREE.ShaderMaterial
可以理解为颜色。还是以三角形为例,一个三角形可以是黑色、白色、渐变色等,这些颜色是由 ShaderMaterial 决定的。
ShaderMaterial 是 Material 的一种,它由顶点着色器和片元着色器进行定义。
3. THREE.Mesh
定义好物体的形状和颜色后,需要把它们组合在一起,称作 Mesh(网格)。有了 Mesh 之后,便可以将它添加到画布中。然后就是常规的 requestAnimationFrame 的流程。
同样的,我们摘取了示例中比较关键的代码,并做了标注。
i. 创建 Geometry(这是从 THREE.BufferGeometry 继承的类):
var geometry = ParticleBufferGeometry({// TODO 一些参数});
ii. 定义 uniforms:
var uniforms = {
dataArray: {
value: null,
type: 't' // 对应 THREE.DataTexture
},
// TODO 其他属性
};
iii. 创建 ShaderMaterial:
var material = new THREE.ShaderMaterial({
uniforms: uniforms,
vertexShader: '', // TODO 传入顶点着色器
fragmentShader: '', // TODO 传入片元着色器
// TODO 其他参数
});
iv. 创建 Mesh:
var mesh = new THREE.Mesh(geometry, material);
v. 创建 Three.js 中一些必须的渲染对象,包括场景和摄像头:
var scene, camera, renderer;
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
camera = new THREE.PerspectiveCamera(45, 1, .1, 1e3);
scene = new THREE.Scene();
vi. 常规的渲染逻辑:
function animate() {requestAnimationFrame(animate);
// TODO 此处可以触发事件,用于更新频率数据
renderer.render(scene, camera);
}
小结
本文首先介绍了如何通过 Web Audio 的相关 API 获取音频的频率数据。
然后介绍了 Canvas 和 WebGL 两种可视化方案,将频率数据映射为图形数据的一些常用方式。
另外,云音乐客户端上线鲸云动效已经有一段时间,看过本文之后,有没有同学想尝试实现一个自己的音频动效呢?
最后附上文中提到的两段 codepen 示例:
- https://codepen.io/jchenn/pen…
- https://codepen.io/jchenn/pen…
本文发布自 网易云音乐前端团队,欢迎自由转载,转载请保留出处。我们一直在招人,如果你恰好准备换工作,又恰好喜欢云音乐,那就 加入我们!