一步一步教你實現iOS音頻頻譜動畫(一)

若是你想先看看最終效果再決定看不看文章 -> bilibili
示例代碼下載

第二篇:一步一步教你實現iOS音頻頻譜動畫(二)html

基於篇幅考慮,本次教程分爲兩篇文章,本篇文章主要講述音頻播放和頻譜數據的獲取,下篇將講述數據處理和動畫繪製。ios

前言

好久之前在電腦上聽音樂的時候,常常會調出播放器的一個小工具,裏面的柱狀圖會隨着音樂節奏而跳動,就感受本身好專業,儘管後來才知道這個是音頻信號在頻域下的表現。git

熱身知識

動手寫代碼以前,讓咱們先了解幾個基礎概念吧github

音頻數字化

  • 採樣: 總所周知,聲音是一種壓力波,是連續的,然而在計算機中沒法表示連續的數據,因此只能經過間隔採樣的方式進行離散化,其中採集的頻率稱爲採樣率。根據奈奎斯特採樣定理 ,當採樣率大於信號最高頻率的2倍時信號頻率不會失真。人類能聽到的聲音頻率範圍是20hz到20khz,因此CD等採用了44.1khz採樣率能知足大部分須要。算法

  • 量化: 每次採樣的信號強度也會有精度的損失,若是用16位的Int(位深度)來表示,它的範圍是[-32768,32767],所以位深度越高可表示的範圍就越大,音頻質量越好。編程

  • 聲道數: 爲了更好的效果,聲音通常採集左右雙聲道的信號,如何編排呢?一種是採用交錯排列(Interleaved): LRLRLRLR ,另外一種採用各自排列(non-Interleaved): LLL RRRswift

以上將模擬信號數字化的方法稱爲脈衝編碼調製(PCM),而本文中咱們就須要對這類數據進行加工處理。數組

傅里葉變換

如今咱們的音頻數據是時域的,也就是說橫軸是時間,縱軸是信號的強度,而動畫展示要求的橫軸是頻率。將信號從時域轉換成頻域可使用傅里葉變換實現,信號通過傅里葉變換分解成了不一樣頻率的正弦波,這些信號的頻率和振幅就是咱們須要實現動畫的數據。數據結構

圖1 (from nti-audio) 傅里葉變換將信號從時域轉換成頻域

實際上計算機中處理的都是離散傅里葉變換(DFT),而快速傅里葉變換(FFT)是快速計算離散傅里葉變換(DFT)或其逆變換的方法,它將DFT的複雜度從O(n²)下降到O(nlogn)。 若是你剛纔點開前面連接看過其中介紹的FFT算法,那麼可能會以爲這FFT代碼該怎麼寫?不用擔憂,蘋果爲此提供了Accelerate框架,其中vDSP部分提供了數字信號處理的函數實現,包含FFT。有了vDSP,咱們只需幾個步驟便可實現FFT,簡單便捷,並且性能高效。app

iOS中的音頻框架

如今開始讓咱們看一下iOS系統中的音頻框架, AudioToolbox功能強大,不過提供的API都是基於C語言的,其大多數功能已經能夠經過AVFoundation實現,它利用Objective-C/Swift對於底層接口進行了封裝。咱們本次需求比較簡單,只須要播放音頻文件並進行實時處理,因此AVFoundation中的AVAudioEngine就能知足本次音頻播放和處理的須要。

圖2 (from WWDC16) iOS/MAC OS X 音頻技術棧

AVAudioEngine

AVAudioEngine 從iOS8加入到AVFoundation框架,它提供了之前須要深刻到底層AudioToolbox才實現的功能,好比實時音頻處理。它把音頻處理的各環節抽象成AVAudioNode並經過AVAudioEngine進行管理,最後將它們鏈接構成完整的節點圖。如下就是本次教程的AVAudioEngine與其節點的鏈接方式。

圖3 AVAudioEngine和AVAudioNode鏈接圖

mainMixerNodeoutputNode都是在被訪問的時候默認由AVAudioEngine對象建立並鏈接的單例對象,也就是說咱們只須要手動建立engineplayer節點並將他們鏈接就能夠了!最後在mainMixerNode的輸出總線上安裝分接頭將定量輸出的AVAudioPCMBuffer數據進行轉換和FFT。

代碼實現

瞭解了以上相關知識後,咱們就能夠開始編寫代碼了。打開項目AudioSpectrum01-starter,首先要實現的是音頻播放功能。

若是你只是想瀏覽實現代碼,打開項目AudioSpectrum01-final便可,已經完成本篇文章的全部代碼

音頻播放

AudioSpectrumPlayer類中建立AVAudioEngineAVAudioPlayerNode兩個實例變量:

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上,再把playerenginemainMixerNode鏈接起來就完成了AVAudioEngine的整個節點圖建立(詳見圖3)。
//2:在調用enginestrat()方法開始啓動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:中止播放調用playerstop()方法便可。

音頻播放功能已經完成,運行項目,試試點擊音樂右側的Play按鈕進行音頻播放吧。

音頻數據獲取

前面提到咱們能夠在mainMixerNode上安裝分接頭定量獲取AVAudioPCMBuffer數據,如今打開AudioSpectrumPlayer文件,先定義一個屬性: fftSize,它是每次獲取到的buffer的frame數量。

private var fftSize: Int = 2048 複製代碼

將光標定位至init()方法中的engine.connect()語句下方,調用mainMixerNodeinstallTap方法開始安裝分接頭:

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計算了,不過如下兩點是我在完成這一部份內容過程當中比較難理解的部分:

  1. 經過buffer對象的方法floatChannelData獲取樣本數據,若是是多聲道而且是interleaved,咱們就須要對它進行deinterleave, 經過下圖就能比較清楚的知道deinterleave先後的結構,不過在我試驗了許多音頻文件以後,發現都是non-interleaved的,也就是無需進行轉換。┑( ̄Д  ̄)┍
圖4 interleaved的樣本數據須要進行deinterleave
  1. 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]},與輸入位數一致。

圖5 第一次轉換時,vDSP_ctoz函數將實數數組轉換成DSPSplitComplex結構

再次運行項目,如今除了聽到音樂以外還能夠在控制檯中看到打印輸出的頻譜數據。

圖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…

做者:potato04 連接:https://juejin.im/post/5c1bbec66fb9a049cb18b64c 來源:掘金 著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。
相關文章
相關標籤/搜索