作为一名 UI 设计师,我不断被提醒学习编码的价值。在设计用户界面时,我为团队中的开发人员考虑而感到自豪。但有时,我会踩到技术地雷。
几年前,作为 wsj.com 的设计总监,我帮助重新设计了《华尔街日报》的 播客目录。该项目中的一位设计师正在处理播客播放器,我偶然发现了 Megaphone 的嵌入式播放器。

我之前曾在 SoundCloud 工作,并且知道这些可视化对那些跳过音频的用户很有用。我想知道我们是否可以为《华尔街日报》网站上的播放器实现类似的外观。
来自工程的回答:绝对不行。鉴于时间安排和限制,该项目无法实现。我们最终发布了重新设计的页面,其中使用了更简单的播客播放器。

但我对这个问题很着迷。在晚上和周末,我努力尝试实现这种效果。我学到了很多关于如何在网络上处理音频的知识,最终使用不到 100 行 JavaScript 代码就实现了这种外观!
事实证明,这个例子是了解 Web Audio API 以及如何使用 Canvas API 可视化音频数据的完美方法。
但首先,让我们了解一下数字音频是如何工作的
在现实的模拟世界中,声音是一种波。当声音从声源(如扬声器)传播到您的耳朵时,它会压缩和解压缩空气,形成一种模式,您的耳朵和大脑会将其听到为音乐、语音或狗吠等。

但在计算机的电子信号世界中,声音不是波。为了将平滑的连续波转换为计算机可以存储的数据,计算机执行一个称为“采样”的过程。采样意味着每秒测量数千次击中麦克风的声波,然后存储这些数据点。当回放音频时,您的计算机会反转此过程:它会逐个重现声音,每次重现音频的极短时间段。

音频文件中的数据点数量取决于它的“采样率”。您可能以前见过这个数字;mp3 文件的典型采样率为 44.1 kHz。这意味着,对于每秒音频,都有 44,100 个单独的数据点。对于立体声文件,每秒有 88,200 个数据点 - 左声道 44,100 个,右声道 44,100 个。这意味着一个 30 分钟的播客有 158,760,000 个单独的数据点来描述音频!
网页如何读取 mp3?
在过去的九年中,W3C(负责维护网络标准的人员)开发了 Web Audio API 来帮助网络开发人员处理音频。Web Audio API 是一个非常深奥的主题;在这篇文章中,我们几乎不会触及它的表面。但一切都始于一个名为 AudioContext 的东西。
将 AudioContext 想象成一个用于处理音频的沙盒。我们可以使用几行 JavaScript 代码对其进行初始化
// Set up audio context
window.AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
let currentBuffer = null;
注释后的第一行是必要的,因为 Safari 将 AudioContext 实现了为 webkitAudioContext
。
接下来,我们需要为新的 audioContext
提供要可视化的 mp3 文件。让我们使用... fetch()
来获取它!
const visualizeAudio = url => {
fetch(url)
.then(response => response.arrayBuffer())
.then(arrayBuffer => audioContext.decodeAudioData(arrayBuffer))
.then(audioBuffer => visualize(audioBuffer));
};
此函数接受一个 URL,获取它,然后将 Response
对象转换几次。
- 首先,它调用
arrayBuffer()
方法,该方法返回 - 你猜对了 - 一个ArrayBuffer
!ArrayBuffer
只是一个二进制数据的容器;它是 JavaScript 中移动大量数据的有效方式。 - 然后,我们通过
decodeAudioData()
方法将ArrayBuffer
发送到audioContext
。decodeAudioData()
接受一个ArrayBuffer
并返回一个AudioBuffer
,AudioBuffer
是一个专门用于读取音频数据的ArrayBuffer
。你知道浏览器自带了所有这些方便的对象吗?当我开始这个项目的时候,我真的不知道。 - 最后,我们将
AudioBuffer
发送出去进行可视化。
过滤数据
为了可视化 AudioBuffer
,我们需要减少正在处理的数据量。就像我之前提到的,我们最初有数百万个数据点,但在最终的可视化中,我们将有更少的数据点。
首先,让我们限制正在处理的通道。通道代表发送到单个扬声器的音频。在立体声中,有两个通道;在 5.1 环绕声中,有六个通道。AudioBuffer
有一个内置方法来执行此操作:getChannelData()
。调用 audioBuffer.getChannelData(0)
,我们只剩下一个通道的数据。
接下来,最困难的部分:遍历通道的数据,并选择更小的一组数据点。我们可以通过几种方法来实现这一点。假设我想要最终的可视化有 70 个条形;我可以将音频数据平均分成 70 部分,然后查看每个部分中的一个数据点。
const filterData = audioBuffer => {
const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data
const samples = 70; // Number of samples we want to have in our final data set
const blockSize = Math.floor(rawData.length / samples); // Number of samples in each subdivision
const filteredData = [];
for (let i = 0; i < samples; i++) {
filteredData.push(rawData[i * blockSize]);
}
return filteredData;
}


