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

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

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

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

前言

很久以前在電腦上聽音樂的時候,經常會調出播放器的一個小工具,里面的柱狀圖會隨著音樂節奏而跳動,就感覺自己好專業,盡管后來才知道這個是音頻信號在頻域下的表現。

熱身知識

動手寫代碼之前,讓我們先了解幾個基礎概念吧

音頻數字化

  • 采樣: 總所周知,聲音是一種壓力波,是連續的,然而在計算機中無法表示連續的數據,所以只能通過間隔采樣的方式進行離散化,其中采集的頻率稱為采樣率。根據奈奎斯特采樣定理 ,當采樣率大于信號最高頻率的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(n2)降低到O(nlogn)。 如果你剛才點開前面鏈接看過其中介紹的FFT算法,那么可能會覺得這FFT代碼該怎么寫?不用擔心,蘋果為此提供了Accelerate框架,其中vDSP部分提供了數字信號處理的函數實現,包含FFT。有了vDSP,我們只需幾個步驟即可實現FFT,簡單便捷,而且性能高效。

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
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

轉載于:https://www.cnblogs.com/Free-Thinker/p/10880118.html

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/247935.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/247935.shtml
英文地址,請注明出處:http://en.pswp.cn/news/247935.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

微信小程序的基礎 (一)

微信小程序介紹- 鏈接 微信小程序&#xff0c;簡稱小程序&#xff0c;是一種不需要下載安裝即可使用的應用&#xff0c;它實現了應用“觸手可及”的夢想&#xff0c;用戶掃一掃或搜一下即可打開應用 1. 為什么是微信小程序? 微信有海量用戶&#xff0c;而且粘性很高&#x…

看YYModel源碼的一些收獲

關于源碼學習自己的一些感悟第一層&#xff1a;熟練使用&#xff1b;第二層&#xff1a;讀懂代碼&#xff1b;第三層&#xff1a;通曉原理&#xff1b;第四層&#xff1a;如何設計&#xff1b;自己學到了什么&#xff0c;還留有什么問題&#xff1b;關于分享關于線下演講分享和…

IDEA提交項目到SVN

一.提交步驟 VCS--Enable...-->點擊項目右鍵-->subversion-->share directory-->commit 二.IDEA SVN 忽略文件的設置 1》share .使用idea在將項目提交到svn的過程中遇到這樣的問題 將項目share之后再設置ignore files &#xff0c;在commit的時候&#xff0c;不會將…

小程序基礎 (二)

小程序開發框架 小程序開發框架的目標是通過盡可能簡單、高效的方式讓開發者可以在微信中開發具有原生 APP 體驗的服務。 整個小程序框架系統分為兩部分&#xff1a;邏輯層&#xff08;App Service&#xff09;和 視圖層&#xff08;View&#xff09;。 小程序提供了自己的視…

項目ITP(五) spring4.0 整合 Quartz 實現任務調度

版權聲明&#xff1a;本文為博主原創文章&#xff0c;未經博主同意不得轉載。https://blog.csdn.net/u010378410/article/details/26016025 2014-05-16 22:51 by Jeff Li 前言 系列文章&#xff1a;[傳送門] 項目需求&#xff1a; 二維碼推送到一體機上&#xff0c;給學生簽到掃…

喜歡用Block的值得注意-Block的Retain Cycle的解決方法

本文不講block如何聲明及使用&#xff0c;只講block在使用過程中暫時遇到及帶來的隱性危險。 主要基于兩點進行演示&#xff1a; 1.block 的循環引用(retain cycle) 2.去除block產生的告警時&#xff0c;需注意問題。 有一次&#xff0c;朋友問我當一個對象中的block塊中的訪問…

小程序基礎 (三)

5. 使用 slot 使用單個slot // 頁面 <Test><view>自定義內容</view> </Test>// 組件 <view><view>前面的內容</view><slot></slot><view>后面的內容</view> </view>使用多個slot - 具名 // 頁面 &…

【PyQt5】QT designer + eclipse 集成開發

【寫在前面的話】 考慮將pyqt5的界面開發qt designer 集成在eclipse中&#xff0c;并且&#xff0c;不利用cmd命令行進行轉換。 【工具】 1、pyqt5 2、qt designer 3、eclipse pydy 【步驟】 1、首先配置Qt designer。 菜單 run-->external Tools-->External tools confi…

iOS UIlabel文字排版(改變字間距行間距)分類

在iOS開發中經常會用到UIlabel來展示一些文字性的內容&#xff0c;但是默認的文字排版會覺得有些擠&#xff0c;為了更美觀也更易于閱讀我們可以通過某些方法將UIlabel的行間距和字間距按照需要調節。 比如一個Label的默認間距效果是這樣&#xff1a; 然后用一個封裝起來的Cat…

MySQL查詢之聚合查詢

為了快速得到統計數據&#xff0c;提供了5個聚合函數&#xff1a; count(*)表示計算總行數&#xff0c;括號中寫星與列名&#xff0c;結果是相同的 查詢學生總數 select count(*) from students; max(列)表示求此列的最大值 查詢女生的編號最大值 select max(id) from students…

React基礎學習(第一天)

React 概述 : React 是一個用于 構建用戶界面 的 JavaScript 庫因為框架是有一整套解決方案的&#xff0c;React就是純粹寫UI組件的 沒有什么異步處理機制、模塊化、表單驗證這些。React和react-router, react-redux結合起來才叫框架&#xff0c;而React本身只是充當一個前端…

iOS 富文本風格NSMutableParagraphStyle、定制UITextView插入圖片和定制復制

問題一 開發過程中&#xff0c;經常會遇到動態計算行高的問題&#xff0c; - (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(nullableNSDictionary<NSString *, id> *)attributes context:(nullable NSStringDrawingC…

day24 01 初識繼承

day24 01 初識繼承 面向對象的三大特性&#xff1a;繼承&#xff0c;多態&#xff0c;封裝 一、繼承的概念 繼承&#xff1a;是一種創建新類的方式&#xff0c;新建的類可以繼承一個或者多個父類&#xff0c;父類又可稱基類或超類&#xff0c;新建的類稱為派生類或者子類 class…

React基礎學習(第二天)

虛擬DOM JSX 涉及到 虛擬DOM ,簡單聊一下 定時器渲染問題 // 方法 function render() {//2. 創建react對象let el (<div><h3>時間更新</h3><p>{ new Date().toLocaleTimeString()}</p></div>)//3. 渲染ReactDOM.render(el, document.g…

iOS 去除字符串中的空格或多余空格(適合英文單詞)

NSString -stringByTrimmingCharactersInSet: 是個你需要牢牢記住的方法。它經常會傳入 NSCharacterSet whitespaceCharacterSet 或 whitespaceAndNewlineCharacterSet 來刪除輸入字符串的頭尾的空白符號。 需要重點注意的是&#xff0c;這個方法 僅僅 去除了 開頭 和 結尾 的…

華為交換機在Telnet登錄下自動顯示接口信息

因為用console連接交換機&#xff0c;默認是自動顯示接口信息的&#xff0c;比如down掉一個接口后&#xff0c;會自動彈出接口被down掉的信息&#xff0c;但是在telnet連接下&#xff0c;默認是不顯示這些信息的&#xff0c;需要開啟后才可顯示。 1、首先開啟info-center(默認是…

React基礎學習(第三天)

條件渲染 1. if / else render () {if (this.state.isLoading) { // 正在加載中return <h1>Loading...</h1>}return <div>這就是我們想要的內容</div>} // 鉤子函數 五秒鐘之后 修改狀態值componentDidMount () { setTimeout(() > {this.setState(…

componentsJoinedByString 和 componentsSeparatedByString 的方法的區別

將string字符串轉換為array數組 NSArray *array [Str componentsSeparatedByString:","]; &#xff1d;&#xff1d;反向方法 將array數組轉換為string字符串 NSString *tempString [mutableArray componentsJoinedByString:","];--,是分隔符 可不加分隔…

java中的各種數據類型在內存中存儲的方式

轉載別人的附上鏈接&#xff1a;https://blog.csdn.net/zj15527620802/article/details/80622314 1.java是如何管理內存的 java的內存管理就是對象的分配和釋放問題。&#xff08;其中包括兩部分&#xff09; 分配&#xff1a;內存的分配是由程序完成的&#xff0c;程序員需要通…

vscode的 jsonp 配置文件

{ // 工具-字體大小 “editor.fontSize”: 15, // 工具-tab縮進 “editor.tabSize”: 2, // 工具-在視區寬度換行 “editor.wordWrap”: “on”, // 工具-縮放 “window.zoomLevel”: 1, // 工具-編寫tab識別語言格式 “emmet.includeLanguages”: { “vue-html”: “html”, “…