zhongyang219 / MusicPlayer2

这是一款可以播放常见音频格式的音频播放器。支持歌词显示、歌词卡拉OK样式显示、歌词在线下载、歌词编辑、歌曲标签识别、Win10小娜搜索显示歌词、频谱分析、音效设置、任务栏缩略图按钮、主题颜色等功能。 播放内核为BASS音频库(V2.4)。
GNU General Public License v3.0
4.61k stars 373 forks source link

[建议]关于频谱分析横坐标取对数的实现建议 #169

Open maoyj opened 3 years ago

maoyj commented 3 years ago

1.目的和意义

目的:频谱分析用横坐标为对数的频谱取代线性谱。 意义:当前频谱分析是线性的,大量有意义的数据集中在前面6格,而后面绝大部分都是几乎用不到的高频。当前的频谱分析并不利于音乐的视觉化。当前的频谱,有效的区域太小。频谱的范围大概是0-20kHz,人耳敏感的频率是1kHz-3kHz,按64等分的话4-9(6%-14%)是最敏感的部分;高频区3kHz-10kHz位于10-32(16%-50%);后面50%都是令人不愉快的啸叫,从67%(14kHz)向后基本上是超出听力范围的。一个理想的频谱分析应该是有利于扒乐的。所以当前线性的频谱不够理想。

2.这个ISSUE的用途

我不会C++,没有办法直接改代码后push到原项目,只能以ISSUE的形式提出实现想法。后续是否做这个功能、具体怎么做这个功能,只能麻烦作者了。对用爱发电的开源项目直接粗暴的提各种要求,是挺不礼貌的行为,我试图在力所能及的范围内尽量做点贡献。

3.我对当前频谱分析的代码理解

好多地方有点对应不上,程序逻辑似乎有点模糊的概念,但是又好像缺了什么,且说出来看下: 1) 用BASS_DATA_FFT256标签从BASS库中获取256点的频谱数据,存储为fft_data //BassCore.cpp 561 2) 后来定义GetFFTData的时候不知道为什么fft_data[128]数组变成128大小了。 //IPlayerCore.h 74 ; BassCore.h 53 等 3) 根据FFT_SAMPLE=128,定义m_fft[128]来存储频谱数据。这里主要不明白fft_data和m_fft的关系
4) 根据SPECTRUM_COL=64,定义m_spectral_data[64],并将m_fft的数值累加到m_spectral_data作为最终显示结果 // Player.cpp 634

4.对数频谱实现的想法

抛开前面提到的没明白的部分,要实现对数谱,我想关键点有三个:

1) 对数化前的精度是多少,才能保证对数化后的效果。

方案一 整个坐标都按照对数化处理。缺点是低频可能分辨率不足。 对数化实际上是进行了坐标映射。最终m_spectral_data有64个,假设有64个数值,线性分布的话对应了(1,y1),(2,y2),(3,y3)……(64,y64),即前一个是1-64的线性横坐标值,后一个y_n是相应频率范围的强度(已经取平方根了)。如果坐标对数化,那么原来的横坐标从n∈[1,64]就映射为了0-(log(n/64)-log(1/64))/log(1/64)64 【注: n/64和1/64是标准化为(0,1)区间,然后减去log(1/64)是让第一数等于0,差除以log(1/64)是让最后一个数等于1,最后再乘以64让新的横坐标∈[0,64]区间。】 该式可化简为 log(n)/log(64)64。 假设原始的采样是64个点的时候,新坐标的x,前三位分别是:0,10.67,16.90;最后一位是64。 假设原始采样是4096个点的时候,新坐标的x,前三位分别是:0,5.33,8.45;最后一位是64。 假设原始采样是16384 个点的时候,新坐标的x,前三位分别是:0,4.57,7.25;最后一位是64。 假设原始采样是32768 个点的时候,新坐标的x,前三位分别是:0,4.27,6.76;最后一位是64。 假设原始采样是524288个点的时候,新坐标的x,前三位分别是:0,3.37,5.34;最后一位是64。 而bass.h里面的FFT只从256-32768,也就是不论取多少个点,划分成64份的频谱记录m_spectrual_data的第1个和第2个都是没有数据的。所以全部取对数的方案不可取。