结果让我大吃一惊!它看起来与我们模拟的可视化完全不同。有很多数据点接近于零或等于零。但这是有道理的:在播客中,单词和句子之间有很多沉默。通过仅查看每个块中的第一个样本,我们很有可能遇到一个非常安静的时刻。
让我们修改算法以查找样本的平均值。同时,我们应该取数据的绝对值,使其全部为正数。
const filterData = audioBuffer => {
const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data
const samples = 70; // Number of samples we want to have in our final data set
const blockSize = Math.floor(rawData.length / samples); // the number of samples in each subdivision
const filteredData = [];
for (let i = 0; i < samples; i++) {
let blockStart = blockSize * i; // the location of the first sample in the block
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum = sum + Math.abs(rawData[blockStart + j]) // find the sum of all the samples in the block
}
filteredData.push(sum / blockSize); // divide the sum by the block size to get the average
}
return filteredData;
}
让我们看看这些数据是什么样子。

这很棒。只剩下最后一件事要做:因为音频文件中有很多沉默,因此产生的数据点的平均值非常小。为了确保此可视化适用于所有音频文件,我们需要规范化数据;也就是说,更改数据的比例,使最响亮的样本测量值为 1。
const normalizeData = filteredData => {
const multiplier = Math.pow(Math.max(...filteredData), -1);
return filteredData.map(n => n * multiplier);
}
此函数使用 Math.max()
查找数组中最大的数据点,使用 Math.pow(n, -1)
取其倒数,然后将数组中的每个值乘以该数字。这保证了最大的数据点将被设置为 1,其余数据将按比例缩放。
现在我们有了正确的数据,让我们编写一个函数来可视化它。
可视化数据
为了创建可视化效果,我们将使用 JavaScript Canvas API。此 API 将图形绘制到 HTML 元素中。使用 Canvas API 的第一步类似于 Web Audio API。
const draw = normalizedData => {
// Set up the canvas
const canvas = document.querySelector("canvas");
const dpr = window.devicePixelRatio || 1;
const padding = 20;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = (canvas.offsetHeight + padding * 2) * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.translate(0, canvas.offsetHeight / 2 + padding); // Set Y = 0 to be in the middle of the canvas
};
此代码在页面上找到 <canvas>
元素,并检查浏览器的像素比(本质上是屏幕的分辨率),以确保我们的图形以正确的大小绘制。然后,我们获取画布的上下文(它的单个方法和值集)。我们计算画布的像素尺寸,将像素比考虑在内并添加一些填充。最后,我们更改 <canvas>
的坐标系,默认情况下(0,0)位于框的左上角,但我们可以通过将(0, 0)设置为左边的中间位置来节省大量数学运算。

