使用原生 JavaScript 创建音频波形可视化器

Avatar of Matthew Ström
Matthew Ström

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

作为一名 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() 方法,该方法返回 - 你猜对了 - 一个 ArrayBufferArrayBuffer 只是一个二进制数据的容器;它是 JavaScript 中移动大量数据的有效方式。
  • 然后,我们通过 decodeAudioData() 方法将 ArrayBuffer 发送到 audioContextdecodeAudioData() 接受一个 ArrayBuffer 并返回一个 AudioBufferAudioBuffer 是一个专门用于读取音频数据的 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() 函数的工作原理如下

  1. 从中心线开始,x = 0
  2. 绘制一条垂直线。使线的长度与数据成比例。
  3. 绘制一个宽度等于线段宽度的一半的圆。
  4. 绘制一条垂直线返回中心线。

大多数命令都很简单:ctx.moveTo()ctx.lineTo() 将海龟移动到指定的坐标,分别在不绘制或绘制时移动。

第 5 行,y = isEven ? -y : y,告诉我们的海龟从中心线向上或向下绘制。线段在中心线上方和下方交替绘制,以便形成平滑的波浪。在 Canvas API 的世界中,y 值比正值更靠上。 这有点违反直觉,所以请记住这一点,因为它可能是错误的来源。

在第 8 行,我们绘制了一个半圆。ctx.arc() 接受六个参数

  • 圆心的 xy 坐标
  • 圆的半径
  • 圆中开始绘制的位置(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)))。 此链过滤、归一化,最后绘制我们从服务器获取的音频。

如果一切按计划进行,您的页面应该如下所示

性能说明

即使经过优化,此脚本仍可能在浏览器中运行数十万次操作。 具体取决于浏览器的实现,这可能需要几秒钟才能完成,并且会对页面上发生的其它计算产生负面影响。 它还在绘制可视化之前下载整个音频文件,这会消耗大量数据。 我们可以通过几种方法来改进脚本以解决这些问题

  1. 在服务器端分析音频。 由于音频文件并不经常更改,因此我们可以利用服务器端计算资源来过滤和归一化数据。 然后,我们只需要传输较小的数据集; 不需要下载 mp3 来绘制可视化!
  2. 仅在用户需要时绘制可视化。 无论我们如何分析音频,最好将此过程推迟到页面加载完成很久之后。 我们可以在使用 交叉观察者 观察元素是否在视图中时等待,或者等到用户与播客播放器交互时再进行更长时间的延迟。
  3. 渐进增强。 在探索 Megaphone 的播客播放器时,我发现他们的可视化只是个幌子——它对每个播客都是相同的波形。 这可以作为我们(非常出色)设计的绝佳默认值。 使用渐进增强的原理,我们可以加载默认图像作为占位符。 然后,我们可以检查是否在启动脚本之前加载实际波形是有意义的。 如果用户禁用了 JavaScript、他们的浏览器不支持 Web Audio API 或他们设置了 save-data 头,则不会出现任何错误。

我也很想听听大家对优化的想法。

一些收尾想法

这是一种非常非常不切实际的音频可视化方法。 它在客户端运行,将数百万个数据点处理成相当简单的可视化。

但它很! 我在编写此代码时学到了很多东西,在编写这篇文章时学到了更多。 我重构了原始项目中的很多内容,并将整个内容缩减了一半。 像这样的项目可能永远不会进入生产代码库,但它们是发展新技能和更深入地了解现代浏览器支持的一些简洁 API 的独特机会。

希望本教程对您有所帮助。 如果您有关于如何改进它或主题的任何酷炫变体的想法,请与我联系! 我在 Twitter 上是 @ilikescience