前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >音乐游戏&音频解析 ABC(下)

音乐游戏&音频解析 ABC(下)

作者头像
用户2615200
发布2018-08-02 17:21:52
1.3K0
发布2018-08-02 17:21:52
举报

  四. 路途

  接着上回所述,我们现在要解决的问题便是:

  “2. 通过所得数据通过一定方法解析出其中的歌曲信息”

  首先我们应该先确定一下我们到底需要解析歌曲的哪些信息?歌名?作者?呵呵,都不是,我们要解析的应该是歌曲的“信号”属性(譬如歌曲的BPM)。然而歌曲的这些属性非常繁杂,有些解析起来比较简单,有些提取起来则比较困难,出于简单考虑,我们这次的目标就定于歌曲的Onset属性上,一方面是因为Onset是歌曲的重要特征之一,通过它我们可以了解不少歌曲信息;另一方面则是由于提取Onset的方法也相对容易,算法过程不会显的艰辛苦涩。(当然的,歌曲或者说音频信号的另一些特性属性就不那么“可爱”了,譬如MFCC,解析过程就相对繁琐艰涩,对此有兴趣的朋友可以仔细看看:))

  那么到底什么是歌曲的Onset呢?非专科出身的我们在此便简单理解:所谓Onset,就是歌曲中音符的起始点,或者更简单的说,就是你在歌曲中听到的“重音”点(我本人的“简陋”理解:)),通过歌曲的Onset,我们能够大体感觉出一首歌的节奏变化,而通过歌曲的节奏变化,我们便能简单的辅以一些规则元素(譬如目前音乐游戏中“泛滥”的节奏点击),使之成为一个游戏 :)

  OK,我们确认了我们所需的音乐信息,那么接下来的问题便是:通过什么方法来获取呢?前面我们已经顺利获取了歌曲的PCM数据,内容大概便是歌曲在各个时间点的波形幅值,或是一个整数,或是一个小数(规范化后),仅仅通过这些“原生”数据,我们确实也可以得到不少信息(譬如ZCR),但是这还远远不够,如果我们想要得到相对精确的Onset信息,还要借助另外的方法,而这方法便是在DSP领域“无人不知”的——傅里叶变换 :)

  关于傅里叶变化,本人仅是初通,网上有不少优秀的教程(譬如这里),有兴趣的朋友可以仔细看看,而就傅里叶变化在信号处理中的作用来讲,我们可以简单的这么理解:前面我们提到过声波,当时简单的就以一个正弦波来替代了,而实际上的声波自然比这要复杂,但是我们仍然可以将其认为为多种正弦波的叠加,即实际上的声波不是单一的正弦波,而是多种正弦波(的叠加),而傅里叶变化就是帮助我们将信号(声波)分解为多种正弦波的一种方法。

  有了这些歌曲的频率数据,我们便能获取歌曲的更多信息,譬如判断当前的歌曲是“清亮”的还是“低沉”的,我们只要简单的查询歌曲频率的幅值,如果高频部分“能量”大,即可判断其相对“清亮”,相反,如果低频部分“能量”大,则可认为其相对“低沉”。不过可惜的是,傅里叶变换虽然作用巨大,但是其一直有一个致命的弱点:计算复杂度较高,对于一些实时程序(譬如游戏)基本不能适用。那怎么办呢?这时我们便需要仰仗快速傅里叶变换了 :)

  所谓快速傅里叶变换,顾名思义,其实便是一种快速计算傅里叶变换的算法,当然了,这种的说法有些欠妥(傅里叶变换本身还有细分,譬如离散傅里叶变换,快速傅里叶变换实际上是针对离散傅里叶变换而提出的),但是好在我们目前还不需要去理会,大概知道快速傅里叶变换可以帮助我们快速计算出歌曲(音频)信号的频率信息即可。

  OK,至此我们已经可以计算歌曲的频率信息了,那么接下来的问题就是:我们如何通过这些频率信息获取我们想要的歌曲信息(即Onset)呢?在此我们又要介绍一个新的名词:Spectral Flux,有兴趣的朋友可以仔细了解,其大概意思便是频率能量差异。我们之前提到Onset即是一些我们在歌曲中听到的一些“重音”,而这些音之所以被称为“重音”,其实便是因为其“前音”较轻,以频率信息来说的话,即其“前音”的频率能量较弱,而当前“音”的频率能量较强,我们通过检测这些频率能量差异,便可以找出我们所需要的Onset :)(当然,检测Onset的方法远不止这些,有个小巧的OnsetsDS库提供了不少相关方面的参考,有兴趣的朋友可以看看)

  在此我们暂停脚步,稍稍总结一下我们先前所讲的各项步骤:

  1. 首先我们通过一定方法(譬如之前提到的libsndfile)从歌曲文件中获取歌曲的PCM数据(或者说音频信号)。

  2. 应用快速傅里叶变换对这些PCM数据(或者说音频信号)进行变换,以获取音频信号中的频率信息。

  3. 再使用一定方法(譬如Spectral Flux)从这些频率信息中获取我们需要的歌曲信息(譬如Onset)

  4. 通过这些歌曲信息(譬如Onset),结合我们的游戏规则设计(譬如节奏点击),一个简单的音乐游戏原型就完成了 :)

  五. 终点

  最后,让我们简单的辅以一些代码细节来分别讲一讲上面所述的四个步骤,处于简单考虑,此处我们省略了音频数据获取(有兴趣的朋友可以以libsndfile为出发点进行了解)和快速傅里叶变换的部分(关于FFT(即快速傅里叶变换)的实现可以参考这里,当然网上也有非常优秀的FFT程序库,譬如FFTW,有兴趣的朋友可以仔细了解一下),而直接选用BASS库来承担这些工作,但就基本原理而言,并没有什么差别:

  首先是 DSPConfig.h 文件,非常简单:

#ifndef __DSP_CONFIG_H__

#define __DSP_CONFIG_H__

//! FFT block frames count

const size_t DSP_AUDIO_FRAME_PER_BLOCK = 4096;

#endif // __DSP_CONFIG_H__

  内容目前只是定义一个常量而已,其意义便是FFT运算时的每一段的帧数。前面我们有提到过PCM中的取样操作,而每一次取样操作所取得的数值便称为帧,FFT运算时需要确定所操作的帧数数量,而DSP_AUDIO_FRAME_PER_BLOCK便是用于定义这个数量。

  接着是IDSPEventDelegate.h,该文件中定义了不少解析中的结构:

//! dsp stream event

enum EDSPEvent

{

EDE_SpectrumBegin = 0, //< dsp spectrum begin event

EDE_Spectrum, //< dsp spectrum event

EDE_SpectrumEnd //< dsp spectrum event end

};

  EDSPEvent定义了解析过程中的所产生的事件(信号)。

//! audio info struct

struct AudioInfo

{

int samplerate; //< audio samplerate

int channel; //< audio channel

AudioInfo():samplerate(0),channel(0) {};

AudioInfo(const AudioInfo& audioInfo):samplerate(audioInfo.samplerate),channel(audioInfo.channel) {};

AudioInfo& operator = (const AudioInfo& audioInfo)

{

samplerate = audioInfo.samplerate;

channel = audioInfo.channel;

return *this;

}

};

  简单的AudioInfo结构用以存储歌曲的各项属性(譬如采样率、声道等等)。

//! dsp event struct

struct SDSPEventData

{

EDSPEvent event; //< event type

AudioInfo audioInfo; //< audio info

size_t curFrame; //< current audio frame

float curTime; //< current audio frame time

size_t spectrumCount; //< spectrum count

const float* spectrumData; //< event extra data

// 省略的构造赋值函数

};

  这个结构定义了解析过程中的事件参数,包括事件类型,当前帧数等等,细节可以参考源码 :)

//! dsp event listener

class IDSPEventDelegate

{

public:

virtual ~IDSPEventDelegate() {};

//! dsp event handler

virtual void OnEvent(const SDSPEventData& event) = 0;

};

  最后的便是IDSPEventDelegate,用于处理DSP事件的代理,有过iOS开发经验的朋友对于Delegate应该是再熟悉不过了,大抵很类似于Observer,有兴趣的朋友可以仔细看看 :)

  好了,接下了便是DSPParserBASS.h,功能其实也很直白,就是使用BASS库解析出相应文件的频率信息,头文件内容基本如下:

class DSPParserBASS

