前言 最近在QQ音乐上听歌时,偶然发现他音乐页面下方有个跟随音乐律动的条纹,个人感觉很有意思,于是脑袋里想到能不能在Web上结合Aplayer
实现这个效果。虽然我不知道QQ音乐是如何实现这一效果,但我后续我去查了一下资料,其本质就类似于傅里叶变换
。 所谓傅里叶变换
(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 ; 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
,但不同浏览器可能有不同的限制,通常建议不超过8192
或16384
,否则性能可能受影响。而getByteFrequencyData(dataArray)
方法会将音频信号转换成由analyser.fftSize
定义的区间范围之内的数值,这些数值代表不同频率的音量强度。 最后一个属性analyser.smoothingTimeConstant
是Web Audio API
中AnalyserNode
的一个属性,用于控制频谱数据的平滑度。它的取值范围是0.0
到1.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) { audioElement = newAudioElement; sourceNode = audioContext.createMediaElementSource (audioElement); sourceNode.connect (analyser); analyser.connect (audioContext.destination ); draw (); } } ap.on ('play' , () => { const newAudio = ap.audio ; newAudio.crossOrigin = "anonymous" ; if (audioContext.state === 'suspended' ) { audioContext.resume (); } 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' ;
以下是本站的实际效果截图,可以自行在美化设置中修改主题色
完整过程 下面是完整的实现示例代码:
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 <div id="player" ></div> <canvas id ="visualizer" width ="1500" height ="500" > </canvas > <script src ="https://cdn.jsdelivr.net/npm/aplayer/dist/APlayer.min.js" > </script > 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 ; let audioElement = null ; const setupAudioVisualization = (newAudioElement ) => { if (!audioElement) { 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 (); } } ap.on ('play' , () => { const newAudio = ap.audio ; if (audioContext.state === 'suspended' ) { audioContext.resume (); } 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 ));