方案二 为了从数学的角度改进方案一的缺点,让分布根均匀,方案二是1kHz以前的低音部分继续采用线性谱,1kHz以后的再改为对数坐标。 这样的话: 分成64份的第1、2、3(分别对应了310Hz、630Hz、930Hz),第4位(1250Hz)开始对数化,按照之前的计算4→21.33;5→24.77;6→27.57。4和5之间的横坐标相差3.4 ,而之前的1、2、3插值在4→21.33之前,即1→5.33, 2→10.67, 3→16.00。相差5.3,有点大了。 增加采样数到1024的时候,第52(1015Hz)开始对数坐标,之后是52→583.7, 53→586.5, 相差3。之前的部分线性分布相差583/52=11。 因为1024合并到64的时候是16:1,至少能保证64份的每个格子都有数。方案二在1024以上采样的时候可行,512不行。

方案三从听觉的角度改进,让听感和视觉一致,方案三是8kHz以前采用线性。1kHz-3kHz的敏感部分,频率变化的体验很显著,即提高一点点频率都能让听觉感受“音高”提高了很多。即使到5kHz也依然较为敏感。所以考虑从8kHz以后开始用对数坐标。 1024采样点时,第410开始取对数坐标,410→888.8,411→889.1 , 相差0.3。 线性部分相差888/410=2 < 16:1 经过计算,即使只有256采样点,第103开始取对数坐标,103→214.0, 206→214.4, 相差0.4。 线性部分相差214/103= 2 < 256:64=4:1。

综合考虑后的方案四 最后从音乐的角度,根据目前的频谱坐标,大部分音乐能感知到明显响度的节拍都集中在前1/3部分,而后50%在频谱上表现不明显。所以结论是对数坐标的开始位置不宜晚于50%即10kHz,33%即6.5kHz是一个不错的位置。线性的部分越多,对最小采样精度的需求越小。结合方案二和方案三,如果线性部分差值精确的等于2< 256:64,那么对数坐标开始的位置是206,通过试验后发现对数坐标从87→206.2开始。也就是说第87个采样点(6797Hz,34%)开始,每个采样点的位置计数(x坐标)换算为对数坐标后,计入相应的统计区间。之前的采样点每2个纳入统计区间。 *具体来说,导出256个采样点数据,fft_data[256]的前86个按2:1的线性的放入SPECTRUM_COL[64]的前 43个中; fft_data[n]按公式 m= log(n) / log(256) 64 放入SPECTRUM_COL[m]中。** 在这种方案下,最敏感的1kHz-3kHz从13/256 - 38/256即5%-15% 映射到了 7/64 - 19/64即11%-29%。较为敏感范围3kHz-10kHz从39/256 - 128/256即 15%-50% 映射到了 20/64 - 56/64 即 31%-88% (前面是线性后面是对数)。转化后,不敏感的音高只占前10%和后11%。 我觉得这个方案是相当合适的。

2) 如何实现对数化坐标轴的程序逻辑。

因为现在64格坐标,每一格区间包括的采样点个数是不相等的,就不能用以前的区间内求和的方式了(player.cpp 634),而应该采用区间内取平均数。取平均数的话,就不存在 SPECTRUM_COL 必须为2的整数倍的限制了,但计算量会明显增加,不知道会不会对性能造成影响。

既然采样了,就应该把每一个采样点的数据都用上,所以对于对数坐标而言,靠后的区间集中了大量采样点,但是如果跳过某些采样点,又会造成频谱信息的损失(即如果只频谱的分布很窄,刚好有数值的采样点被忽略了的话,那么就造成了信息损失)。所以实现上是用最少的采样点实现频谱图,最好是前面线性的部分1个采样点对应一个区间,然后后面的部分再说对数坐标的事。也就是最后一种综合方案(即256取样,前86是线性,87开始是对数坐标)