现在让我们绘制一些线!首先,我们将创建一个函数来绘制单个线段。
const drawLineSegment = (ctx, x, y, width, isEven) => {
ctx.lineWidth = 1; // how thick the line is
ctx.strokeStyle = "#fff"; // what color our line is
ctx.beginPath();
y = isEven ? y : -y;
ctx.moveTo(x, 0);
ctx.lineTo(x, y);
ctx.arc(x + width / 2, y, width / 2, Math.PI, 0, isEven);
ctx.lineTo(x + width, 0);
ctx.stroke();
};
Canvas API 使用一种称为“海龟图形”的概念。想象一下,代码是给一只带有记号笔的海龟的一组指令。简单来说,drawLineSegment()
函数的工作原理如下
- 从中心线开始,
x = 0
。 - 绘制一条垂直线。使线的长度与数据成比例。
- 绘制一个宽度等于线段宽度的一半的圆。
- 绘制一条垂直线返回中心线。
大多数命令都很简单:ctx.moveTo()
和 ctx.lineTo()
将海龟移动到指定的坐标,分别在不绘制或绘制时移动。
第 5 行,y = isEven ? -y : y
,告诉我们的海龟从中心线向上或向下绘制。线段在中心线上方和下方交替绘制,以便形成平滑的波浪。在 Canvas API 的世界中,负 y 值比正值更靠上。 这有点违反直觉,所以请记住这一点,因为它可能是错误的来源。
在第 8 行,我们绘制了一个半圆。ctx.arc()
接受六个参数
- 圆心的 x 和 y 坐标
- 圆的半径
- 圆中开始绘制的位置(
Math.PI
或 π 是 9 点钟位置的弧度) - 圆中结束绘制的位置(弧度中的
0
表示 3 点钟位置) - 一个布尔值,告诉我们的乌龟是逆时针绘制(如果为
true
)还是顺时针绘制(如果为false
)。 在最后一个参数中使用isEven
表示我们将为偶数段绘制圆的上半部分——从 9 点钟顺时针到 3 点钟——,而为奇数段绘制下半部分。

好的,回到 draw()
函数。
const draw = normalizedData => {
// Set up the canvas
const canvas = document.querySelector("canvas");
const dpr = window.devicePixelRatio || 1;
const padding = 20;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = (canvas.offsetHeight + padding * 2) * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.translate(0, canvas.offsetHeight / 2 + padding); // Set Y = 0 to be in the middle of the canvas
// draw the line segments
const width = canvas.offsetWidth / normalizedData.length;
for (let i = 0; i < normalizedData.length; i++) {
const x = width * i;
let height = normalizedData[i] * canvas.offsetHeight - padding;
if (height < 0) {
height = 0;
} else if (height > canvas.offsetHeight / 2) {
height = height > canvas.offsetHeight / 2;
}
drawLineSegment(ctx, x, height, width, (i + 1) % 2);
}
};
在我们之前的设置代码之后,我们需要计算每条线段的像素宽度。 这是画布的屏幕宽度,除以我们想要显示的段数。
然后,一个 for 循环遍历数组中的每个条目,并使用我们之前定义的函数绘制一条线段。 我们将 x 值设置为当前迭代的索引,乘以段宽度。 height,段的所需高度来自将我们的归一化数据乘以画布的高度,减去我们之前设置的填充。 我们检查一些情况:减去填充可能会将 height
推入负数,因此我们将其重新设置为零。 如果段的高度会导致绘制一条超出画布顶部的线,我们将高度重新设置为最大值。
我们传入段宽度,对于 isEven
值,我们使用了一个巧妙的技巧:(i + 1) % 2
表示“查找 i + 1
除以 2 的余数”。 我们检查 i + 1
因为我们的计数器从 0 开始。 如果 i + 1
是偶数,它的余数将为零(或假)。 如果 i
是奇数,它的余数将为 1
或真。
就这样。 让我们把所有内容放在一起。 这是完整的脚本,及其所有功能。
在 drawAudio()
函数中,我们在最终调用中添加了一些函数:draw(normalizeData(filterData(audioBuffer)))
。 此链过滤、归一化,最后绘制我们从服务器获取的音频。
如果一切按计划进行,您的页面应该如下所示

