1. 程式人生 > >基於Unity的FFT快速傅立葉變換的頻譜解析的研究_預處理音訊分析(2)

基於Unity的FFT快速傅立葉變換的頻譜解析的研究_預處理音訊分析(2)

Unity中的演算法節拍對映:預處理音訊分析

如果您還沒有閱讀本系列中的上一篇文章“關於FFT的音訊解析的研究_實時取樣解析音訊(1)”,使用Unity API進行實時音訊分析,請在閱讀之前花些時間這樣做。 它涵蓋了執行預處理分析所需的許多核心概念。

在進行實時分析時,我們發現為了檢測節拍,我們必須略微落後於當前播放的音訊。 我們也只能使用直到當前時間在軌道上檢測到的節拍來做出決定。 我們可以通過預先處理整個音訊檔案並在向用戶播放音訊之前檢測所有節拍來消除這些限制。

Unity特性

雖然Unity的大部分助手都是用於實時分析的,但它確實為我們提供了一個可以讓我們入手的幫手。 要預先處理整個音訊檔案,我們需要該檔案的所有樣本資料。 Unity允許我們使用AudioClip.GetData獲取資料。 正如您在文件示例中所看到的,它允許我們使用剪輯包含的所有示例填充陣列。

AudioSource aud = GetComponent<AudioSource>();

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中編譯之前確實需要一些小的轉換。我將把我的轉換版本放在這裡:

如果我們在檔案頂部新增對新庫的引用,我們應該能夠編譯沒有問題。

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