创建一个可以动态处理多个音频源的音频可视化器 - 全部使用 Vanilla JS!
我最近的一个项目是制作一个音频可视化工具,以此来深入探索数据可视化的世界。市面上有很多关于如何编写音频可视化工具的指南,甚至包括 Vanilla JS 的指南,但我没能找到一个能详细阐述如何接收多个声音输入的指南,而这正是我的项目(一个可分层的音景混音器)的必要功能。此外,输入需要动态变化——用户可以随意添加或删除声音,而可视化工具需要实时反映这些变化。我将逐步向您介绍我解决这个问题的方法。
首先,我会提供我用于可视化工具本身的主要资源链接。为了了解音频上下文如何在 JS 中使用Web Audio API,我参考了这个CodePen来制作一个简单的单源水平可视化工具。在完成并运行后,我决定将可视化工具的形状重写为环绕圆形。为此,我参考了这份分步指南。我将重点介绍该实现,因为它是我用来实现接收多个源的实现。
注意 //我毫不怀疑这不是在浏览器中实现可视化工具的最有效方法。一旦添加了多个音频源,或者通常添加了较大文件,这对客户端来说是一个相当大的负担。尽管如此,这仍然是可以实现的,而且考虑到不需要任何软件包或框架,我认为这非常酷。
就上下文而言,所有声音都与我的程序中的特定花朵对象相关联,以防您对某些变量名称的花卉主题感到好奇。
让我们首先看看声音是如何产生的。
function createSound (flower) {
const sound = document.createElement('audio');
sound.id = flower.name; // set ID of sound to use as a key for global obj
sound.src = `./sounds/${flower.sound}.mp3`; // set source to locally stored file
sound.crossOrigin = "anonymous"; // avoid a CORS error
sound.loop = "true"; // sounds need to loop to the beginning after they end
sound.dataset.action = "off"; // for pausing feature
document.getElementById("audio-container").append(sound); // append sound to HTML container
allSoundsById[sound.id] = sound; // add to global object for later use
return sound; // return sound to parent function
}
当页面加载时将声音呈现到页面上时,createSound
会在开始时调用该函数来创建 HTML<audio>
标签并填充一个全局数组,该数组使用 id(在本例中为相关花朵的名称)作为键,使用元素作为值。
每朵花都有一个“click”事件监听器,它会先播放声音,然后调用renderVisualizer
函数将当前正在播放的声音数据显示到页面上。接下来我们来看一下这个函数。
在深入探讨如何接收多个声音输入的细节之前,我想先介绍一下可视化工具的设置方法。它绘制在一个 HTML5 Canvas 元素上,当动画帧渲染时,会在中心绘制一个圆圈。它被均等地分成固定数量的部分,每个部分的数量等于可视化工具的条形数量。每个条形都与一些频率数据相关联,每次渲染动画帧时,其高度都会根据声音的变化而变化。因此,宽度是固定的,而高度代表了声音不断变化的频率信息(正是它让声音动起来!)。如果您想更深入地了解其工作原理,请参考文章末尾链接的我的资源。
首先,让我们访问页面上的 Canvas 元素。这只是一个 HTML 元素,您可以选择在脚本文件中创建,也可以选择在 HTML 中准备好。我选择了后者。之后,您需要获取 HTML Canvas 的上下文——我们处理的是 2D(而不是 3D)。请注意,这canvasContext
就是我们将要绘制的内容——canvas
它相当于 DOM 元素。
function renderVisualizer () {
// Get canvas
const canvas = document.getElementById("vis");
const canvasContext = canvas.getContext("2d");
接下来,我们需要为每种声音创建音频上下文。这使我们能够访问所有这些精彩的数据。我之前提到过,所有声音都存储在一个全局对象中以供后续使用——这就是我们将要用到它的地方!对于对象中的每个声音键值对,我都会创建另一个具有相同键的对象,并将必要的信息设置为值:
Object.keys(allSoundsById).forEach((id) => {
// condition to avoid creating duplicate context. the visualizer won't break without it, but you will get a console error.
if (!audioContextById[id]) {
audioContextById[id] = createAudioContextiObj(allSoundsById[id])
}
})
...这是createAudioContextObj
函数:
function createAudioContextiObj (sound) {
// initialize new audio context
const audioContext = new AudioContext();
// create new audio context with given sound
const src = audioContext.createMediaElementSource(sound);
// create analyser (gets lots o data bout audio)
const analyser = audioContext.createAnalyser();
// connect audio source to analyser to get data for the sound
src.connect(analyser);
analyser.connect(audioContext.destination);
analyser.fftSize = 512; // set the bin size to condense amount of data
// array limited to unsigned int values 0-255
const bufferLength = analyser.frequencyBinCount;
const freqData = new Uint8Array(bufferLength);
audioContextObj = {
freqData, // note: at this time, this area is unpopulated!
analyser
}
return audioContextObj;
}
在这里,我们创建一个音频上下文,将声音连接到它,并在对象中返回必要的工具,以便稍后在父函数中使用。我还将fftSize
(代表快速傅里叶变换)设置为 512——默认值是 2048,我们不需要那么多数据,所以我对其进行了压缩。这将使数组的长度达到freqData
256——考虑到我们的条形图数量只有 130,这个长度更合适!我知道,目前这可能会有点复杂;虽然我不想说了解这里发生的细节并不重要,但即使现在还没有完全理解这里发生的事情也没关系。本质上,我们正在使用提供给我们的工具来获取有关声音频率的信息,我们将用这些信息来绘制可视化效果。
让我们继续。在调用renderFrame
内部函数之前renderVisualizer
,我将设置固定的条形数量、相应的宽度,并初始化它们的高度变量:
const numBars = 130;
let barWidth = 3;
let barHeight;
好了,现在我们可以深入研究它了。我们进入了renderFrame
函数内部。它负责持续渲染数据并将其绘制到画布上。
function renderFrame() {
const freqDataMany = []; // reset array that holds the sound data for given number of audio sources
const agg = []; // reset array that holds aggregate sound data
canvasContext.clearRect(0, 0, canvas.width, canvas.height) // clear canvas at each frame
requestAnimationFrame(renderFrame); // this defines the callback function for what to do at each frame
audioContextArr = Object.values(audioContextById); // array with all the audio context information
// for each element in that array, get the *current* frequency data and store it
audioContextArr.forEach((audioContextObj) => {
let freqData = audioContextObj.freqData;
audioContextObj.analyser.getByteFrequencyData(freqData); // populate with data
freqDataMany.push(freqData);
})
if (audioContextArr.length > 0) {
// aggregate that data!
for (let i = 0; i < freqDataMany[0].length; i++) {
agg.push(0);
freqDataMany.forEach((data) => {
agg[i] += data[i];
});
}
好吧,代码有点多!我们来逐步讲解一下。首先,在每一帧,renderFrame
都会调用该函数。我们要做的第一件事是重置保存所有频率数据实例的数组,以及包含所有数据加在一起的数组。记住,音频上下文中的每个频率数据当前都被设置为一个未填充的数组,该数组将由其各自的分析器填充。说完这些之后,可以这样理解:
freqDataMany = [ [freqDataForFirstSound], [freqDataForSecondSound], [freqDataForThirdSound]....];
agg = [[allFreqDataAddedTogether]];
为了满足您的好奇心,这里有一段agg
包含一些数据的片段:
是不是很棒?我们稍后会用汇总数据做更多的事情,但首先让我们画一个圆圈,用来放置条形图:
// still inside if (audioContextArr.length > 0)
// set origin of circle to center of canvas
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = 50; // set size of circle based on its radius
// draw circle
canvasContext.beginPath();
canvasContext.arc(centerX, centerY, radius, 0, (2*Math.PI) );
canvasContext.lineWidth = 1;
canvasContext.stroke();
canvasContext.closePath()
注意 //如果您希望圆圈始终绘制在画布上,则可以在函数外部编写此renderFrame
代码。我希望在没有声音播放时画布完全空白。
魔法就在这里发生。每次渲染(动画的每个帧都会发生)时,这个循环都会运行 130 次(也就是上面定义的条形数量)。它负责绘制圆圈周围的每个条形。
for (let i = 0; i < (numBars); i++) {
barHeight = (agg[i] * 0.4);
let rads = (Math.PI * 2) / numBars;
let x = centerX + Math.cos(rads * i) * (radius);
let y = centerY + Math.sin(rads * i) * (radius);
let x_end = centerX + Math.cos(rads * i) * (radius + barHeight);
let y_end = centerY + Math.sin(rads * i) * (radius + barHeight);
drawBar(canvasContext, x, y, x_end, y_end, barWidth)
}
条形高度被动态设置为i
聚合频率数据数组中的第 th 位信息。让我们来理解一下这一点。频率数据被分成 265 个“bin”。agg[0]
是第一个 bin,agg[1]
是第二个……agg[130]
是第 130 个。请注意,我可以设置numBars
为 256 来访问数组中的每一位频率数据。但是,我更喜欢舍弃较高的频率,并减少条形数量(这样可以使一些高频鸟鸣声标准化)。此外,我将其乘以 0.4 来限制条形高度,以便所有内容都能显示在画布上。
让我们开始数学计算。别担心——这只是一些三角函数,可以帮助我们沿着圆绘制条形。rads
将圆弧转换为弧度——这对于我们的目的来说更容易一些。我们将使用一个常用公式将极坐标(使用弧度)转换为笛卡尔坐标(换句话说,我们熟悉的 (x, y)):
x = 半径 × cos( θ )
y = 半径 × sin( θ )
您可以更深入地了解其工作原理(参见下面的链接),但如果您想进一步了解,只需知道我们正在使用此公式来确定条形的起始和结束坐标。它的起点需要位于圆周上的某个点(这就是上述公式的用途),并且需要根据我们所处的循环周期递增(这就是我们将它乘以的原因i
- 否则它们都会重叠在一起)。端点基于barHeight
,如果您还记得的话,它基于agg
数组中与其关联的位数据。有了所有必要的坐标和我们在循环前定义的条形的固定宽度,我们就可以绘制条形了:
function drawBar(canvasContext, x1, y1, x2, y2, width){
const gradient = canvasContext.createLinearGradient(x1, y1, x2, y2); // set a gradient for the bar to be drawn with
// color stops for the gradient
gradient.addColorStop(0, "rgb(211, 197, 222)");
gradient.addColorStop(0.8, "rgb(255, 230, 250)");
gradient.addColorStop(1, "white");
canvasContext.lineWidth = width; // set line width equal to passed in width
canvasContext.strokeStyle = gradient; // set stroke style to gradient defined above
// draw the line!
canvasContext.beginPath();
canvasContext.moveTo(x1,y1);
canvasContext.lineTo(x2,y2);
canvasContext.stroke();
canvasContext.closePath();
}
快完成了。现在只需确保所有这些函数在正确的时间调用。尽可能地折叠所有东西,renderVisualizer
函数如下:
函数定义完成后renderFrame
,我们直接调用它。该renderVisualizer
函数在声音首次播放时,点击操作时调用。当另一个声音通过点击叠加时,其频率数据会聚合到当前频率数据中。当声音暂停时,没有频率数据——记住,freqData
它们agg
会在每一帧渲染时重置。如果声音没有播放,它就freqData
只是一堆零——当它与当前正在播放的声音聚合时,它根本就没有任何数据可以添加。
下面是实际运行的 gif:
为了获得合适大小的 GIF,我只录了可视化工具的屏幕。首先,添加一个初始声音,然后添加另一个(注意条形的高度跳跃,尤其是在左下角)——第二个声源被移除,然后第一个声源也被移除。
瞧!我只用了几天就实现了,所以我非常乐意接受任何优化或批评。以下是我使用的参考资料列表:
- 简单 JS 音频可视化工具的 CodePen
- 圆形音频可视化器的步骤
- MDN - Web 音频 API 文档
- MDN - 特别是关于频率BinCount
- MDN - 特别是关于 requestAnimationFrame
- 关于 HTML5 Canvas 元素
- 查找单位圆上的 (x,y) 坐标
- 极坐标解释
- 傅里叶变换解释
有了♡,编码快乐。
文章来源:https://dev.to/rizz0s/creating-an-audio-visualizer-that-can-handle-multiple-audio-sources-dynamically-all-in-vanilla-js-5hfl