性能说明
即使经过优化,此脚本仍可能在浏览器中运行数十万次操作。 具体取决于浏览器的实现,这可能需要几秒钟才能完成,并且会对页面上发生的其它计算产生负面影响。 它还在绘制可视化之前下载整个音频文件,这会消耗大量数据。 我们可以通过几种方法来改进脚本以解决这些问题
- 在服务器端分析音频。 由于音频文件并不经常更改,因此我们可以利用服务器端计算资源来过滤和归一化数据。 然后,我们只需要传输较小的数据集; 不需要下载 mp3 来绘制可视化!
- 仅在用户需要时绘制可视化。 无论我们如何分析音频,最好将此过程推迟到页面加载完成很久之后。 我们可以在使用 交叉观察者 观察元素是否在视图中时等待,或者等到用户与播客播放器交互时再进行更长时间的延迟。
- 渐进增强。 在探索 Megaphone 的播客播放器时,我发现他们的可视化只是个幌子——它对每个播客都是相同的波形。 这可以作为我们(非常出色)设计的绝佳默认值。 使用渐进增强的原理,我们可以加载默认图像作为占位符。 然后,我们可以检查是否在启动脚本之前加载实际波形是有意义的。 如果用户禁用了 JavaScript、他们的浏览器不支持 Web Audio API 或他们设置了
save-data
头,则不会出现任何错误。
我也很想听听大家对优化的想法。
一些收尾想法
这是一种非常非常不切实际的音频可视化方法。 它在客户端运行,将数百万个数据点处理成相当简单的可视化。
但它很酷! 我在编写此代码时学到了很多东西,在编写这篇文章时学到了更多。 我重构了原始项目中的很多内容,并将整个内容缩减了一半。 像这样的项目可能永远不会进入生产代码库,但它们是发展新技能和更深入地了解现代浏览器支持的一些简洁 API 的独特机会。
希望本教程对您有所帮助。 如果您有关于如何改进它或主题的任何酷炫变体的想法,请与我联系! 我在 Twitter 上是 @ilikescience。
这太棒了! 我以前也研究过这个想法,你的实现比我做得好得多。 你关于性能的说明也是我发现的问题所在,也是我项目的阻碍因素。 相反,我想到的是比 Megaphone 好一步的方案,我会动态生成一个随机波形。 查看我的初始模型:https://codepen.io/andrewscofield/pen/oGyrEv
当我向真实用户展示这一点时,我很遗憾地证实了我的怀疑……他们不像我那样关心波形的准确性。 特别是当我去追求流行的厚实的线条风格时,它本来就不准确。
我做的一个额外的步骤是将生成的随机波形发送回服务器并将其保存到数据库中。 这样它就只运行一次,之后便被缓存。 虽然脚本本身在性能方面相当不错,但我还是想保持一致的波形
Matthew,感谢你的分享! 非常酷的主题,终于有点新鲜感了。 它让我想起我们如何在 Amiga 的汇编语言中做同样的事情。 再次感谢你,祝你今天过得愉快! R.
你将数字音频文件描述为“粗略地重新创建平滑的连续波”,但实际上并非如此。 数字音频文件能够完美地重新创建任何频率等于或小于采样率一半的声音(不包括常用的有损压缩技术)。
除此之外,好文章! 我可能很快就会在我的网站上用到它。
我目前正在使用多个画布来实时模拟沙滩车数据,它必须处理数千个点。 当使用它时,CPU 始终保持在 100%,我一直在尝试寻找降低负载的方法。
网站:http://adam.teaches.engineering
我的天哪……这篇文章太棒了!
我对 javascript 还比较陌生,你成功地通过清晰的解释说明了非常复杂的内容。 我只有一个问题。 当我第一次搜索关于 Audio Web API 的文档时,我偶然发现了 AnalyserNode 的 getFloatTimeDomainData()。 你没有使用这种方法是否有特殊原因?
无论如何,再次感谢你的精彩讲解。
这真的很有帮助,感谢你分享!
如果其他人需要,我已经将
Buffer => normalizedData
放入了一个名为audioform
的 npm 包 中。它旨在用于 NodeJS,以便在服务器/构建过程中准备数据。
就像 Dewitte 一样,我也很好奇你为什么没有使用 getFloatTimeDomainData()? 我认为它可以以更好、更高效的方式创建图形。
https://mdn.org.cn/en-US/docs/Web/API/AnalyserNode/getFloatTimeDomainData
https://mdn.org.cn/en-US/docs/Web/API/Web_Audio_API/Using_Web_Audio_API