创建一个可以动态处理多个音频源的音频可视化器 - 全部使用 Vanilla JS!

2025-06-07

创建一个可以动态处理多个音频源的音频可视化器 - 全部使用 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
    }


Enter fullscreen mode Exit fullscreen mode

当页面加载时将声音呈现到页面上时,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");


Enter fullscreen mode Exit fullscreen mode

接下来,我们需要为每种声音创建音频上下文。这使我们能够访问所有这些精彩的数据。我之前提到过,所有声音都存储在一个全局对象中以供后续使用——这就是我们将要用到它的地方!对于对象中的每个声音键值对,我都会创建另一个具有相同键的对象,并将必要的信息设置为值:



    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])
            }
        })


Enter fullscreen mode Exit fullscreen mode

...这是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; 
    }


Enter fullscreen mode Exit fullscreen mode

在这里,我们创建一个音频上下文,将声音连接到它,并在对象中返回必要的工具,以便稍后在父函数中使用。我还将fftSize(代表快速傅里叶变换)设置为 512——默认值是 2048,我们不需要那么多数据,所以我对其进行了压缩。这将使数组的长度达到freqData256——考虑到我们的条形图数量只有 130,这个长度更合适!我知道,目前这可能会有点复杂;虽然我不想说了解这里发生的细节并不重要,但即使现在还没有完全理解这里发生的事情也没关系。本质上,我们正在使用提供给我们的工具来获取有关声音频率的信息,我们将用这些信息来绘制可视化效果。

让我们继续。在调用renderFrame内部函数之前renderVisualizer,我将设置固定的条形数量、相应的宽度,并初始化它们的高度变量:



    const numBars = 130;

    let barWidth = 3;
    let barHeight;


Enter fullscreen mode Exit fullscreen mode

好了,现在我们可以深入研究它了。我们进入了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];
                        });
                    }


Enter fullscreen mode Exit fullscreen mode

好吧,代码有点多!我们来逐步讲解一下。首先,在每一帧,renderFrame都会调用该函数。我们要做的第一件事是重置保存所有频率数据实例的数组,以及包含所有数据加在一起的数组。记住,音频上下文中的每个频率数据当前都被设置为一个未填充的数组,该数组将由其各自的分析器填充。说完这些之后,可以这样理解:



    freqDataMany = [ [freqDataForFirstSound], [freqDataForSecondSound], [freqDataForThirdSound]....];
    agg = [[allFreqDataAddedTogether]];


Enter fullscreen mode Exit fullscreen mode

为了满足您的好奇心,这里有一段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()


Enter fullscreen mode Exit fullscreen mode

注意 //如果您希望圆圈始终绘制在画布上,则可以在函数外部编写此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)
      }


Enter fullscreen mode Exit fullscreen mode

条形高度被动态设置为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();
    } 


Enter fullscreen mode Exit fullscreen mode

完成了。现在只需确保所有这些函数在正确的时间调用。尽可能地折叠所有东西,renderVisualizer函数如下:

替代文本

函数定义完成后renderFrame,我们直接调用它。该renderVisualizer函数在声音首次播放时,点击操作时调用。当另一个声音通过点击叠加时,其频率数据会聚合到当前频率数据中。当声音暂停时,没有频率数据——记住,freqData它们agg会在每一帧渲染时重置。如果声音没有播放,它就freqData只是一堆零——当它与当前正在播放的声音聚合时,它根本就没有任何数据可以添加。

下面是实际运行的 gif:

替代文本

为了获得合适大小的 GIF,我只录了可视化工具的屏幕。首先,添加一个初始声音,然后添加另一个(注意条形的高度跳跃,尤其是在左下角)——第二个声源被移除,然后第一个声源也被移除。

瞧!我只用了几天就实现了,所以我非常乐意接受任何优化或批评。以下是我使用的参考资料列表:

有了♡,编码快乐。

文章来源:https://dev.to/rizz0s/creating-an-audio-visualizer-that-c​​an-handle-multiple-audio-sources-dynamically-all-in-vanilla-js-5hfl
PREV
您需要了解哪些 JavaScript 知识才能更有效地编写单页应用程序:指南摘要 JavaScript 基础知识 DOM 操作 箭头函数 数组方法 异步 JavaScript 使用 Fetch 向 API 发出请求 ES 模块 使用 NPM 进行包管理 结论
NEXT
从头开始使用 React 和 Babel 设置 Webpack 5