Unity中的算法节拍映射:预处理音频分析
如果您还没有阅读本系列中的上一篇文章“关于FFT的音频解析的研究_实时采样解析音频(1)”,使用Unity API进行实时音频分析,请在阅读之前花些时间这样做。 它涵盖了执行预处理分析所需的许多核心概念。
在进行实时分析时,我们发现为了检测节拍,我们必须略微落后于当前播放的音频。 我们也只能使用直到当前时间在轨道上检测到的节拍来做出决定。 我们可以通过预先处理整个音频文件并在向用户播放音频之前检测所有节拍来消除这些限制。
Unity特性
虽然Unity的大部分助手都是用于实时分析的,但它确实为我们提供了一个可以让我们入手的帮手。 要预先处理整个音频文件,我们需要该文件的所有样本数据。 Unity允许我们使用AudioClip.GetData获取数据。 正如您在文档示例中所看到的,它允许我们使用剪辑包含的所有示例填充数组。
AudioSource aud = GetComponent
();
float[] samples = new float[aud.clip.samples * aud.clip.channels];
aud.clip.GetData(samples, 0);
AudioClip.samples是剪辑中包含的单通道或组合通道样本的总数。 这意味着如果一个剪辑有100个立体声样本,AudioClip.samples将是50.我们乘以AudioClip.channels,因为AudioClip.GetData将以交错格式返回样本数据。 意思是,如果我们有一个立体声的剪辑,数据将返回:
L =左声道; R =右声道
[L,R,L,R,L,R,...]
这意味着,如果我们想要的数据类似于Channel 0在实时助手AudioSource.GetOutputData中返回的数据,我们需要遍历我们的全套样本并将每两个样本平均得到单声道样本。 你会发现,由于给定剪辑中的样本数量,这对CPU来说非常沉重。
我们来算一算:
如果我有一个5分钟(300秒)的音轨,采样率为每秒48000个采样,这意味着我们有:
300 * 48000 = 14,400,000个样本(可在AudioClip.samples中找到)
但是,如果我们是立体声,我们有交错的样本,这使我们的样本数量加倍
14,400,000 * 2 = 28,800,000个样本
我们真的想要1440万个单声道样本,所以我们将迭代2880万个样本并对每对立体声样本求平均值。如果您希望使用offset参数在一系列帧上执行此操作以获取较小的音频样本块,您应该知道偏移参数实际应用于组合通道的样本数(AudioClip.samples)。因此,如果您传递10的偏移量,您仍然会获得交错的立体声采样,但它会在立体声数据中启动20(10对立体声)采样。
无论你在做什么,迭代超过2880万个样本在Unity的主线程上做得不够快。我建议使用System.Threading库将此任务传递给后台线程,该库与Unity C#一起打包。我们不会深入研究System.Threading,但你应该知道Unity强烈建议(通常以抛出异常的形式)你不能从后台线程中访问任何Unity API功能。从您需要的AudioSouce / AudioClip中获取任何值,使其可访问,然后在线程内部进行数学运算并将Unity API的所有访问权限保留给主线程。
让我们设置将立体声样本转换为单声道。首先,我们需要从Unity API中获取我们稍后在生成后台线程时可能需要的任何属性:
// Need all audio samples. If in stereo, samples will return with left and right channels interweaved
// [L,R,L,R,L,R]
multiChannelSamples = new float[audioSource.clip.samples * audioSource.clip.channels];
numChannels = audioSource.clip.channels;
numTotalSamples = audioSource.clip.samples;
clipLength = audioSource.clip.length;
// We are not evaluating the audio as it is being played by Unity, so we need the clip's sampling rate
sampleRate = audioSource.clip.frequency;
audioSource.clip.GetData(multiChannelSamples, 0);
现在是组合频道的循环:
float[] preProcessedSamples = new float[this.numTotalSamples];
int numProcessed = 0;
float combinedChannelAverage = 0f;
for (int i = 0; i < multiChannelSamples.Length; i++) {
combinedChannelAverage += multiChannelSamples [i];
// Each time we have processed all channels samples for a point in time, we will store the average of the channels combined
if ((i + 1) % this.numChannels == 0) {
preProcessedSamples[numProcessed] = combinedChannelAverage / this.numChannels;
numProcessed++;
combinedChannelAverage = 0f;
}
}
现在,我们的浮点数组preProcessedSamples包含的格式与AudioSource.GetOutputData实时返回的格式相同,但它包含从轨道开头到结尾的样本,而不仅仅是当前播放音频的样本。 您可以将输出与AudioSource.GetOutputData的输出进行比较,以查看歌曲中的某个时间点,并确定它们非常接近。 这里有一个问题是,AudioSource.GetOutputData返回最近播放的1024(或数组的长度)样本,如果您正在进行数学运算以找到代表某个时间点的预处理样本中的起点,这可能会让您失望 在音轨上。
使用频率(和外部库)
我们有样本数据,这是随时间变化的幅度,但我们想要的是频谱数据,或者在某个时间点频谱上的重要性。在我们的实时分析中,我们使用Unity的辅助AudioSource.GetSpectrumData实现了这一功能,以执行快速傅里叶变换。 Unity没有用于对原始样本数据执行FFT的等效帮助器,因此我们希望查看其他地方。
我发现了一个名为DSPLib的基本傅立叶变换的非常轻量级的C#实现。它包括离散傅立叶变换和快速傅立叶变换,许多可用窗口,最重要的是,它为我们提供了表示与使用AudioSource.GetSpectrumData时相同的相对幅度的输出。
单击“仅下载库C#代码”以下载FFT的源代码,然后解压缩DSPLib.cs文件并将其放在项目的目录中。
这里有一个问题。 DSPLib在很大程度上依赖于C#.NET的System.Numerics库提供的Complex数据类型。您会看到Unity抱怨无法找到Complex数据类型或System.Numerics。这是因为Unity提供的.NET风格的Mono不包含System.Numerics库。我们可以做些什么来解决这个问题,直接转到源代码(在本例中为Microsoft的github页面)并下载Complex.cs并将其放在我们的项目中。 Complex.cs在Unity中编译之前确实需要一些小的转换。我将把我的转换版本放在这里:
https://github.com/jesse-scam/algorithmic-beat-mapping-unity/blob/master/Assets/Lib/External/NET/Complex.cs
如果我们在文件顶部添加对新库的引用,我们应该能够编译没有问题。
using System.Numerics;
using DSPLib;
在我们将光谱数据发送到光谱通量算法之前,我将继续向您展示执行其余样品制备的代码。 如果我们已经完成了所有事情,我们根本不需要改变光谱通量算法。
public void getFullSpectrumThreaded() {
try {
// We only need to retain the samples for combined channels over the time domain
float[] preProcessedSamples = new float[this.numTotalSamples];
int numProcessed = 0;
float combinedChannelAverage = 0f;
for (int i = 0; i < multiChannelSamples.Length; i++) {
combinedChannelAverage += multiChannelSamples [i];
// Each time we have processed all channels samples for a point in time, we will store the average of the channels combined
if ((i + 1) % this.numChannels == 0) {
preProcessedSamples[numProcessed] = combinedChannelAverage / this.numChannels;
numProcessed++;
combinedChannelAverage = 0f;
}
}
Debug.Log ("Combine Channels done");
Debug.Log (preProcessedSamples.Length);
// Once we have our audio sample data prepared, we can execute an FFT to return the spectrum data over the time domain
int spectrumSampleSize = 1024;
int iterations = preProcessedSamples.Length / spectrumSampleSize;
FFT fft = new FFT ();
fft.Initialize ((UInt32)spectrumSampleSize);
Debug.Log (string.Format("Processing {0} time domain samples for FFT", iterations));
double[] sampleChunk = new double[spectrumSampleSize];
for (int i = 0; i < iterations; i++) {
// Grab the current 1024 chunk of audio sample data
Array.Copy (preProcessedSamples, i * spectrumSampleSize, sampleChunk, 0, spectrumSampleSize);
// Apply our chosen FFT Window
double[] windowCoefs = DSP.Window.Coefficients (DSP.Window.Type.Hanning, (uint)spectrumSampleSize);
double[] scaledSpectrumChunk = DSP.Math.Multiply (sampleChunk, windowCoefs);
double scaleFactor = DSP.Window.ScaleFactor.Signal (windowCoefs);
// Perform the FFT and convert output (complex numbers) to Magnitude
Complex[] fftSpectrum = fft.Execute (scaledSpectrumChunk);
double[] scaledFFTSpectrum = DSPLib.DSP.ConvertComplex.ToMagnitude (fftSpectrum);
scaledFFTSpectrum = DSP.Math.Multiply (scaledFFTSpectrum, scaleFactor);
// These 1024 magnitude values correspond (roughly) to a single point in the audio timeline
float curSongTime = getTimeFromIndex(i) * spectrumSampleSize;
// Send our magnitude data off to our Spectral Flux Analyzer to be analyzed for peaks
preProcessedSpectralFluxAnalyzer.analyzeSpectrum (Array.ConvertAll (scaledFFTSpectrum, x => (float)x), curSongTime);
}
Debug.Log ("Spectrum Analysis done");
Debug.Log ("Background Thread Completed");
} catch (Exception e) {
// Catch exceptions here since the background thread won't always surface the exception to the main thread
Debug.Log (e.ToString ());
}
}
您在此处看到的大部分内容都基于DSPLib网站上提供的示例。 基本步骤是:
1将立体声样本组合为单声道
2以块的形式迭代样本,大小为2的幂. 1024,这是我们的神奇数字。
3抓取当前要处理的样本块。
4使用一个可用的FFT窗口缩放和窗口。 我发现DSPLib在Hanning的实现在这里非常可靠。
5执行FFT以检索复杂的频谱值,这将是我们的样本数据的一半 - 在这种情况下为512。
6将我们的复杂数据转换为可用格式(双精度数组)
7将我们的窗口比例因子应用于输出
8计算当前块表示的当前音频时间
9将我们的缩放,窗口,转换后的光谱数据传递给我们的光谱通量算法。 你可以在这里看到我设置我们使用浮点数,而DSPLib真的喜欢双精度。 我通过转换浮动而不是将频谱通量算法转换为使用双精度来增加一些开销。
在实时分析中,我们要求Unity为我们提供1024个频谱值,这意味着Unity会在时域内采样2048个音频样本。 在这里,我们提供了来自时域的1024个音频样本,它为我们提供了512个频谱值。 这意味着我们的频率仓粒度略有不同。 我们当然可以提供2048个音频样本,使其具有与我们在实时分析中完全相同的粒度,但我发现在光谱中的不同点处具有512个分区是非常合格的。
对于512个箱,我们只需将支持的频率范围(我们的采样率/ 2 - 奈奎斯特频率)除以512,以确定每个箱所代表的频率。
48000/2/512 = 每箱46.875Hz。
我们不需要在此处使用Spectral Flux算法重新定义我们的起始检测。 它保持完全相同,因为我们以与我们在进行实时分析时可用的方式类似的方式格式化了我们的频谱数据。
浏览光谱通量输出
音频时间的计算非常简单。我们知道我们的样本数据是随时间变化的幅度,因此每个索引必须代表不同的时间点。我们的采样率和我们在样本数据中的当前位置可以粗略地(在几毫秒内)告诉我们当前样本块的代表什么时间。
让我们从实时例子中获取我们的恐怖鸽歌。这是04:27,或267秒。剪辑的采样率为每秒44100(AudioClip.frequency)样本。所以,我们希望大致有:
44100 * 267 = 11,774,700个样本
记录AudioClip.samples为我们提供了11,782,329个样本。比我们预期的多8k个样品,或0.18秒。这只是因为音轨长度不是267秒。
我们需要知道每个块处理多少时间,以便我们知道光谱通量样本所代表的时间。
每个样品1/44100 = ~0.0000227秒
0.0000227 * 1024 =每块0.023秒
如果我们有可用的信息,我们可以通过将轨道长度(以秒为单位)除以样本总数得出相同的结果。
每个样品267 / 11,774,700 = ~0.0000227秒
0.0000227 * 1024 =每块0.023秒
我们也可以反过来进行数学计算,以了解频谱通量指数对应于歌曲中的特定时间。聆听这首歌,我听到的是低音节拍大约3.6秒。还有很多其他节目,但让我们用这个作为例子。
时间除以每个样本的时间长度应该给我们索引,但我们必须记住,我们已经按照每个块的1024个样本进行分组以获得我们的光谱通量。
所以在时间3.6:
3.6 / 0.023 = 156.52 - 所以我们期望在指数156附近找到一个峰值。
public int getIndexFromTime(float curTime) {
float lengthPerSample = this.clipLength / (float)this.numTotalSamples;
return Mathf.FloorToInt (curTime / lengthPerSample);
}
public float getTimeFromIndex(int index) {
return ((1f / (float)this.sampleRate) * index);
}
int spectralFluxIndex = getIndexFromTime(audioSource.time) / 1024;
float curTime = getTimeFromIndex(i) * 1024;
结果
我们的光谱通量输出中的每个指数仅代表0.023秒,而我通过耳朵选择时间3.6,因此我们可能不正确的目标。 让我们看看指数156每个方向的10个样本,总共大约半秒,看看我们是否接近。
我们的算法在索引148处找到了一个峰值。所以我是8个索引,或大约0.184秒。 不是太寒酸。
从每个方向看一个更大的窗口,我们可以看到我们正在记录每18个索引的峰值,或者每0.414秒。 这意味着我们正在分析的部分歌曲的节奏必须是~145 BPM。 可以肯定的是,我问Terror Pigeon这首歌的实际BPM是什么,他说我是2关,这首歌是143 BPM。 分析〜1秒的时间框架还不错!
您可以使用以下代码进行类似的比较:
int indexToAnalyze = getIndexFromTime(3.6f) / 1024;
for (int i = indexToAnalyze - 30; i <= indexToAnalyze + 30; i++) {
SpectralFluxInfo sfSample = preProcessedSpectralFluxAnalyzer.spectralFluxSamples[i];
Debug.Log(string.Format("Index {0} : Time {1} Pruned Spectral Flux {2} : Is Peak {3}", i, getTimeFromIndex(i) * spectrumSampleSize, sfSample.prunedSpectralFlux, sfSAmple.isPeak));
}
让我们并排绘制实时分析和预处理分析的输出进行比较。
我们的实时音符位于顶部,我们的预处理音符位于底部。 您可以看到有一些偏斜,但实时图中最高的9个峰大致对应于预处理图上的相同9个峰。 偏斜是因为我们的实时索引大约是1帧的时间间隔,这与我们所知的预处理索引的距离不同,我们知道该轨道相距0.023秒。 您可以看到预处理图中的波动较少且过度记录较少,当然,我们的预处理图超出了实时范围。 我们可以跳到歌曲的任何时间,我们的预处理光谱通量将可用。
我们现在在向用户播放音频之前映射音频文件的所有节拍。 虽然结果可能会有所不同,但这应该可以让您很好地开始根据音频文件中的节拍创建自己的游戏玩法。
如有错误欢迎批评指正
qq : 940299880