12

算法系列之二十三:离散傅立叶变换之音频播放与频谱显示

 3 years ago
source link: https://blog.csdn.net/orbit/article/details/44728901
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

算法系列之二十三:离散傅立叶变换之音频播放与频谱显示

频谱和均衡器,几乎是媒体播放程序的必备物件,没有这两个功能的媒体播放程序会被认为不够专业,现在主流的播放器都具备这两个功能,foobar 2000的十八段均衡器就曾经让很多人着迷。在上一篇对离散傅立叶变换介绍的基础上,本篇就进一步介绍一下频谱是怎么回事儿,下一篇继续介绍均衡器。

1 什么是频谱

       频谱是数字信号处理领域的一个专业术语,但是与本文要介绍的频谱还不太一样。我用Winamp播放音乐(AOL已经在2013年12月20日停止了Winamp的支持),最早吸引我的原因就是播放界面上那个跳动的频谱(如图 1 所示),相信几乎所有主流的媒体播放软件都有这个,这就是本文要介绍的频谱。

图 1 winmap上跳动的频谱

1.1 频谱的原理

       本篇先来说说频谱。既然是频谱,就一定和频率有关系吧?是的,那个跳动的频谱实际上就是当前播放的一小段片音频信息在频域上的功率分布。鼓声和弦乐的频率范围相差很大,当音乐中有震耳的鼓声时,频谱中中低频的部分就跳的很高,说明这部分频率的功率比较高。同样,当高亢的小提琴声音响起时,频谱中高频的部分就跳的很高,说明高频部分的功率比较高。正是因为这个关系,频谱总是和正在播放的音乐“相映成趣”。

       要在播放器中显示跳动的频谱,就需要知道音频数据中各个频率对应的功率,常见的音频数据都是时域信号,需要转换成频域信号才能进行分析。在《听声音破解电话号码》一文中,我们介绍了离散傅立叶变换可以将时域的声音信号转换成频域的频率功率分布,并给出了相关的算法,这正是本篇要介绍的频谱显示的基础。

1.2 频谱的选择

       《听声音破解电话号码》一文中给出的PowerSpectrumS()函数,可以将44100Hz采样率的音频信号经过2048点离散傅立叶变换后,可以得到1024个点的有效频率和功率分布(另外1024个点与之具有对称性),对应的频率映射范围是0Hz到22050Hz。播放器软件通常有一个很小巧的界面,在这个界面上用1024个波段全部显示从0到22050Hz的频谱是不现实的,也完全没有必要,因为大部分人的耳朵听力范围在20Hz到20KHz之间,不在此范围的频率可以忽略。一般频谱最多显示32个波段(我用的winamp 2.91 版本只有19个频谱波段),这就涉及到另一个问题,那就是如何从1024个频谱数据中选择32个用作频谱的显示。选取的原则是要选择有代表性的频率,两个波段的中心频率最好不要相差太小,可以是均匀选择,也可以是不均匀选择。可采用的方法很多,最简单的方法,就是每隔32个频率点选择一个数据,刚好选择32个点的功率值,然后映射到32个频谱波段上显示。44100Hz采样率的音频信号经过2048点离散傅立叶变换后,其频谱分辨率是3.90625Hz,每32个点的频域数据覆盖的频率宽度是125Hz。也就是说,这种方法每隔125Hz选择一个频率点,“简单粗暴”地丢弃了太多的数据,会使得跳动的频谱缺少一致的连贯性。

1.3 频谱的计算

       本文介绍的方法是将1024个点分成32个波段,每个波段包含32个频率点。在每个波段内找到中心频率点,从中心频率点向左和向右均匀地各取两个频率点,加上中心频率点共采集5个频率点的值进行计算。计算的方法是给这5个点赋予不同的权重,中间点权重最高,向两边依次降低,然后计算5个点的加权平均值,将加权平均值作为这个波段的频谱功率映射到频谱上显示。这样计算出来的加权平均值更能反映这个125Hz宽的频率段的实际功率,从最终的频谱显示效果看,这种方法得到的频谱跳动起来有比较好的连贯性。UpdateSpectrum()函数就是这个算法的体现,对于sampleData参数给出的一段音频数据,首先调用PowerSpectrumS()函数得到这段音频的功率分布,然后按照BAND_COUNT常量对其分段,最后对每段的频域数据计算加权平均值。我们给这5个点分配的权重是:中央点0.5,紧邻中央点的两个点是0.15,最外边的两个点是0.1。SpectrumWnd是频谱窗口对象,通过该对象SetBandLevel()函数将计算的结果传递给频谱窗口。

void UpdateSpectrum(short *sampleData, int totalSamples, int channels)  
{  
    float power[FFT_SIZE];  
    if(PowerSpectrumS(&m_hFFT, sampleData, totalSamples, channels, power))  
    {  
        int fpFen = FFT_SIZE / 2 / BAND_COUNT;  

        int level[BAND_COUNT];  
        for(int i = 0; i < BAND_COUNT; i++)  
        {  
            int centPos = i * fpFen + fpFen / 2;  
            double bandTotal = power[centPos - 2] * 0.1 + power[centPos - 1] * 0.15 + power[centPos] * 0.5 + power[centPos + 1] * 0.15 + power[centPos + 2] * 0.1;  
            level[i] = (int)(bandTotal + 0.5);  
        }  
        m_SpectrumWnd.SetBandLevel(level, BAND_COUNT);  
    }  
}

