webgl 与频谱相结合 —— 数据处理

1,210 阅读5分钟

最近研究了下音乐频谱相关的知识,通过webgl进行可视化处理

这一节主要讲述频谱数据预处理相关的前置知识

audioContext

首先我们要跟音乐数据相结合,就需要了解一下audioContext的内容。

这里我们的使用流程是这样的

image.png 因此,对应相关的逻辑为

const audioCtx = new AudioContext(); // 创建音频上下文
const audioEle = new Audio(); // 创建音频节点
audioEle.src = 'http://y.qq.com/test/a.mp4'; // 设置音频播放链接(这里我用了代理,线上不可用)
audioEle.autoplay = true;
audioEle.preload = 'auto';

const audioSourceNode = audioCtx.createMediaElementSource(audioEle); // 创建音频源,将音频上下文与当前节点关联

const analyserNode = audioCtx.createAnalyser(); // 创建分析节点, 可以对音频数据提供实时的频域和时域数据
analyserNode.fftSize = 256; // 傅立叶变换窗口大小

const bufferLength = analyserNode.frequencyBinCount; // 获取可视化数据值的数量,这里为FFT窗口大小的一半
const dataArray = new Float32Array(bufferLength); // 用一个32位浮点数数组来接收数据

// 设置音频节点网络(音频源->分析节点->输出)
audioSourceNode.connect(analyserNode); // 音频源连接分析节点
analyserNode.connect(audioCtx.destination); // 输出音频源

之后,我们便可以通过以下操作获取音频的数据

analyserNode.getFloatFrequencyData(dataArray); // 获取的是频域数据,时域数据用getFloatTimeData

这样之后,就会把音频数据写入dataArray这个变量中。

数据有了。我们先看看数据长什么样。

image.png

第一反应是不是觉得自己的写错了,其实不然,这说明你的音频没有声音,我们加载音频刚开始都是这样的。

之后才会出现正确的数据

image.png

这样的数据才是正确的。

现在是不是开始怀疑,获取的数据到底是什么,为什么基本上都是负数,数据是如何对频谱数据进行表示的呢?

  1. getFloatFrequencyData为例,我们获取的是[0Hz, 22000Hz]之间的数据,数组每一个代表的是赫兹单位。fftSize代表的是进行快速傅立叶变换的窗口大小,信号量数据的多少。返回值里,数组中每项对应的值为其振幅(分贝)大小。

  2. 至于为什么振幅都是负数,这是因为数字音乐都是用的满刻度电平【0dBFS(0dB Full Scale)】,即把16bit采样的±32767允许的最大值作为0dB【这就是参考点】。

了解完数据之后,我们就要开始我们的绘制操作了。

webgl结合音频数据进行绘图

简单📊

是的,以最常规的简单直方图为例子

歌曲1: 梦中的婚礼

img.gif

歌曲2: New Thang

img.gif

我们会发现,不同频率的幅度变化很小。从梦中的婚礼到New Thang, 这两首歌在我们听觉中,闭上眼睛,想象的频谱波动跟展示不一致。再多听几遍,多看几遍,多想一下,我们先找出有什么问题?

  1. 幅度差太小

嗯是的,为什么信号展示的跟我们想象中的差别这么大?该高不高,该低不低。

开始怀疑:

  1. 是不是获取的数据有问题?
  2. 是不是频谱数据不是真正反映人耳对歌曲音调的敏感程度?

首先先排查第一个问题

频谱图的概念是什么?维基百科上说,频谱图是以频率为横坐标,信号幅度为纵坐标组成的表示方式。

而我们通过mdn上查找的资料表明,通过getFloatFrequencyData这里获取到的,确实是以频率(赫兹)为索引单位,以信号幅度(分贝)作为信号强度的数据。

那我们先排除第一个原因。那频谱数据是否真正反映了人耳对歌曲音调的敏感程度呢?

人在不同频率的差别感知是不一样的,物理学有频率计权的说法,频率计权的目的是将电信号修正为与人耳听感相近的数值。本质是一组滤波器。

img

由于我们常使用的是A计权方式,公式为:

