最近研究了下音乐频谱相关的知识,通过webgl进行可视化处理
这一节主要讲述频谱数据预处理相关的前置知识
audioContext
首先我们要跟音乐数据相结合,就需要了解一下audioContext的内容。
这里我们的使用流程是这样的
因此,对应相关的逻辑为
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这个变量中。
数据有了。我们先看看数据长什么样。
第一反应是不是觉得自己的写错了,其实不然,这说明你的音频没有声音,我们加载音频刚开始都是这样的。
之后才会出现正确的数据
这样的数据才是正确的。
现在是不是开始怀疑,获取的数据到底是什么,为什么基本上都是负数,数据是如何对频谱数据进行表示的呢?
-
以
getFloatFrequencyData为例,我们获取的是[0Hz, 22000Hz]之间的数据,数组每一个代表的是赫兹单位。fftSize代表的是进行快速傅立叶变换的窗口大小,信号量数据的多少。返回值里,数组中每项对应的值为其振幅(分贝)大小。 -
至于为什么振幅都是负数,这是因为数字音乐都是用的满刻度电平【0dBFS(0dB Full Scale)】,即把16bit采样的±32767允许的最大值作为0dB【这就是参考点】。
了解完数据之后,我们就要开始我们的绘制操作了。
webgl结合音频数据进行绘图
简单📊
是的,以最常规的简单直方图为例子
歌曲1: 梦中的婚礼
歌曲2: New Thang
我们会发现,不同频率的幅度变化很小。从梦中的婚礼到New Thang, 这两首歌在我们听觉中,闭上眼睛,想象的频谱波动跟展示不一致。再多听几遍,多看几遍,多想一下,我们先找出有什么问题?
- 幅度差太小
嗯是的,为什么信号展示的跟我们想象中的差别这么大?该高不高,该低不低。
开始怀疑:
- 是不是获取的数据有问题?
- 是不是频谱数据不是真正反映人耳对歌曲音调的敏感程度?
首先先排查第一个问题
频谱图的概念是什么?维基百科上说,频谱图是以频率为横坐标,信号幅度为纵坐标组成的表示方式。
而我们通过mdn上查找的资料表明,通过getFloatFrequencyData这里获取到的,确实是以频率(赫兹)为索引单位,以信号幅度(分贝)作为信号强度的数据。
那我们先排除第一个原因。那频谱数据是否真正反映了人耳对歌曲音调的敏感程度呢?
人在不同频率的差别感知是不一样的,物理学有频率计权的说法,频率计权的目的是将电信号修正为与人耳听感相近的数值。本质是一组滤波器。
由于我们常使用的是A计权方式,公式为:
因此,我们可以定义一个加权函数。
// 这里计权函数可以参考 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;
}
我们可以看看效果
梦中的婚礼:
New Thang
嗯,从上述中,通过加权,我们确实让幅度变得稍微符合我们的听觉感受了,但是,这里的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;
}
梦中的婚礼
New Thang
然而,我们发现,这里还会有一些锯齿的行为出现,所以我们考虑,采用加权平均的方式
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;
}
至此,我们频谱数据的预处理阶段已经完成。
参考:
傅立叶变换 强烈推荐李永乐老师的讲解 www.youtube.com/watch?v=0Lu…
A计权方式计算公式 -> zhuanlan.zhihu.com/p/44388614 (几张图从这里来的,侵权必删)
这位ios老哥给了我很多思路: juejin.cn/post/684490…
最牛逼不过 mdn: developer.mozilla.org/zh-CN/docs/…