音频可视化

其实从事前端开发以来没有碰到过这样到需求,只是在前段时间看到一些音乐播放器的界面很漂亮,就想 web 端能否实现这样的效果呢,网上找了一下,发现已经有了很多案例了,这里只是自己实践一下.

目标效果

201908151456.gif

基本原理

我们知道一般可视化都是基于数据实现的,那么音频可视化可以分为两个部分来实现:

  1. 将音频转换为数据(流);

  2. 用 canvas、svg 或者其他方式将拿到的数据进行可视化;

第二步不用多说,有一定的 canvas 或者 svg 基础问题不大,那问题是第一步怎么将音频转换为数据(流)呢,幸运的是 html 5 提供流相关 api,就是 Web Audio.

Web Audio API 使用户可以在音频上下文(AudioContext)中进行音频操作,具有模块化路由的特点。在音频节点上操作进行基础的音频, 它们连接在一起构成音频路由图。

代码实现

既然了解流基本原理,就开始实践吧.这里我没有使用第三方框架/库,没必要搞得很复杂.

AudioItem

AudioItem, 这个类应该有开始、暂停、停止、播放完成以及让外部获取一些状态,如:获取音频时长、当前播放时长、当前播放状态和当前需要绘制的音频图像等方法.具体看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class AudioItem {
constructor(callback) {
this._audioContext = new AudioContext();
this._callback = callback;
}

// 当前实例url
_url;

// 音频上下文对象
_audioContext;

// 音频源ArrayBuffer对象[只能播放一次]
_audioBufferSourceNode;

// 用于分析音频频谱的节点
_analyser;

// frequencyBinCount 的值固定为 AnalyserNode 接口中fftSize值的一半.
// 该属性通常用于可视化的数据值的数量
_bufferLength;

// 播放状态[0:加载中,1:播放中,2:已暂停,3:已结束,4:已停止]
_status = 0;

// 开始播放时间
_startAt = 0;

// 暂停时间
_pausedAt = 0;

// 音频时长
_duration = 0;

// 解码后一系列操作
_afterDecode() {
// 创建AudioBufferSourceNode 用于播放解码出来的buffer的节点
this._audioBufferSourceNode = this._audioContext.createBufferSource();
// 创建AnalyserNode 用于分析音频频谱的节点
this._analyser = this._audioContext.createAnalyser();
// 接口的fftSize属性AnalyserNode是无符号长值,表示在执行快速傅里叶变换(FFT)以获取频域数据时使用的样本中的窗口大小
this._analyser.fftSize = 512;
// 将不同的音频图节点连接在一起
this._audioBufferSourceNode.connect(this._analyser);
this._analyser.connect(this._audioContext.destination);
// bufferLength
this._bufferLength = this._analyser.frequencyBinCount;
//回调函数传入的参数
this._audioBufferSourceNode.buffer = this._buffer;
//部分浏览器是noteOn()函数,用法相同
this._audioBufferSourceNode.start(0, this._pausedAt / 1000);
// 音频时长
this._duration = this._audioBufferSourceNode.buffer.duration;
// 记录播放时间
this._startAt = new Date().getTime() - this._pausedAt;
// 重置暂停时间
this._pausedAt = 0;
// 更新播放状态
this._status = 1;
// callback
this._callback();
}

// 初始化
_init(val) {
if (val) {
let data = this._source.slice();
this._audioContext.decodeAudioData(data, (buffer) => {
// 暂停、快进后播放重新解码耗时验证,明显卡顿,所以缓存一下buffer
this._buffer = buffer;
this._afterDecode();
});
} else {
this._afterDecode();
}
}

// 获取音频资源
_getSource(url) {
fetch(url)
.then((res) => res.arrayBuffer())
.then((res) => {
this._source = res.slice();
this._init(1);
});
}

// 获取绘制图像的数据
getDataArray() {
// 检查下是否播放完成
let played = new Date().getTime() - this._startAt;
let total = this._duration * 1000;
if (played >= total) this.done();

// 返回新arraybuffer
let dataArray = new Uint8Array(this._bufferLength);
this._analyser.getByteFrequencyData(dataArray);
let tempArr = Array.from(dataArray);
let len = this._bufferLength / 2;
tempArr = tempArr.slice(0, len);
tempArr = tempArr.concat([...tempArr].reverse());
return Uint8Array.from(tempArr);
}

// 获取播放时长
getPlayed() {
let played = new Date().getTime() - this._startAt;
return played;
}

// 获取音频时长(秒)
getDuration() {
return this._duration;
}

// play
play(input) {
// url,如果当前实例url和传入url不一致则获取新的音频并自动播放
if (typeof input === "string") {
if (this._url !== input) {
this._getSource(input);
this._url = input;
return false;
} else {
this._init();
}
}
// 传入时间ms
else if (typeof input === "number") {
this.pause(input);
this._init();
} else {
this._init();
}
}

// pause(ms)
pause(time) {
time = time || new Date().getTime() - this._startAt;
this._audioBufferSourceNode.stop();
this._pausedAt = time;
this._status = 2;
}

// done
done() {
this._audioBufferSourceNode.stop();
this._pausedAt = 0;
this._startAt = 0;
this._status = 3;
}

// stop
stop() {
if (this._audioBufferSourceNode) {
this._audioBufferSourceNode.stop();
}
this._pausedAt = 0;
this._startAt = 0;
this._status = 4;
}

// 获取当前播放状态
getStatus() {
return this._status;
}
}

