示例程式碼下載
基於篇幅考慮,本次教程分為兩篇文章,本篇文章主要講述音訊播放和頻譜資料的獲取,下篇將講述資料處理和動畫繪製。
前言
很久以前在電腦上聽音樂的時候,經常會調出播放器的一個小工具,裡面的柱狀圖會隨著音樂節奏而跳動,就感覺自己好專業,儘管後來才知道這個是音訊訊號在頻域下的表現。
熱身知識
動手寫程式碼之前,讓我們先了解幾個基礎概念吧
音訊數字化
-
取樣: 眾所周知,聲音是一種壓力波,是連續的,然而在計算機中無法表示連續的資料,所以只能通過間隔取樣的方式進行離散化,其中採集的頻率稱為取樣率。根據奈奎斯特取樣定理 ,當取樣率大於訊號最高頻率的2倍時訊號頻率不會失真。人類能聽到的聲音訊率範圍是20hz到20khz,所以CD等採用了44.1khz取樣率能滿足大部分需要。
-
量化: 每次取樣的訊號強度也會有精度的損失,如果用16位的Int(位深度)來表示,它的範圍是[-32768,32767],因此位深度越高可表示的範圍就越大,音訊質量越好。
-
聲道數: 為了更好的效果,聲音一般採集左右雙聲道的訊號,如何編排呢?一種是採用交錯排列(Interleaved):
LRLRLRLR
,另一種採用各自排列(non-Interleaved):LLL RRR
。
以上將模擬訊號數字化的方法稱為脈衝編碼調製(PCM),而本文中我們就需要對這類資料進行加工處理。
傅立葉變換
現在我們的音訊資料是時域的,也就是說橫軸是時間,縱軸是訊號的強度,而動畫展現要求的橫軸是頻率。將訊號從時域轉換成頻域可以使用傅立葉變換實現,訊號經過傅立葉變換分解成了不同頻率的正弦波,這些訊號的頻率和振幅就是我們需要實現動畫的資料。
圖1 (from nti-audio) 傅立葉變換將訊號從時域轉換成頻域實際上計算機中處理的都是離散傅立葉變換(DFT),而快速傅立葉變換(FFT)是快速計算離散傅立葉變換(DFT)或其逆變換的方法,它將DFT的複雜度從O(n²)降低到O(nlogn)。 如果你剛才點開前面連結看過其中介紹的FFT演算法,那麼可能會覺得這FFT程式碼該怎麼寫?不用擔心,蘋果為此提供了Accelerate框架,其中vDSP部分提供了數字訊號處理的函式實現,包含FFT。有了vDSP,我們只需幾個步驟即可實現FFT,簡單便捷,而且效能高效。
iOS中的音訊框架
現在開始讓我們看一下iOS系統中的音訊框架, AudioToolbox
功能強大,不過提供的API都是基於C語言的,其大多數功能已經可以通過AVFoundation
實現,它利用Objective-C
/Swift
對於底層介面進行了封裝。我們本次需求比較簡單,只需要播放音訊檔案並進行實時處理,所以AVFoundation
中的AVAudioEngine
就能滿足本次音訊播放和處理的需要。
AVAudioEngine
AVAudioEngine
從iOS8加入到AVFoundation
框架,它提供了以前需要深入到底層AudioToolbox
才實現的功能,比如實時音訊處理。它把音訊處理的各環節抽象成AVAudioNode
並通過AVAudioEngine
進行管理,最後將它們連線構成完整的節點圖。以下就是本次教程的AVAudioEngine
與其節點的連線方式。
圖3 AVAudioEngine和AVAudioNode連線圖
mainMixerNode
和outputNode
都是在被訪問的時候預設由AVAudioEngine
物件建立並連線的單例物件,也就是說我們只需要手動建立engine
和player
節點並將他們連線就可以了!最後在mainMixerNode
的輸出匯流排上安裝分接頭將定量輸出的AVAudioPCMBuffer
資料進行轉換和FFT。
程式碼實現
瞭解了以上相關知識後,我們就可以開始編寫程式碼了。開啟專案AudioSpectrum01-starter
,首先要實現的是音訊播放功能。
如果你只是想瀏覽實現程式碼,開啟專案
AudioSpectrum01-final
即可,已經完成本篇文章的所有程式碼
音訊播放
在AudioSpectrumPlayer
類中建立AVAudioEngine
和AVAudioPlayerNode
兩個例項變數:
private let engine = AVAudioEngine()
private let player = AVAudioPlayerNode()
複製程式碼
接下來在init()
方法中新增如下程式碼:
//1
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: nil)
//2
engine.prepare()
try! engine.start()
複製程式碼
//1
:這裡將player
掛載到engine
上,再把player
與engine
的mainMixerNode
連線起來就完成了AVAudioEngine
的整個節點圖建立(詳見圖3)。
//2
:在呼叫engine
的strat()
方法開始啟動engine
之前,需要通過prepare()
方法提前分配相關資源
繼續完善play(withFileName fileName: String)
和stop()
方法:
//1
func play(withFileName fileName: String) {
guard let audioFileURL = Bundle.main.url(forResource: fileName, withExtension: nil),
let audioFile = try? AVAudioFile(forReading: audioFileURL) else { return }
player.stop()
player.scheduleFile(audioFile, at: nil, completionHandler: nil)
player.play()
}
//2
func stop() {
player.stop()
}
複製程式碼
//1
:首先需要確保檔名為fileName
的音訊檔案能正常載入,然後通過stop()
方法停止之前的播放,再呼叫scheduleFile(_:at:completionHandler:)
方法編排新的檔案,最後通過play()
方法開始播放。
//2
:停止播放呼叫player
的stop()
方法即可。
音訊播放功能已經完成,執行專案,試試點選音樂右側的Play
按鈕進行音訊播放吧。
音訊資料獲取
前面提到我們可以在mainMixerNode
上安裝分接頭定量獲取AVAudioPCMBuffer
資料,現在開啟AudioSpectrumPlayer
檔案,先定義一個屬性: fftSize
,它是每次獲取到的buffer
的frame數量。
private var fftSize: Int = 2048
複製程式碼
將游標定位至init()
方法中的engine.connect()
語句下方,呼叫mainMixerNode
的installTap
方法開始安裝分接頭:
engine.mainMixerNode.installTap(onBus: 0, bufferSize: AVAudioFrameCount(fftSize), format: nil, block: { [weak self](buffer, when) in
guard let strongSelf = self else { return }
if !strongSelf.player.isPlaying { return }
buffer.frameLength = AVAudioFrameCount(strongSelf.fftSize) //這句的作用是確保每次回撥中buffer的frameLength是fftSize大小,詳見:https://stackoverflow.com/a/27343266/6192288
let amplitudes = strongSelf.fft(buffer)
if strongSelf.delegate != nil {
strongSelf.delegate?.player(strongSelf, didGenerateSpectrum: amplitudes)
}
})
複製程式碼
在分接頭的回撥 block 中將拿到的 2048 個 frame 的 buffer 交由fft
函式進行計算,最後將計算的結果通過delegate
進行傳遞。
按照 44100hz 取樣率和 1 Frame = 1 Packet 來計算(可以參考這裡關於channel、sample、frame、packet的概念與關係),那麼block將會在一秒中呼叫44100/2048≈21.5次左右,另外需要注意的是block有可能不在主執行緒呼叫。
FFT實現
終於到實現FFT
的時候了,根據vDSP
文件,首先需要定義一個FFT
的權重陣列(fftSetup)
,它可以在多次FFT
中重複使用和提升FFT
效能:
private lazy var fftSetup = vDSP_create_fftsetup(vDSP_Length(Int(round(log2(Double(fftSize))))), FFTRadix(kFFTRadix2))
複製程式碼
不需要時在解構函式(反初始化函式)中銷燬:
deinit {
vDSP_destroy_fftsetup(fftSetup)
}
複製程式碼
最後新建fft
函式,實現程式碼如下:
private func fft(_ buffer: AVAudioPCMBuffer) -> [[Float]] {
var amplitudes = [[Float]]()
guard let floatChannelData = buffer.floatChannelData else { return amplitudes }
//1:抽取buffer中的樣本資料
var channels: UnsafePointer<UnsafeMutablePointer<Float>> = floatChannelData
let channelCount = Int(buffer.format.channelCount)
let isInterleaved = buffer.format.isInterleaved
if isInterleaved {
// deinterleave
let interleavedData = UnsafeBufferPointer(start: floatChannelData[0], count: self.fftSize * channelCount)
var channelsTemp : [UnsafeMutablePointer<Float>] = []
for i in 0..<channelCount {
var channelData = stride(from: i, to: interleavedData.count, by: channelCount).map{ interleavedData[$0]}
channelsTemp.append(UnsafeMutablePointer(&channelData))
}
channels = UnsafePointer(channelsTemp)
}
for i in 0..<channelCount {
let channel = channels[i]
//2: 加漢寧窗
var window = [Float](repeating: 0, count: Int(fftSize))
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
vDSP_vmul(channel, 1, window, 1, channel, 1, vDSP_Length(fftSize))
//3: 將實數包裝成FFT要求的複數fftInOut,既是輸入也是輸出
var realp = [Float](repeating: 0.0, count: Int(fftSize / 2))
var imagp = [Float](repeating: 0.0, count: Int(fftSize / 2))
var fftInOut = DSPSplitComplex(realp: &realp, imagp: &imagp)
channel.withMemoryRebound(to: DSPComplex.self, capacity: fftSize) { (typeConvertedTransferBuffer) -> Void in
vDSP_ctoz(typeConvertedTransferBuffer, 2, &fftInOut, 1, vDSP_Length(fftSize / 2))
}
//4:執行FFT
vDSP_fft_zrip(fftSetup!, &fftInOut, 1, vDSP_Length(round(log2(Double(fftSize)))), FFTDirection(FFT_FORWARD));
//5:調整FFT結果,計算振幅
fftInOut.imagp[0] = 0
let fftNormFactor = Float(1.0 / (Float(fftSize)))
vDSP_vsmul(fftInOut.realp, 1, [fftNormFactor], fftInOut.realp, 1, vDSP_Length(fftSize / 2));
vDSP_vsmul(fftInOut.imagp, 1, [fftNormFactor], fftInOut.imagp, 1, vDSP_Length(fftSize / 2));
var channelAmplitudes = [Float](repeating: 0.0, count: Int(fftSize / 2))
vDSP_zvabs(&fftInOut, 1, &channelAmplitudes, 1, vDSP_Length(fftSize / 2));
channelAmplitudes[0] = channelAmplitudes[0] / 2 //直流分量的振幅需要再除以2
amplitudes.append(channelAmplitudes)
}
return amplitudes
}
複製程式碼
通過程式碼中的註釋,應該能瞭解如何從buffer
獲取音訊樣本資料並進行FFT
計算了,不過以下兩點是我在完成這一部分內容過程中比較難理解的部分:
- 通過
buffer
物件的方法floatChannelData
獲取樣本資料,如果是多聲道並且是interleaved
,我們就需要對它進行deinterleave
, 通過下圖就能比較清楚的知道deinterleave
前後的結構,不過在我試驗了許多音訊檔案之後,發現都是non-interleaved
的,也就是無需進行轉換。┑( ̄Д  ̄)┍
vDSP
在進行實數FFT
計算時利用一種獨特的資料格式化方式以達到節省記憶體的目的,它在FFT
計算的前後通過兩次轉換將FFT
的輸入和輸出的資料結構進行統一成DSPSplitComplex
。第一次轉換是通過vDSP_ctoz
函式將樣本資料的實數陣列轉換成DSPSplitComplex
。第二次則是將FFT
結果轉換成DSPSplitComplex
,這個轉換的過程是在FFT
計算函式vDSP_fft_zrip
中自動完成的。第二次轉換過程如下:n位樣本資料(n/2位複數)進行fft計算會得到n/2+1位複數結果:{[DC,0],C[2],...,C[n/2],[NY,0]} (其中DC是直流分量,NY是奈奎斯特頻率的值,C是複數陣列),其中[DC,0]和[NY,0]的虛部都是0,所以可以將NY放到DC中的虛部中,其結果變成{[DC,NY],C[2],C[3],...,C[n/2]},與輸入位數一致。
再次執行專案,現在除了聽到音樂之外還可以在控制檯中看到列印輸出的頻譜資料。
圖6 將結果通過Google Sheets繪製出來的頻譜圖好了,本篇文章內容到這裡就結束了,下一篇文章將對計算好的頻譜資料進行處理和動畫繪製。
資料參考
[1] wikipedia,脈衝編碼調製, zh.wikipedia.org/wiki/%E8%84…
[2] Mike Ash,音訊資料獲取與解析, www.mikeash.com/pyblog/frid…
[3] 韓 昊, 傅立葉分析之掐死教程, blog.jobbole.com/70549/
[4] raywenderlich, AVAudioEngine程式設計入門,www.raywenderlich.com/5154-avaudi…
[5] Apple, vDSP程式設計指南, developer.apple.com/library/arc…
[6] Apple, aurioTouch案例程式碼,developer.apple.com/library/arc…