前言

最近在QQ音乐上听歌时,偶然发现他音乐页面下方有个跟随音乐律动的条纹,个人感觉很有意思,于是脑袋里想到能不能在Web上结合Aplayer实现这个效果。虽然我不知道QQ音乐是如何实现这一效果,但我后续我去查了一下资料,其本质就类似于傅里叶变换
img-1
所谓傅里叶变换(Fourier Transform, FT),它是一种数学公式,它能够将时域信号转换为频域表示。简单来说,任何复杂的信号都可以拆分为不同频率的正弦波组合,而傅里叶变换就是用来完成这个分解的工具。它在各个领域都有广泛应用,本文只是做一个简单介绍,感兴趣的话可以自行搜索更深入的内容。
在音频处理中,我们主要使用快速傅里叶变换(FFT, Fast Fourier Transform),它是傅里叶变换的一种高效计算方法。那么现在问题来了,在Web中如何使用傅里叶变换?

在Web中使用傅里叶变换

其实具体过程也没那么复杂,在Web开发中,Web Audio API提供了AnalyserNode,它的内部实现了FFT,并且可以将音频数据转换为频谱数据。

1
2
3
4
5
6
7
8
9
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();

analyser.fftSize = 256; // 设置 FFT 变换大小

const bufferLength = analyser.frequencyBinCount; // 获取频率数据的数组长度
const dataArray = new Uint8Array(bufferLength); // 用于存储频率数据
analyser.getByteFrequencyData(dataArray); // 获取当前频谱数据
analyser.smoothingTimeConstant = 0.85; // 让波形更加平滑

其中,analyser.fftSize用于设置 FFT 变换大小,数字越大,区间范围越大,频谱数据就更精细,相对计算量也更大。但其数字大小必须是必须是2(如32,64,128,512等),且最大值是32768,但不同浏览器可能有不同的限制,通常建议不超过819216384,否则性能可能受影响。而getByteFrequencyData(dataArray)方法会将音频信号转换成由analyser.fftSize定义的区间范围之内的数值,这些数值代表不同频率的音量强度。
最后一个属性analyser.smoothingTimeConstantWeb Audio APIAnalyserNode的一个属性,用于控制频谱数据的平滑度。它的取值范围是0.01.0,默认值通常是0.8。在音频分析过程中,AnalyserNode.getByteFrequencyData(dataArray)方法会不断更新音频的频率数据(FFT计算后的结果)。由于音频信号是快速变化的,如果直接绘制FFT结果,可能会导致数据抖动太大,视觉效果过于跳跃。smoothingTimeConstant通过在前后帧之间进行平滑过渡,减少数据抖动,使动画更加流畅。

结合APlayer和Canvas进行音频可视化

既然已经了解了如何在Web中使用FFT,接下来就结合APlayer和Canvas来实现音频可视化。
🎵 实现流程解析
以下是实现音频可视化的主要逻辑:

1、获取音频数据

  • 监听APlayer播放事件
  • 使用createMediaElementSource连接到音频源
  • 通过AnalyserNode获取音频频谱数据

2、绘制频谱

  • 使用requestAnimationFrame()实现动态渲染
  • 计算音频数据对应的频谱条高度
  • 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

const ap = new APlayer({
container: document.getElementById('player'),
audio: [{
name: '音乐1',
artist: '歌手1',
url: './wdym.mp3',
cover: 'cover1.jpg'
},
{
name: '音乐2',
artist: '歌手2',
url: './hu.mp3',
cover: 'cover1.jpg'
}
]
});

const draw = () => {
requestAnimationFrame(draw);

analyser.getByteFrequencyData(dataArray); // 获取当前频谱数据
ctx.clearRect(0, 0, canvas.width, canvas.height);

const totalBars = bufferLength * 2;
const barWidth = canvas.width / totalBars;

for (let i = 0; i < bufferLength; i++) {
const barHeight = dataArray[i] * 0.5; // 等比调整频谱高度
const xLeft = canvas.width / 2 - i * barWidth;
const xRight = canvas.width / 2 + i * barWidth;

ctx.fillRect(xLeft, canvas.height - barHeight, barWidth, barHeight);
ctx.fillRect(xRight, canvas.height - barHeight, barWidth, barHeight);
}
}


const setupAudioVisualization = (newAudioElement) => {
if (!audioElement) {
// **第一次执行**(避免重复创建 sourceNode)
audioElement = newAudioElement;
sourceNode = audioContext.createMediaElementSource(audioElement);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
draw(); // 启动绘制
}
}
// 监听 APlayer 播放事件
ap.on('play', () => {
const newAudio = ap.audio; // 获取 APlayer 内部的 audio
newAudio.crossOrigin = "anonymous";
if (audioContext.state === 'suspended') {
audioContext.resume(); // 确保 AudioContext 启动
}
setupAudioVisualization(newAudio); // 只会执行一次
});