具体实现上,不用分两部分,不用i<87和i>=87分别写公式,只需要单独做一个映射表就行了。 1) 首先定义一个矩阵SPECTRUM_MAP[256]来记录映射关系(一次计算重复使用) 2) 再定义一个矩阵SPECTRUM_MAP_COUNT[64] (用来统计每个区间的取样数便于平均)。i∈[1,86]时对应的值取2 3) 根据i∈[1,86]遍历,m=(i/2)取整,把 m 放入SPECTRUM_MAP[i] 中,并且SPECTRUM_MAP_COUNT[m]加一。 4) 根据i∈[87,256]遍历,新的对数坐标 m= log(i) / log(256) *64放入SPECTRUM_MAP[m]中,并且SPECTRUM_MAP_COUNT[m]加一。 5) 每个周期计算频谱时,按照 i ∈[1,256]遍历fft_data,把fft_data[i]加到 SPECTRUM_COL[ SPECTRUM_MAP[i] ]中。 6) i∈[1,64]遍历SPECTRUM_COL[64]的每个元素,用该元素的值除以SETCTRUM_MAP_COUNT[i]并重新赋值给SPECTRUM_COL[i]

3) 为了配合横坐标的对数化,如何尽量减少其他代码变化,同时又不增加复杂度。

除了上述逻辑的代码实现以外,可以保留SPECTRUM_COL = 64 和FFT_SAMPLE =128 的定义。没有其他代码的变动,变动都集中在Player.cpp里面,频谱用最小的256个采样点就足够了,和以前没有变化。

附件:

附件中的WAV文件,能够很好展示出听觉感受和视觉感受的错位。其中40s是12kHz(60岁的听力上限), 43s是14kHz(40岁的听力上限), 45s是16kHz(正常年轻人的听力上限)。

听力测试25-20000Hz.zip

maoyj commented 3 years ago

image

目前没有找到更好的映射方法。

从数学上,对数曲线上任意一点的斜率,一定是大于该点到原点直线的斜率。所以后面对数坐标的采样点一定更密集,且比前面线性的部分会有一个明显的密度增加。也就是说,保证了线性部分密度的情况下,对数部分的采样点密度一定是过多的。目前没有其他方式在不增加复杂程度的情况下解决这个问题。

从实现效果上,至少敏感区间扩大了很多,且大部分落在线性的部分。频谱分析不会有太突兀的结果。

SplitGemini commented 3 years ago

看你写那么认真,提供一些repo吧 合辑:awesome-audio-visualization
其中这两个是C实现的,cava, aubio,看起来还不错,或许可以引用

maoyj commented 3 years ago

合辑:awesome-audio-visualization 其中这两个是C实现的,cava, aubio,看起来还不错,或许可以引用

妙极,让我学习一下。

SplitGemini commented 3 years ago

@zhongyang219 https://www.youtube.com/watch?v=9PSp8VA6yjU,这样确实好看欸

zhongyang219 commented 3 years ago

感谢你的建议,我会好好研究你的关于频谱分析的建议。 关于为什么256点FFT后数组大小为128的问题,那是因为快速傅里叶变换得到的结果是对称的,所以只有128点是可用的。

zhongyang219 commented 3 years ago

你好,我今天按照你的方法修改了代码,但是我发现了一个问题。 对于i∈[0, 85](C++数组下标是从0开始的),使用线性计算频谱,m = i/2;而对于i∈[86,255],使用对数计算频谱,m= log(i) / log(256) 64。 当i=85时,m的值为85/2=42;当i=86时,m的值为log(86)/log(256)64=51。这样就缺少了43~50这8个频谱数据: 2021-05-04 (3) 最后的结果就会是这个样子: 2021-05-04 (2)

maoyj commented 3 years ago

@zhongyang219 抱歉抱歉,方案4的地方是我算错了。

要满足线性部分精确2:1采样的条件,那么对数开始的位置应该是108,而不是87。87分界的线性部分采样比不是整数 此时,108/2 = 54 ;log(108) / log(256) *64 = 54 上面的部分我算错了。我图方便没有写方程,直接心算的,结果漏掉了一项。[捂脸]

以0作为开始点的话,可以把[0,107]按线性计算,把[108,255]按对数计算,如下表:

原下标(0-255) 新下标(向下取整 0-63)
104 [ 104/2 ] = 52
105 [ 105/2 ] = 52
106 [ 106/2 ] = 53
107 [ 107/2 ] = 53
108 [ log(108)/log(256)*64 ] = 54
109 [ log(109)/log(256)*64 ] = 54
zhongyang219 commented 3 years ago

@maoyj 谢谢,代码已经提交了。