預覽:
捕捉音效卡輸出:
實現音訊視覺化, 第一步就是獲得音訊取樣, 這裡我們選擇使用計算機正在播放的音訊作為取樣源進行處理:
NAudio 中, 可以藉助 WasapiLoopbackCapture 來進行捕捉:
WasapiLoopbackCapture cap = new WasapiLoopbackCapture();
cap.DataAvailable += (sender, e) => // 錄製資料可用時觸發此事件, 引數中包含音訊資料
{
float[] allSamples = Enumerable // 提取資料中的取樣
.Range(0, e.BytesRecorded / 4) // 除以四是因為, 緩衝區內每 4 個位元組構成一個浮點數, 一個浮點數是一個取樣
.Select(i => BitConverter.ToSingle(e.Buffer, i * 4)) // 轉換為 float
.ToArray(); // 轉換為陣列
// 獲取取樣後, 在這裡進行詳細處理
}
cap.StartRecording(); // 開始錄製
分離左右通道:
獲取完取樣後, 我們還需要對取樣進行一點小處理, 因為捕獲的資料是分通道的, 一般是左右聲道:
// 設定我們已經將剛剛的取樣儲存到了變數 AllSamples 中, 型別為 float[]
int channelCount = cap.WaveFormat.Channels; // WasapiLoopbackCapture 的 WaveFormat 指定了當前聲音的波形格式, 其中包含就通道數
float[][] channelSamples = Enumerable
.Range(0, channelCount)
.Select(channel => Enumerable
.Range(0, AllSamples.Length / channelCount)
.Select(i => AllSamples[channel + i * channelCount])
.ToArray())
.ToArray();
取通道平均值
將取樣分為一個個通道的取樣後, 我們可以將其合併, 取平均值, 以便於繪製:
// 設定我們已經將分開了的取樣儲存到了變數 ChannelSamples 中, 型別為 float[][]
// 例如通道數為2, 那麼左聲道的取樣為 ChannelSamples[0], 右聲道為 ChannelSamples[1]
float[] averageSamples = Enumerable
.Range(0, AllSamples.Length / channelCount)
.Select(index => Enumerable
.Range(0, channelCount)
.Select(channel => ChannelSamples[channel][index])
.Average())
.ToArray();
繪製時域圖象:
處理剛剛的取樣後, 你可以直接將其作為資料繪製到視窗中, 這即是時域圖象, 這裡使用最簡單的折線繪製.
// 設定 g 為視窗的 Graphics 物件, windowHeight 為視窗的顯示區域高度
// 設定通道取樣平均值為 AverageSamples, 型別為 float[]
Point[] points = AverageSamples
.Select((v, i) => new Point(i, windowHeight - v))
.ToArray(); // 將資料轉換為一個個的座標點
g.DrawLines(Pens.Black, points); // 連線這些點, 畫線
傅立葉變換:
NAudio 中還提供了快速傅立葉變換的方法, 通過傅立葉變換, 可以將時域資料轉換為頻域資料, 也就是我們所說的頻譜
// 我們將對 AverageSamples 進行傅立葉變換, 得到一個複數陣列
// 因為對於快速傅立葉變換演算法, 需要資料長度為 2 的 n 次方, 這裡進行
float log = Math.Ceiling(Math.Log(AverageSamples.Length, 2)); // 取對數並向上取整
int newLen = (int)Math.Pow(2, log); // 計算新長度
float[] filledSamples = new float[newLen];
Array.Copy(AverageSamples, filledSamples, AverageSamples.Length); // 拷貝到新陣列
Complex[] complexSrc = filledSamples
.Select(v => new Complex(){ X = v }) // 將取樣轉換為複數
.ToArray();
FastFourierTransform(false, log, complexSrc); // 進行傅立葉變換
// 變換之後, complexSrc 則已經被處理過, 其中儲存了頻域資訊
分析頻域資訊:
對於傅立葉變換的頻域資訊, 需要稍加處理才可以方便的使用, 首先是提取有用的資訊:
// NAudio 的傅立葉變換結果中, 似乎不存在直流分量(這使我們的處理更加方便了), 但它也是有共軛什麼的(也就是資料左右對稱, 只有一半是有用的)
// 仍然使用剛剛的 complexSrc 作為變換結果, 它的型別是 Complex[]
Complex[] halfData = complexSrc
.Take(complexSrc.Length / 2)
.ToArray(); // 一半的資料
float[] dftData = halfData
.Select(v => Math.Sqrt(v.X * v.X + v.Y * v.Y)) // 取複數的模
.ToArray(); // 將複數結果轉換為我們所需要的頻率幅度
// 其實, 到這裡你完全可以把這些資料繪製到視窗上, 這已經算是頻域圖象了, 但是對於音樂視覺化來講, 某些頻率的資料我們完全不需要
// 例如 10000Hz 的頻率, 我們完全沒必要去繪製它, 取 最小頻率 ~ 2500Hz 足矣
// 對於變換結果, 每兩個資料之間所差的頻率計算公式為 取樣率/取樣數, 那麼我們要取的個數也可以由 2500 / (取樣率 / 取樣數) 來得出
int count = 2500 / (cap.WaveFormat.SampleRate / filledSamples.Length);
float[] finalData = dftData.Take(count).ToArray();
繪製頻域圖象:
得到上面分析後的 finalData 後, 我們就可以直接繪製出來了, 這次使用柔和的曲線繪製
// 設定 g 為視窗的 Graphics 物件, height 為視窗高度
PointF[] points = finalData
.Select((v, i) => new PointF(i, height - v))
.ToArray();
g.DrawCurve(Pens.Purple, points); // Graphics 可以直接繪製曲線
更優的繪製:
上面的時域和頻域圖象, 我們都是一股腦的將資料的索引作為 X 座標, 視窗高度減去資料值作為 Y 座標, 有兩個突出的問題:
- 資料可能無法填滿視窗的寬度或者超出視窗的寬度範圍
- 資料太大時, 也會導致繪製的線條超出視窗高度
第一個問題好解決, 直接使索引所佔資料長度的百分比恰好等於 X 座標相對於視窗寬度的百分比即可:
對於第二個問題, 有兩個解決方案, 一是直接為資料加權重, 例如統一乘 0.5, 使資料減小一節, 二就是套一個函式, 例如 log 函式, 畢竟 log 函式在較高自變數的情況下, 因變數的變化趨勢越來越小, 我們只需要對這個 log 函式進行稍加處理, 就可以直接應用到資料變換資料上, 使其不超出視窗繪圖區域
另外, 我們也可以平滑頻譜顯示(指動畫變換), 它的原理大概是這樣:
例如這次進行傅立葉變換的結果是:
{0, 100, 50}
,下一次傅立葉變換的結果是:
{100, 0, 0}
,可以得出, 增量為:
{100, -100, -50}
,在更新變換結果時, 我們不再直接將新的結果替換舊的結果, 而是在舊的結果的基礎上, 加上增量×權重
例如權重是
0.5
時, 那麼實際增量是:{50, -50, -25}
,那麼實際新的值是:
{50, 50, 25}
,如果下一次變換的結果還是
{100, 0, 0}
, 那我們再次從{50, 50, 25}
向新值逼近, 權重仍然是0.5
, 那麼實際增量是:{25, -25, -12.5}
,注意到了嗎? 這次的增量是上次增量的一半, 這正好是一個減速運動, 而且新值與舊值的差越大, 變化的就越快, 而它們會不斷重合, 因而速度不斷變慢, 形成減速運動的頻譜圖.
更多內容:
更多關於 NAudio 的使用, 可以看這篇文章: [C#] NAudio 的各種常見使用方式 播放 錄製 轉碼 音訊視覺化
專案已開源:
關於本文章涉及的大部分內容, 均在 github.com/SlimeNull/AudioTest 倉庫中的 Null.AudioVisualizer 專案中有寫. (註釋妥當了)
其實音訊視覺化我老早就想做了, 但是本人數學不是非常的好, 不過最後總算是堅持下來了, 弄出來了啊, 心情老激動了