2 显示动态频谱

       动态频谱就是以一定的时间间隔更新频谱,使其看起来具有动态效果。图2展示了一个仿Winamp的频谱显示窗口,从这个静止的瞬间观察,频谱的每一个波段主要有三部分组成,分别是背景、当前强度级别和一条缓缓落下的细线(Top_Bar)。

2.1 实现方法

       频谱显示窗口的原理非常简单,只要熟悉Windows GDI 编程,实现一个动态频谱窗口非常容易。首先需要记录32个波段的数据,每个波段的数据包含三部分,需要一个列表记录当前各个波段的强度级别和当前Top_Bar的位置。每当一个buffer播放完成以后,UpdateSpectrum()函数会计算出相应波段的功率强度,并刷新当前各个波段的强度级别列表,根据选择的播放缓冲区buffer大小,刷新的频率应该在每秒5-10次左右。与此同时,内部的位置更新定时器也在周期地减少各个波段的强度级别的值,并降低Top_Bar的位置,为了使频谱显示平滑一点,更新定时器的频率要大于强度级别的刷新频率,一般应该在每秒15次以上。

       Top_Bar位置和强度级别的刷新就是一个不断较少的过程,但是减少的方式不一样。强度级别的减少可以是一个固定值,每次都减少一定的数量。Top_Bar则维持一个悬停时间,在悬停时间内位置不变化,悬停时间结束后,其值的减少是一个逐步加快的过程,并最终在强度级别减到0之前赶上强度级别的位置,这样使得频谱显示看起来生动有趣。下面给出更新定时器的处理代码,是本文的例子中使用的,仅供参考:

void CSpectrumWnd::UpdateLevelOnTimer()  
{  
    for(int i = 0; i < BAND_COUNT; i++)  
    {  
        if(m_curLevel[i] >= m_levelStep)  
            m_curLevel[i] -= m_levelStep;  
        else  
            m_curLevel[i] = 0;  

        if(m_topBar[i].wait > 0)  
            m_topBar[i].wait--;  
        else  
        {  
            m_topBar[i].level = (m_topBar[i].level > m_topBar[i].step) ? (m_topBar[i].level - m_topBar[i].step) : 0;  
            if(m_topBar[i].level <= m_curLevel[i])  
                m_topBar[i].level = m_curLevel[i];  

            if(m_topBar[i].step < 64)  
                m_topBar[i].step += (m_topBar[i].step / 2);  
        }  
    }  
}  

       m_levelStep是强度值每次减少的点数,Top_Bar的wait属性是悬停计数,用于控制悬停时间,当其减少到0时,则开始下降Top_Bar的位置,每次下降的点数是前一次下降点数的1.5倍,因此是一个逐步加快的过程。

2.2 杂项说明

       频谱显示窗口是一个需要高速绘图的窗口,直接使用GDI函数画频谱窗口已经被证明是低效的方法,不推荐使用。一般都是采用位图缓冲区的方式处理高速刷新的窗口,具体做法就是在一片位图数据中直接通过颜色值控制“生成”频谱显示的位图,然后用贴图的GDI函数直接“贴”到窗口DC上。

       由于声音和视觉信号在人类的神经和大脑之间传导过程存在差异,会导致声音和视觉在大脑中的反应有一个时间差,再加上声和光的传播速度本身也有很大的差异,因此,为了使频谱显示能有更好的感官体验,需要对频谱显示的时机做一些调整。一般来说,应该先将声音播放出来后再显示频谱,这就涉及一个问题,即声音的音频数据分段多长比较合适?这实际上是播放器音频缓冲区大小的选择问题,缓冲区不能太大,比如0.5秒以上的音频缓冲区,等播放完0.5秒后再显示频谱,视觉体验上就觉得对不上,鼓声都响了半天了频谱上才体现出来,这种感觉肯定不好。缓冲区太小也不好,首先离散傅立叶转换计算量大,需要一定的时间对音频数据进行处理,缓冲区太小的话就没有足够的时间进行计算,当然,现在的CPU都很强劲,这个不是主要问题,主要问题是如果缓冲器太小会导致频谱刷新的太频繁,这使得频谱显示看起来感觉不连贯,很机械。这方面我也没有理论的数据支撑,根据实践经验,音频缓冲区大小在0.05秒到0.2秒之间时,可以取得比较好的视觉体验,本文给出的例子程序使用了0.1s的音频缓冲区,对于我的感觉来说,效果还可以。朋友们如果有这方面的理论数据可以告诉我,本人将不胜感激。

3 结果展示

       本文在撰写过程中创建的例子程序是一个Wave文件播放程序,播放并显示一个跳动的频谱,外观仿Winamp的显示效果,绘制出来的频谱形状比较接近Winamp的显示,图2 是演示程序最终的效果,就到这里吧,下一篇再接着讲音频均衡器的实现。

       最后广播一下,应广大热心读者的要求,本专栏全部文章对应的演示代码,将提供打包下载,目前正在找存储位置,请大家关注博客的更新。

图 2 频谱显示演示窗口


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK