CG - Project 1: 音乐可视化
本项目利用 HTML5 Canvas,实现了对音乐旋律的可视化,使用 TypeScript 编写。
Computer Graphics @ Fudan University, fall 2021.
1 程序说明
1.1 线上 Demo
本项目利用 Vercel 部署了一个 线上 Demo,访问可能需要良好的网络环境。因为是线上 Demo,选择好音乐后需等待音乐加载完毕才能使用。
1.2 本地安装
执行 ./scripts/prebuild.sh 安装所有依赖,然后执行 ./scripts/build.sh 构建本项目。如果你使用的是 Windows,一种选择是使用 WSL,或者你也可以手动下载安装 Node.js,然后执行以下指令:
1 2 3 | |
1.3 如何使用
执行 ./scripts/start.sh 启动本地服务器,然后在浏览器打开 http://localhost:7070 即可访问。如果出现端口冲突,可以在 server.mjs 里指定 listenPort 为其他可用端口。如果你使用的是 Windows,则执行以下指令:
1 | |
启动后,点击左下角的「选择音乐」按钮,选择需要可视化的音乐,或者提供可用的 mp3 外链地址1,然后点击播放,即可看到可视化的画面。
执行 ./scripts/stop.sh 停止本地服务器。如果你使用的是 Windows,则执行以下指令:
1 | |
2 程序原理
核心代码参见 src/components/MusicVisualizer.vue,下面讲讲程序的主要思路。
2.1 音频分析
首先是初始化音频分析器 audioAnalyser,将其绑定到我们的音频源上,并设置一些参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
这里我们设定了 AnalyserNode.smoothingTimeConstant 的值为 \(0.8\),以对一定量的历史响度数据取平均,从而使每帧之间的过渡平滑一点。这个值的选取范围为 \([0,1]\) 内的浮点数,\(0\) 表示仅考虑当前一帧,不做任何平均化处理。
对于每一帧的音频,我们利用函数 AnalyserNode.getByteFrequencyData() 按频率将 \([0,f_s/2]\) 范围的音频线性地切割成 \(N/2\) 个频域,同时得到每个频域上音频的响度(默认范围为 \([-100,-30]\ \mathrm{Db}\))线性映射到整数域 \([0,255]\) 上的值。其中 \(f_s\) 即 context.sampleRate 为采样频率,其默认值取决于音频设备的系统设置,通常为 \(44100\ \mathrm{Hz}\)。\(N\) 即 FFT_SIZE 为 FFT 时的采样点数,本项目中取值为 \(256\)。
1 2 | |
也就是说,我们在这步会得到一个长度为 \(128\) 的数组 \(S\)(即 spectrum),数组中的值均为 \([0,255]\) 范围内的整数,其中 \(0\) 表示 \(-100\ \mathrm{Db}\),\(255\) 表示 \(-30\ \mathrm{Db}\),中间的值为线性映射。而数组索引则表示频域的范围,例如索引 \(0\) 就表示 \([0,172.27]\ \mathrm{Hz}\) 的频域,索引 \(i\) 就表示 \([172.27i,172.27(i+1)]\ \mathrm{Hz}\) 的频域2。
当然,懂乐理的朋友肯定知道,这个分割方式显然是不合理的。因为音频的音高和频率是呈对数关系而不是线性关系:音调每高一个八度,频率翻一倍。因此正确的分割方式应该是按指数关系切割,低频的索引对应的频域小一点,高频的索引对应的频域大一点,这样得到的可视化结果才是基于音调的。但 JS 提供的音频相关 API 就是这样设计的,你也没什么办法。
我尝试过将这个线性切割的数组重新映射到指数切割,但最后发现计算实在是比较复杂。主要难点在于要将不同频域的响度重新合并(不合并而是单纯采样的话,得到的值会非常小),而合并当然不是简单线性相加或者指数相加,而是要先将这个 \([0,255]\) 范围的整数映射回 \([-100,-30]\ \mathrm{Db}\) 范围的原始响度3,然后将这个响度按指数关系转化为原始的能量大小,将指定范围内的能量相加得到总能量,再转化回响度,最后映射到一个正的线性范围。为了精度还要提高 \(N\) 的值,至少要提高到 \(2048\),因此数组的长度也变成了 \(1024\)。考虑到这样的转化过程每帧都要做一次,可能会带来很多不必要的性能开销,想想还是算了,不给自己找麻烦了。
接下来,我们对这个数组 \(S\) 进行可视化,将每个数据点转化为相应的图形。
2.2 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 | |
首先我们在页面上生成一个铺满窗口的 canvas,然后根据这个 canvas 的宽度 \(w\) 和高度 \(h\) 决定生成多少个柱形、每个柱形的高度缩放比以及在 canvas 中的位置。这里我们设置每个柱形的宽度为 \(w_0\)(即 BAR_WIDTH,此处为 \(16\))、间隔为 \(\Delta w\)(即 BAR_GAP,此处为 \(5\))、最小高度为 \(h_{\min}\)(即 MIN_HEIGHT,此处为 \(2\))。
于是我们就可以算出 canvas 里可以平铺的柱形数量 \(n\)(即 barCount)
\[n = \lfloor \frac{w}{w_0+\Delta w} \rfloor\]
和每个柱形的高度缩放比 \(\phi\)(即 scale)
\[\phi = \frac{h}{N}\]
对于第 \(i\) 个柱形,我们根据 \(n\) 按线性比例划分其表示的频域范围 \([i\Delta N,\ (i+1)\Delta N)\),其中
\[\Delta N = \frac{|S|}{n} = \frac{N}{2n}\]
然后我们算出这个频域范围内的响度均值 \(A\)(即 barValue)
\[A = \frac{1}{\lceil \Delta N \rceil} \sum\limits\_{k=\lfloor i\Delta N \rfloor}^{\lceil (i+1)\Delta N \rceil} S_k\]
那么这个柱形的高度 \(h_i\)(即 barHeight)就可以设定为
\[h_i = \phi A\]
当这个频域内的响度达到最大值时,柱形就可以有 \(100\%\) 的 canvas 高度 \(h\)。
柱形的位置即为
\[(x_i,\ y_i) = (i(w_0+\Delta w),\ h-h_i)\]
其中原点为 canvas 的左上角。
如此依次渲染所有的柱形后,我们就得到了这一帧的柱状图。
这里我们调用函数 setFillStyle() 美化了一下输出,设定柱形的填充色为渐变的橙色。
1 2 3 4 5 6 7 8 9 | |
最后我们利用函数 requestAnimationFrame(render) 每秒调用 \(60\) 次这个渲染函数 render(),就得到了连贯的 \(60\ \mathrm{fps}\) 可视化动画。
可能存在跨域问题(CORS),请提供允许跨域的地址。
取 \(f_s=44100\),则有 \(44100/2/128 = 172.27\)。
当然,也可以利用函数 AnalyserNode.getFloatFrequencyData() 直接得到原始响度,但最后还是要映射回去。