{

public:

DSPParserBASS():m_BASSStream(0),m_curFrameCount(0),m_dspDelegate(NULL) {};

//! do DSP stream operation by the given filename

bool DoDSPParse(const char* filename);

//! dsp event delegate

void SetDSPEventDelegate(IDSPEventDelegate* delegate_) { m_dspDelegate = delegate_; }

IDSPEventDelegate* GetDSPEventDelegate() { return m_dspDelegate; }

private:

// dispatch helper function

void dispatchSpectrumEvent(EDSPEvent event, size_t spectrumCount, const float* spectrum);

// get bass FFT flag

DWORD getBassFFTFlag(size_t windowSize) const;

private:

HSTREAM m_BASSStream; //< BASS stream handler

size_t m_curFrameCount; //< current frame count

AudioInfo m_audioInfo; //< audio info

private:

IDSPEventDelegate* m_dspDelegate; //< dsp event delegate

};

  SetDSPEventDelegate与GetDSPEventDelegate不用详述,设置和获取Delegate而已,DoDSPParse则是核心的用以解析的函数,在此我们稍稍细看一下他:

bool DSPParserBASS::DoDSPParse(const char* filename)

{

// first load the file

m_BASSStream = BASS_StreamCreateFile(false, filename, 0, 0, BASS_STREAM_DECODE|BASS_STREAM_PRESCAN);

if (!m_BASSStream)

{

printf("Error to load audio file : %s", filename);

return false;

}

// get the channel info

BASS_CHANNELINFO info;

BOOL ret = BASS_ChannelGetInfo(m_BASSStream, &info);

if (!ret)

{

printf("Error to load audio info : %s", filename);

return false;

}

// get audio info

m_audioInfo.channel = info.chans;

m_audioInfo.samplerate = info.freq;

m_curFrameCount = 0;

// dispatch begin event

dispatchSpectrumEvent(EDE_SpectrumBegin, 0, NULL);

// allocate buffer

const size_t sizeCount = DSP_AUDIO_FRAME_PER_BLOCK / 2;

float* spectrum = new float[sizeCount];

// use BASS get spectrum data, more info see BASS doc

DWORD fftFlag = getBassFFTFlag(DSP_AUDIO_FRAME_PER_BLOCK);

DWORD spectrumRet = BASS_ChannelGetData(m_BASSStream, spectrum, fftFlag);

while (spectrumRet != -1)

{

int spectrumCount = spectrumRet / sizeof(float);

m_curFrameCount += spectrumCount;

// dispatch parsing event here

dispatchSpectrumEvent(EDE_Spectrum, spectrumCount, spectrum);

spectrumRet = BASS_ChannelGetData(m_BASSStream, spectrum, fftFlag);

}

// dispatch end event

dispatchSpectrumEvent(EDE_SpectrumEnd, 0, NULL);

// release the bass stream here

BASS_StreamFree(m_BASSStream);

m_BASSStream = NULL;

// release buffer

delete[] spectrum;

return true;

}

  通过注释其实流程非常明了:首先是创建Stream,然后获取Stream的信息,接着按照DSP_AUDIO_FRAME_PER_BLOCK大小一段一段的解析Stream的频率信息,并发送相应消息,最后自然是做一些清理 :)至于相关函数细节,有兴趣的朋友可以参考一下BASS库的文档,在此就不细述了。

  最后,便是真正执行Onset检测的AudioAnalysisOnsetSpectralFlux:

class AudioAnalysisOnsetSpectralFlux: public IDSPEventDelegate

{

public:

AudioAnalysisOnsetSpectralFlux():m_thresholdTime(0.16f),m_multiplier(1.5f) {};

//! reset method

void Reset();

//! dsp event handler

virtual void OnEvent(const SDSPEventData& event);

//! get the onset count

size_t GetOnsetCount() const { return m_onset.size(); }

//! get the onset time which is detected

float GetOnsetByIndex(size_t index) const

if (index >= m_onset.size()) return -1;

return m_onset[index];

}

private:

// get spectrum data

void getSpectrumData(const SDSPEventData& event);

// do spectrum analysis

void doSpectrumAnalysis();

private:

std::vector<float> m_lastSpectrum; //< last spectrum buffer