这里我们使用了 H5 的 AudioContext,具体资料可以到 MDN 上去了解,需要注意到有一下几个地方:

1、AudioBufferSourceNode 对象只能使用一次,所以每次暂停/停止后都要重新创建,之前都是在解码音频都回调中实现,发现音频解码耗时严重,会产生严重卡顿,所以后面将解码后的 buffer 缓存

2、已播放时长的问题,这个 MDN 上说的也不清除,后来在 stock overflow 上才找到解决方案.具体见代码注释.猜测实际播放时间(特别是暂停/停止后)为从播放开始的时间减去总暂停的时间,这个地方比较绕

3、音频 Uint8Array 的操作,因为 Uint8Array 也只能使用一次,所以每次都是使用当前 Uint8Array 的副本,相关 api 可以查看Uint8Array - JavaScript | MDN

CanvasItem

canvas 绘制图像,这里只暴露了两个方法,初始化和绘制,毕竟一进来什么都没有就比较奇怪,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class CanvasItem {
constructor(element) {
let canvas = document.querySelector(element);
canvas.width = canvas.offsetWidth;
this.ctx = canvas.getContext("2d");
canvas.height = canvas.offsetHeight;
this.r = canvas.width / 2;
this.d = canvas.width;
this._r = this.r * 0.7;
this.init();
}

init() {
let data = new Array(256).fill(0);
this.draw(data);
}

// 绘制柱状图
draw(data) {
let length = data.length;
this.ctx.clearRect(0, 0, this.d, this.d);
data.forEach((item, index) => {
let rotateArg = index * (360 / length) * (Math.PI / 180);
this.ctx.translate(this.r, this.r);
this.ctx.rotate(rotateArg);

this.ctx.fillStyle = "rgb(255, 0, 0)";
this.ctx.fillRect(0, this._r, 2, 5 + item / 4);

this.ctx.rotate(-rotateArg);
this.ctx.translate(-this.r, -this.r);
});
}
}

canvas 画图没什么好说都,这里只有一个很重要都点,canvas 旋转绘图,每次旋转绘图完成后要将中心点移动回之前都位置,否则图像会越来越乱.

项目地址

项目地址: https://github.com/qiangqiang93/audio-visualization

在线 demo: 音频可视化播放器 demo

参考文章:

Uint8Array - JavaScript | MDN

AudioContext - Web API | MDN

javascript - Web Audio API resume from pause - Stack Overflow

Canvas-图片旋转 - Mr.苏 - 博客园

web-audio-api 可视化音乐播放器,实现暂停切换歌曲功能,粉色系专场~ - 简书

[越努力,越幸运!]