当然,以上代码有一个需要注意的地方,如果APlayer初始化时的音频链接是第三方的,且返回的链接响应头中没有access-control-allow-origin:*时,那么执行audioContext.createMediaElementSource(audioElement)时,就会提示跨域错误MediaElementAudioSource outputs zeroes due to CORS access restrictions,这是浏览器的策略问题所导致的。解决方法也很简单,就是在初始化APlayer时添加一个ap.crossOrigin = "anonymous"参数即可。

视觉优化

为了让频谱显示更加美观,可以进行以下优化:

  • 使用渐变颜色来美化视觉效果
  • 添加阴影让动画更具层次感

🔹颜色设置

1
2
3
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, 'rgb(57, 197, 187)');
ctx.fillStyle = gradient;

🔹阴影效果

1
2
ctx.shadowBlur = 15;
ctx.shadowColor = '#00ffcc';

以下是本站的实际效果截图,可以自行在美化设置中修改主题色
img-2
img-3
img-4

完整过程

下面是完整的实现示例代码:

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
//html 
<div id="player"></div>
<canvas id="visualizer" width="1500" height="500"></canvas>

//引入APayer
<script src="https://cdn.jsdelivr.net/npm/aplayer/dist/APlayer.min.js"></script>
//js
const ap = new APlayer({
container: document.getElementById('player'),
audio: [{
name: '音乐1',
artist: '歌手1',
url: './music1.mp3',
cover: './cover1.jpg'
},
{
name: '音乐2',
artist: '歌手2',
url: './music2.mp3',
cover: './cover2.jpg'
}
],
crossOrigin: "anonymous"
});

const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');

const audioContext = new(window.AudioContext || window.webkitAudioContext)();
const analyser = audioContext.createAnalyser();
analyser.smoothingTimeConstant = 0.85; // 让波形更加平滑

analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);

let sourceNode = null; // 用于存储唯一的 MediaElementSourceNode
let audioElement = null; // 记录当前 APlayer 内部的 audio

const setupAudioVisualization = (newAudioElement) => {
if (!audioElement) {
// **第一次执行**(避免重复创建 sourceNode)
audioElement = newAudioElement;
sourceNode = audioContext.createMediaElementSource(audioElement);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
draw(); // 启动绘制
}
}

const draw = () => {
requestAnimationFrame(draw);

analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);

const gap = 1;
const middleGap = 1; // 控制中间的间距
const totalBars = bufferLength * 2;
const totalGaps = totalBars - 1;
const barWidth = (canvas.width - totalGaps * gap - middleGap) / totalBars;
const centerX = canvas.width / 2;

const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, 'rgb(57, 197, 187)');
ctx.shadowBlur = 15;
ctx.shadowColor = '#00ffcc';
ctx.fillStyle = gradient;

for (let i = 0; i < bufferLength; i++) {
const barHeight = dataArray[i] * 0.5;
const topWidth = barWidth * (1 - (barHeight / canvas.height));

const xLeft = centerX - middleGap / 2 - (i * (barWidth + gap)) - barWidth;
const xRight = centerX + middleGap / 2 + (i * (barWidth + gap));

// 左侧条纹
ctx.beginPath();
ctx.moveTo(xLeft, canvas.height);
ctx.lineTo(xLeft + barWidth, canvas.height);
ctx.lineTo(xLeft + barWidth - topWidth / 2, canvas.height - barHeight);
ctx.lineTo(xLeft + topWidth / 2, canvas.height - barHeight);
ctx.closePath();
ctx.fill();

// 右侧条纹
ctx.beginPath();
ctx.moveTo(xRight, canvas.height);
ctx.lineTo(xRight + barWidth, canvas.height);
ctx.lineTo(xRight + barWidth - topWidth / 2, canvas.height - barHeight);
ctx.lineTo(xRight + topWidth / 2, canvas.height - barHeight);
ctx.closePath();
ctx.fill();
}
}

// 监听 APlayer 播放事件
ap.on('play', () => {
const newAudio = ap.audio; // 获取 APlayer 内部的 audio
if (audioContext.state === 'suspended') {
audioContext.resume(); // 确保 AudioContext 启动
}
setupAudioVisualization(newAudio); // 只会执行一次
});

const throttle = (fn, wait) => {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= wait) {
lastTime = now;
fn(...args);
}
};
}
window.addEventListener("resize", throttle(() => {
canvas.width = window.innerWidth > 1500 ? 1500 : window.innerWidth * 0.9;
}, 50));