DSP

基于Unity的FFT快速傅里叶变换的频谱解析的研究_实时采样解析音频(1)

2019-07-13 17:50发布

Unity中的算法节拍映射:使用Unity API进行实时音频分析 对于实时分析,我们将尽最大努力在场景中正在播放的音频内或尽可能接近地检测节拍。 我们会在这里找到一些限制,但会有一个可用于许多用例的解决方案。 它还将很好地介绍执行预处理分析所需的概念。

Unity 特性

为了在Unity中播放音频,我们将始终使用AudioSource播放一个表示为AudioClip的文件。 一旦我们将音频文件导入为AudioClip,我们应该将加载类型设置为“Decompress on Load” ,以确保我们可以在运行时访问音频样本数据。 将AudioSource组件附加到场景中的GameObject,将AudioClip拖动到AudioSource中,确保选中“Play on Awake”,然后按“播放”。 您现在应该听到Unity中播放的音频文件。 一旦我们在Unity中播放音频,Unity API就会提供一些非常方便的助手来访问有关当前正在播放的音频的信息。 这使得我们对音频样本本身的实时分析非常简单。   我们有两个重要的助手Unity的API: AudioSource.GetOutputData AudioSource.GetSpectrumData 正如您所看到的(在撰写本文时),每个帮助程序的文档都不是很具描述性。 随着我们的进展,我会尝试增加一些清晰度。 GetOutputData将为我们提供一个表示特定通道随时间变化的幅度或响度的数组。 我们称之为“样本数据”。 虽然样本数据对于预处理变得更有用,但由于下一个帮助器GetSpectrumData,我们实际上并不需要它来进行实时节拍检测。 GetSpectrumData将为我们提供一个表示相对幅度的数组,我将其称为重要性,在特定通道上的时间样本的频域上。 我们称之为“频谱数据”。 这非常有用,因为这可以清楚地表明我们不仅在这个时间点发生了重大事件,而且还可以告诉我们哪些频率范围具有重大作用。 这可以帮助我们做出关于不同频率范围内发生的事情的明智决策,我们可以将其粗略地转换为轨道内的不同乐器。 引言中的GetSpectrumData正在执行快速傅里叶变换(FFT)以将时域上的幅度转换到频域,仅返回从FFT返回的复数数据的相对幅度部分。虽然FFT通常在从0Hz到采样率的整个频率范围内执行(在本例中让我们使用48kHz),0Hz - 采样率的一半(24kHz)是范围后半部分的镜像(24kHz-48kHz) 。该中点称为奈奎斯特频率(Nyquist frequency)。因此,GetSpectrumData和其他一些基于FFT的助手仅返回分析前半部分的相对幅度。这意味着执行FFT所需的音频样本数量是GetSpectrumData返回的频率仓数量的两倍。因此,要分析的频率粒度越高,生成该粒度所需的时间范围就越大。如果你想要1024个频率箱(frequency bins),每箱的粒度为23.43Hz,则需要2048个音频样本。需要更多的音频样本意味着收集这些样本需要更长的时间,这会增加模糊性以准确知道每个频率值在时域中的位置。这是一个权衡,你可以试验看看什么最适合你。我发现512和1024的频谱阵列大小都足以进行基本的起始检测。 我强烈建议您观看以下有关傅里叶变换的视频,以便更好地了解Unity为我们所做的事情:https://www.youtube.com/watch?v=spUNpyF58BY   对于GetSpectrumData的参数:   Channel 0在这里是一个安全的选择,因为如果我们有立体声音频,我们需要单独处理2个通道的样本数据。 Channel 0包含立体声样本的平均值,将每2个立体声样本组合成1个单声道样本。 这使我们能够更简单地决定音频中发生的事情。我们可以从众多FFT窗口中选择窗口并缩放我们的光谱数据。 我发现BlackmanHarris非常出 {MOD}地向我们展示了每个频率箱的不同动作,而箱之间没有太多泄漏。 我们提供的阵列将填充到我们提供的阵列的长度,以及最近播放的音频样本的频谱数据。 这意味着我们不必在GetSpectrumData之前调用GetOutputData。 数组长度必须是2的幂。FFT分析对于2的幂的样本大小最有效,Unity使用GetSpectrumData强制执行该建议。 我发现1024的频谱数组大小给了我们很好的粒度。 同样,这意味着Unity引擎将采集2048个音频样本。 如果我们知道音频采样率,我们可以找到支持的频谱数据频率范围,然后,使用我们的数组大小,我们可以快速找出阵列的每个索引所代表的频率范围。 通过频率分析 Unity可以通过静态成员AudioSettings.outputSampleRate告诉我们混音器的赫兹(Hz)音频采样率。这将为我们提供Unity播放音频的采样率,通常为48000或44100.我们还可以使用AudioClip.frequency获取单个AudioClip的采样率。 知道我们的采样率后,我们就可以知道FFT的最大支持频率,这将是采样率的一半。此时,我们可以除以我们的频谱长度,以了解每个bin(索引)代表的频率。 48000/2 = 24000Hz,位于我们支持的范围的顶部。我们通常只关心20Hz-20000Hz的音频,但额外的几赫兹不会弄乱任何东西。 每箱24000/1024 = ~23.47Hz。现在,显然我们没有足够的粒度来表示每个单独的频率,但对于大多数用例来说它应该足够好。在这个粒度下,我们的第10个bin将给出〜234Hz的相对幅度+/-相邻频率的小窗口。 使用预定义的标准来描述频率范围,我们可以根据哪些频率在我们的轨道中的某个时间点起作用来做出决策。您还可以更深入地尝试基于频率检测不同的音符。 为了确保这个工作正常并且我们正在进行数学计算,我从耳机测试中下载了音频,它只扫描了10Hz - 20kHz的频谱。赛道在2:08左右达到234Hz。因此,如果我将此轨道加载到我的AudioSource并在128秒调用GetSpectrumData,我应该会看到bin 10周围的操作。 我们做到了! 你可以在这里看到频段之间存在一些泄漏,但我们仍然足够精细,我们可以看到此时234Hz范围内有重大动作,正如我们预期的那样。   以下是自己进行此类测试的代码:   if (audioSource.time >= 128f && audioSource.time < 129f) { float[] curSpectrum = new float[1024]; audioSource.GetSpectrumData (curSpectrum, 0, FFTWindow.BlackmanHarris);   float targetFrequency = 234f; float hertzPerBin = (float)AudioSettings.outputSampleRate / 2f / 1024; int targetIndex = targetFrequency / hertzPerBin;   string outString = ""; for (int i = targetIndex - 3; i <= targetIndex + 3; i++) { outString += string.Format("| Bin {0} : {1}Hz : {2} |   ", i, i * hertzPerBin, curSpectrum[i]); }   Debug.Log (outString); }   好的,现在我们可以知道音轨中某个时间点的频率分布。 这打开了很多门。 最重要的是,目前,这意味着我们拥有使用Spectral Flux执行开始检测所需的数据。   使用光谱通量进行开始检测   如果您还没有,我真的鼓励您阅读上面链接的Mario的文章。在第1部分中,Mario定义了我们需要继续的许多术语。我们将跳到第6部分,将我们的实时频谱数据插入到频谱通量算法中。马里奥在解释什么是光谱通量以及我们如何使用光谱通量来检测节拍开始方面做得非常出 {MOD},因此我不会尝试在此重新编写。我将主要将Mario的预处理Java解决方案转换为实时Unity C#解决方案。   频谱通量就是在两个接近的时间点找到频谱数据之间每个区间的总差异。对我们来说,这实际上意味着将当前帧中播放的音频的频谱数据与上一帧的数据或我们上次检查的数据进行比较。你可以在这里看到我们已经通过实时分析将自己暴露给错误,因为我们受到歌曲和帧率本身的进步的限制。   每次我们调用GetSpectrumData时,我们都应该保留最新的频谱数据进行比较。如果我们每帧更新我们的频谱数据,那么这很简单。   float[] curSpectrum = new float[numSamples]; float[] prevSpectrum = new float[numSamples];   public void setCurSpectrum(float[] spectrum) { curSpectrum.CopyTo (prevSpectrum, 0); spectrum.CopyTo (curSpectrum, 0); }   太好了,现在我们有足够的历史来做我们的比较。我们想知道最新频谱数据和当前频谱数据之间每个频率仓的差异。为了清理数据,我们只会保持正差异,因此希望我们能够看到我们是否在整个频谱中起作用。我们称之为整流光谱通量。请记住,我们正在分析包含所有支持频率的整个频谱。如果你想在一个频率子集上运行相同的算法,例如只有低音和低音范围,就像指定你想要处理整流光谱通量的1024个长度谱的哪个索引一样简单。关于算法的其他任何事情都不需要改变。既然您已经知道如何确定哪个索引对应哪个频率仓,那么您应该能够将其与您的确切用例相匹配。   在Giant Scam,我们喜欢同时在多个频率范围内运行此算法,以消除噪音并磨练在某个时间点歌曲中可能发生的许多不同事物。然后,我们可以通过了解哪些“乐器”在音轨中的不同时间进行了多少动作来做出更好的游戏玩法决策。   这里,为简单起见,我们一次分析整个频谱。       float calculateRectifiedSpectralFlux() { float sum = 0f;   // Aggregate positive changes in spectrum data for (int i = 0; i < numSamples; i++) { sum += Mathf.Max (0f, curSpectrum [i] - prevSpectrum [i]); } return sum; }   接下来,我们希望能够基于我们针对特定光谱通量值周围(在过去和将来)的时间范围计算的光谱通量值来生成阈值。这将使我们能够确定光谱中的变化是否足够重要,以至于我们认为它是一个节拍。我们平均光谱通量值的帧并将其乘以我们的灵敏度乘数。如果我们处理的光谱通量值高于我们提高的平均值,我们就会开始!   当我们谈论实时时,这变得棘手,因为我们不能真正关注未来的光谱通量。您可以使用Unity做一些棘手的事情,例如将音频发送到静音混音器并在用户听到之前播放,但我们不打算在此处实现。   我们要做的只是在过去检测多个光谱通量值(我们的帧样本大小/ 2)。因此,如果我们的帧大小为30,一旦我们有30个光谱通量值,我们可以通过平均值1-30精确处理值15,将平均值乘以某个灵敏度乘数,并检查值15是否更高。因此,我们将开始处理值15并从那里向前移动到值16,这将与值2-31的平均值进行比较,依此类推。实时后面的〜15个光谱通量值可能比目前播放的音频大约落后半秒,具体取决于帧速率。     float getFluxThreshold(int spectralFluxIndex) { // How many samples in the past and future we include in our average int windowStartIndex = Mathf.Max (0, spectralFluxIndex - thresholdWindowSize / 2); int windowEndIndex = Mathf.Min (spectralFluxSamples.Count - 1, spectralFluxIndex + thresholdWindowSize / 2);           // Add up our spectral flux over the window float sum = 0f; for (int i = windowStartIndex; i < windowEndIndex; i++)  { sum += spectralFluxSamples [i].spectralFlux; }   // Return the average multiplied by our sensitivity multiplier float avg = sum / (windowEndIndex - windowStartIndex); return avg * thresholdMultiplier; } 其余功能非常简单。   首先,我们只关心通量高于阈值的部分。 我们称之为修剪后的光谱通量。 如果通量低于阈值,我们会说修剪后的光谱通量为0。     float getPrunedSpectralFlux(int spectralFluxIndex)  { return Mathf.Max (0f, spectralFluxSamples [spectralFluxIndex].spectralFlux - spectralFluxSamples [spectralFluxIndex].threshold); }   最后,我们可以确定光谱通量样本是否为峰值。 我们通过将样本的修剪光谱通量与其直接邻居进行比较来做到这一点。 如果它高于先前采样的修剪光谱通量和下一个,那么它就是一个峰值! 这意味着,当然,我们必须在当前播放的音频后面再多一个样本,以便我们分析的样本具有已经计算出它们的修剪光谱通量的邻居。   bool isPeak(int spectralFluxIndex) { if (spectralFluxSamples [spectralFluxIndex].prunedSpectralFlux > spectralFluxSamples [spectralFluxIndex + 1].prunedSpectralFlux && spectralFluxSamples [spectralFluxIndex].prunedSpectralFlux > spectralFluxSamples [spectralFluxIndex - 1].prunedSpectralFlux) { return true; } else { return false; } }   把它们放在一起并不是太棘手。 我们需要连续收集光谱数据,计算整流的光谱通量,然后看看过去(我们的窗口大小的一半),看看样本是否高于我们现在可以生成的阈值,然后去一个 更多的样本过去,以查看该样本是否是一个高峰。   这样做的示例脚本,显示上述索引体操,在这里:   public void analyzeSpectrum(float[] spectrum, float time) { // Set spectrum setCurSpectrum(spectrum);   // Get current spectral flux from spectrum SpectralFluxInfo curInfo = new SpectralFluxInfo(); curInfo.time = time; curInfo.spectralFlux = calculateRectifiedSpectralFlux (); spectralFluxSamples.Add (curInfo);   // We have enough samples to detect a peak if (spectralFluxSamples.Count >= thresholdWindowSize) { // Get Flux threshold of time window surrounding index to process spectralFluxSamples[indexToProcess].threshold = getFluxThreshold (indexToProcess);   // Only keep amp amount above threshold to allow peak filtering spectralFluxSamples[indexToProcess].prunedSpectralFlux = getPrunedSpectralFlux(indexToProcess);   // Now that we are processed at n, n-1 has neighbors (n-2, n) to determine peak int indexToDetectPeak = indexToProcess - 1;   bool curPeak = isPeak (indexToDetectPeak);   if (curPeak) { spectralFluxSamples [indexToDetectPeak].isPeak = true; } indexToProcess++; } else { Debug.Log(string.Format("Not ready yet.  At spectral flux sample size of {0} growing to {1}", spectralFluxSamples.Count, thresholdWindowSize)); } }   您可能会注意到我正在填充一个类,以便为每个光谱通量样本对算法的每个步骤的输出进行分组。   public class SpectralFluxInfo { public float time; public float spectralFlux; public float threshold; public float prunedSpectralFlux; public bool isPeak; }     通过对此信息进行分组,我可以实时绘制算法的输出。 我发现可视化算法的输出使得算法更有意义,并且它还可以为我们提供关于我们应该如何敏感地设置阈值的线索,因为我们可以看到在轨道播放时检测到峰值。   要开始看到一些结果,我们可以从播放歌曲的行为中调用analyzeSpectrum函数:     void Update() { audioSource.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris); spectralFluxAnalyzer.analyzeSpectrum (spectrum, audioSource.time); }     结果 我将我的朋友Terror Pigeon的歌曲“Chamber of Secrets for 1”插入我的AudioSource,并随着时间的推移可视化算法的输出。 在这里,您可以看到重低音节拍非常清晰一致。 如果您发现自己过度录音,可以将阈值提高一点以消除一些额外的噪音。       每个绿点是经过整流的光谱通量样本。您可以看到我们一直到绿线都有绿点,表示音频轨道中当前播放的时间。蓝点是该时间点的阈值。如您所见,由于计算平均值所需的样本时间范围,我们只能实时生成几个样本的阈值。红点是我们的峰值,在这种情况下是在歌曲开头的均匀间隔的低音节拍。由于我们在这里分析整个频谱,所以低音节拍并不总是在我们的曲线的y轴上的相同点(整流的光谱通量值),因为在歌曲中还有其他事情,频率低音之外的范围,强度可能会增加或减少。   那么你现在能做到什么,你有峰值?嗯,峰值是开始的明确指标,并且开始是节拍的明确指标。您现在在技术上跟踪歌曲中发生的最重要的节拍。您可以使用此信息来生成项目,影响环境,可视化音频轨道等等。问题是您总是会在实时音频背后的几个样本,这不是最佳的。您可以使用我之前提到的混音器查看Unity hack,或者在第一次播放歌曲时存储节拍并将结果缓存以供日后使用。如果你像我一样并且你不想要这些限制,那么我们应该找到一种预先处理整个音频文件的方法,通过我们相同的光谱通量算法运行所有样本,这样我们就可以检测到所有在游戏之前节拍 - 即使这是我们第一次看到音轨。在Unity中进行预处理音频功能需要一些工作,但它肯定是可行的。   如有错误欢迎批评指正 qq : 940299880