img

因此,我们可以定义一个加权函数。

// 这里计权函数可以参考 https://zhuanlan.zhihu.com/p/44388614
function createFrequencyWeights() {
    const deltaF = 22050 * 2 / fftSize;
    const frequencyBinCount = fftSize / 2;
    let arr = new Array(frequencyBinCount).fill().map((_, index) => {
        return index * deltaF;
    });

    const c1 = Math.pow(12194.217, 2);
    const c2 = Math.pow(20.598997, 2);
    const c3 = Math.pow(107.65265, 2);
    const c4 = Math.pow(737.86223, 2);

    const num = arr.map((item) => {
        return item * c1 * item;
    });

    const den = arr.map((item) => {
        return (item + c2) * Math.sqrt((item + c3) * (item + c4)) * (item + c1);
    });

    const weights = num.map((item, index) => {
        return 1.2589 * item / den[index];
    });

    return weights;
}

我们可以看看效果

梦中的婚礼:

img.gif

New Thang

img.gif

嗯,从上述中,通过加权,我们确实让幅度变得稍微符合我们的听觉感受了,但是,这里的FFT区间我设置的是256,相当于 0-22050hz每间隔 22050 / 256 赫兹取一个数据。这样的话,但是,人耳很容易分辨出100-200hz的音调区别,但很难区别出10000hz-10100hz的音调区别,因此,在索引中,我们去要对不同频段进行不同的加权处理。因此,在实现动画效果的时候,应该把线性增长的频率改成对数建立的索引曲线。

我们划分一下新的频带

function createBands() {
    const frequencyBands = bandNum; // 频带数量
    const startFrequency = 100; // 起始频率
    const endFrequency = 10000; // 截止频率

    const n = Math.log2(endFrequency / startFrequency) / frequencyBands;

    const nextBand = {
        lowerFrequency: startFrequency,
        upperFrequency: 0,
    };

    let bands = [];
    for (let i = 0; i < frequencyBands; i++) {
        const highFrequency = nextBand.lowerFrequency * Math.pow(2, n);
        nextBand.upperFrequency = i === frequencyBands ? endFrequency : highFrequency;
        bands.push({
            lowerFrequency: nextBand.lowerFrequency,
            highFrequency
        });

        nextBand.lowerFrequency = highFrequency;
    }
    return bands;
}

梦中的婚礼

img.gif

New Thang

img.gif

然而,我们发现,这里还会有一些锯齿的行为出现,所以我们考虑,采用加权平均的方式

function highlightWaveform(arr) {
    const weights = [1, 2, 3, 5, 3, 2, 1]; // 权重
    const totalWeights = weights.reduce((res, a) => {
        return res + a;
    }, 0);
    const startIndex = weights.length / 2;

    // 前几个和后几个都不进行加权平均
    let res = arr.slice(0, startIndex);

    // 转换数组为[{key: xxx, value: xxx}]的形式
    const transfromArr = (arr1, arr2) => {
        const res = arr1.map((item, index) => {
            return {
                key: item,
                value: arr2[index],
            };
        });
        return zipRes;
    }
    for (let i = startIndex; i < arr.length - startIndex; i++) {
        const data = transfromArr(arr.slice(i - startIndex, i + startIndex), weights);
        // 获取加权平均的数组数据
        const averageArr = data.map((item) => {
            return item.key * item.value;
        });

        // 累加➗总权重得到加权平均后的值
        const average = averageArr.reduce((r, a) => {
            return r + a;
        }, 0) / totalWeights;

        res.push(average);
    }

    res.push(...arr.slice(arr.length - startIndex, arr.length));

    return res;
}

img.gif

至此,我们频谱数据的预处理阶段已经完成。

参考:

傅立叶变换 强烈推荐李永乐老师的讲解 www.youtube.com/watch?v=0Lu…

A计权方式计算公式 -> zhuanlan.zhihu.com/p/44388614 (几张图从这里来的,侵权必删)

这位ios老哥给了我很多思路: juejin.cn/post/684490…

最牛逼不过 mdn: developer.mozilla.org/zh-CN/docs/…