std::vector<float> m_spectralFlux; //< spectral flux TODO: more detail, like bin spectral flux

std::vector<float> m_threshold; //< threshold flux buffer

std::vector<float> m_prunnedSpectralFlux; //< prunne spectral flux

std::vector<float> m_onset; //< onset storage

private:

float m_thresholdTime; //< threshold time

float m_multiplier; //< onset detect multiplier

private:

AudioInfo m_audioInfo; //< audio info buffer

};

  内容可能稍显凌乱,但就使用上来说其实就是:GetOnsetCount和GetOnsetByIndex,用途便是获取Onset个数和Onset信息(时间点),非常简单。

  在此我们仔细看一下doSpectrumAnalysis这个函数,因为其便是用于解析Onset的核心函数:

void AudioAnalysisOnsetSpectralFlux::doSpectrumAnalysis()

{

//////////////////////////////////////////////////////////////////////////

size_t thresholdWindowSize = 10;

//////////////////////////////////////////////////////////////////////////

// do threshold

for (size_t i = 0; i < m_spectralFlux.size(); ++i)

{

int start = std::max(0, int(i - thresholdWindowSize));

int end = std::min((int)(m_spectralFlux.size() - 1), (int)(i + thresholdWindowSize));

float mean = 0;

for (int j = start; j <= end; ++j)

{

mean += m_spectralFlux[j];

}

mean /= (end - start);

m_threshold.push_back(mean * m_multiplier);

}

// do prune

for (size_t i = 0; i < m_threshold.size(); ++i)

{

if (m_threshold[i] <= m_spectralFlux[i])

{

m_prunnedSpectralFlux.push_back(m_spectralFlux[i] - m_threshold[i]);

}

else

{

m_prunnedSpectralFlux.push_back(0);

}

}

// at last, we analysis onset

for (int i = 0; i < int(m_prunnedSpectralFlux.size() - 1); ++i)

{

if (m_prunnedSpectralFlux[i] > m_prunnedSpectralFlux[i+1])

{

m_onset.push_back(i * DSP_AUDIO_FRAME_PER_BLOCK * 1.0f / m_audioInfo.samplerate);

}

}

}

  此处相交简单的前后帧数频率对比,做了更多的工作:首先是确定了一个阈值窗口,即thresholdWindowSize,用以进一步过滤太短的差异变化,方法是取一定范围内的平均值,接着又做了进一步的“裁剪”,用以记录平均值与原值的差异,最后通过差异的大小,确定Onset位置(差异大则说明频率能量改变大,也就相当于Onset的位置)。

  OK,细节到此为止,有兴趣的朋友可以自取一份源码来看看。

六. 尾声

  前面的过程貌似简单,但实际上仍然有不少问题存在,其中一个便是效率,虽然FFT的运算速度已经较普通的傅里叶变换有了长足的进步,但是有时仍然难以满足实时程序需求,尤其像游戏这类对效率要求极高的程序更是难以适从;另一个与游戏相关的问题便是歌曲信息的进一步运用,虽然上述的Onset信息很大程度上表现出了歌曲的节奏变化,但是这与优秀的音乐游戏关卡还有不少距离,优秀的音乐游戏关卡除了能表现出这些节奏变化,还要能让我们区分出其中的主次,辨别出歌曲的层次,以及其节奏位置排布也要附有变化性和适用性等等,而这些单靠单薄的一个歌曲Onset信息还远远不够……

  好了,关于音乐游戏和音频解析的一些内容算是粗糙的讲了一些基础,算作总结,也希望能给有兴趣的朋友做些参看,网上有很多优秀的参考:譬如有很多程序库已经帮我们解决了上面的问题(譬如AubiolibxtractMarsyas等等),当然这些库比较偏向MIR,稍稍学术化一些,而QueenMary则是研究MIR领域中不错的一个机构,感觉很权威,他们有个工具SonicVisualiser很不错 :)当然也有不少偏向运用的程序库,譬如Maximilian 、ALF等等,都是不错的相关参考,网上自然还有不少教程,譬如这里,强力推荐,上面文章中的很多内容都是借鉴于此:)另外的书籍也有不少,其中一本之前提到过,这里再推荐一次(这里) :)

  OK,闲话结束,下次再见吧 :)

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2013年03月02日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档