AVFoundation框架解析(十九)—— AVAudioEngine之詳細說明和一個簡單示例(二)

版本記錄

版本號 時間
V1.0 2018.08.19

前言

AVFoundation框架是ios中很重要的框架,所有與視頻音頻相關的軟硬件控制都在這個框架里面,接下來這幾篇就主要對這個框架進行介紹和講解。感興趣的可以看我上幾篇。
1. AVFoundation框架解析(一)—— 基本概覽
2. AVFoundation框架解析(二)—— 實現(xiàn)視頻預覽錄制保存到相冊
3. AVFoundation框架解析(三)—— 幾個關鍵問題之關于框架的深度概括
4. AVFoundation框架解析(四)—— 幾個關鍵問題之AVFoundation探索(一)
5. AVFoundation框架解析(五)—— 幾個關鍵問題之AVFoundation探索(二)
6. AVFoundation框架解析(六)—— 視頻音頻的合成(一)
7. AVFoundation框架解析(七)—— 視頻組合和音頻混合調試
8. AVFoundation框架解析(八)—— 優(yōu)化用戶的播放體驗
9. AVFoundation框架解析(九)—— AVFoundation的變化(一)
10. AVFoundation框架解析(十)—— AVFoundation的變化(二)
11. AVFoundation框架解析(十一)—— AVFoundation的變化(三)
12. AVFoundation框架解析(十二)—— AVFoundation的變化(四)
13. AVFoundation框架解析(十三)—— 構建基本播放應用程序
14. AVFoundation框架解析(十四)—— VAssetWriter和AVAssetReader的Timecode支持(一)
15. AVFoundation框架解析(十五)—— VAssetWriter和AVAssetReader的Timecode支持(二)
16. AVFoundation框架解析(十六)—— 一個簡單示例之播放、錄制以及混合視頻(一)
17. AVFoundation框架解析(十七)—— 一個簡單示例之播放、錄制以及混合視頻之源碼及效果展示(二)
18. AVFoundation框架解析(十八)—— AVAudioEngine之基本概覽(一)

開始

向大多數(shù)iOS開發(fā)人員提及音頻處理,他們認為很困難甚至是恐懼。這是因為,在iOS 8之前,它意味著深入探討低級Core Audio框架的深度 - 只有少數(shù)勇敢的靈魂才能做到這一點。值得慶幸的是,隨著iOS 8和AVAudioEngine的發(fā)布,這一切都在2014年發(fā)生了變化。本文將向您展示如何使用Apple的新的更高級別的音頻工具audio toolkit包來制作音頻處理應用程序,而無需深入研究Core Audio

那就對了!您不再需要搜索模糊的基于指針的C / C ++結構和內存緩沖區(qū)來收集原始音頻數(shù)據(jù)。

在這個AVAudioEngine教程中,您將使用AVAudioEngine構建下一個優(yōu)秀的播客應用程序。更具體地說,您將添加由UI控制的音頻功能:播放/暫停按鈕,跳過前進/后退按鈕,進度條和播放速率選擇器。當你完成后,你會有一個很棒的應用程序。

注意:寫作本文的環(huán)境Swift 4, iOS 11, Xcode 9。


iOS Audio Framework Introduction - iOS音頻框架介紹

在進入項目之前,首先看一下iOS音頻框架的概述:

  • CoreAudioAudioToolbox是低級C框架。
  • AVFoundation是一個Objective-C / Swift框架。
  • AVAudioEngineAVFoundation的一部分。
  • AVAudioEngine是一個定義一組連接的音頻節(jié)點的類。 您將向項目添加兩個節(jié)點:AVAudioPlayerNodeAVAudioUnitTimePitch

Setup Audio - 設置Audio

打開ViewController.swift并查看內部。 在頂部,您將看到所有連接的outlets和類變量。 actions還連接到sb中的相應outlets

將以下代碼添加到setupAudio()

// 1
audioFileURL = Bundle.main.url(forResource: "Intro", withExtension: "mp4")

// 2
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: audioFormat)
engine.prepare()

do {
  // 3
  try engine.start()
} catch let error {
  print(error.localizedDescription)
}

仔細看看發(fā)生了什么:

  • 1)這將獲取bundle音頻文件的URL。 設置后,它將在上面變量聲明部分的audioFileURLdidSet塊中實例化audioFile
  • 2)將播放器節(jié)點附加到引擎,在連接其他節(jié)點之前必須執(zhí)行此操作。 這些節(jié)點將生成,處理或輸出音頻。 音頻引擎提供連接到播放器節(jié)點的主混音器節(jié)點。 默認情況下,主混音器連接到engine默認輸出節(jié)點(iOS設備揚聲器)。 prepare()預分配所需的資源。

接下來,將以下內容添加到scheduleAudioFile()

guard let audioFile = audioFile else { return }

skipFrame = 0
player.scheduleFile(audioFile, at: nil) { [weak self] in
  self?.needsFileScheduled = true
}

這會調度播放整個audioFileat:是您希望音頻播放的未來時間(AVAudioTime)。 設置為nil會立即開始播放。 該文件僅調度播放一次。 再次點擊Play按鈕不會從頭重新開始。 您需要重新調度再次播放。 播放完音頻文件后,在完成塊中設置標志needsFileScheduled

還有其他調度音頻用于播放:

  • scheduleBuffer(AVAudioPCMBuffer,completionHandler:AVAudioNodeCompletionHandler?= nil):這提供了預先加載音頻數(shù)據(jù)的緩沖區(qū)。
  • scheduleSegment(AVAudioFile,startingFrame:AVAudioFramePosition,frameCount:AVAudioFrameCount,at:AVAudioTime?,completionHandler:AVAudioNodeCompletionHandler?= nil):這就像scheduleFile,除了你指定開始播放的音頻幀和播放的幀數(shù)。

然后,將以下內容添加到playTapped(_ :)

// 1
sender.isSelected = !sender.isSelected

// 2
if player.isPlaying {
  player.pause()
} else {
  if needsFileScheduled {
    needsFileScheduled = false
    scheduleAudioFile()
  }
  player.play()
}

下面細分一下:

  • 1)切換按鈕的選擇狀態(tài),這會更改sb中設置的按鈕圖像。
  • 2)使用player.isPlaying確定當前播放器正在播放。 如果是這樣,暫停它,如果不是,請播放。 您還可以檢查needsFileScheduled并根據(jù)需要重新調度文件。

Build并運行,然后點擊playPauseButton。 你應該聽到聲音。 但是,沒有UI反饋,你不知道文件有多長或者你現(xiàn)在播放到哪里。


Add Progress Feedback - 增加進度反饋

viewDidLoad()中添加如下代碼:

updater = CADisplayLink(target: self, selector: #selector(updateUI))
updater?.add(to: .current, forMode: .defaultRunLoopMode)
updater?.isPaused = true

CADisplayLink是一個計時器對象,與顯示器的刷新率同步。 您使用方法updateUI實例化它。 然后,將其添加到運行循環(huán)中 - 在本例中為默認運行循環(huán)default run loop。 最后,它不需要開始運行,因此將isPaused設置為true

用以下內容替換playTapped(_ :)的實現(xiàn):

sender.isSelected = !sender.isSelected

if player.isPlaying {
  disconnectVolumeTap()
  updater?.isPaused = true
  player.pause()
} else {
  if needsFileScheduled {
    needsFileScheduled = false
    scheduleAudioFile()
  }
  connectVolumeTap()
  updater?.isPaused = false
  player.play()
}

這里的關鍵是當播放器暫停時使用updater.isPaused = true暫停UI。 您將在下面的VU Meter部分中了解connectVolumeTap()disconnectVolumeTap()

使用以下內容替換var currentFrame:AVAudioFramePosition = 0

var currentFrame: AVAudioFramePosition {
  // 1
  guard
    let lastRenderTime = player.lastRenderTime,
    // 2
    let playerTime = player.playerTime(forNodeTime: lastRenderTime)
    else {
      return 0
  }
  
  // 3
  return playerTime.sampleTime
}

currentFrame返回播放器呈現(xiàn)的最后一個音頻樣本。 下面一步步的看:

  • 1)player.lastRenderTime返回引擎啟動時間的時間。 如果引擎未運行,則lastRenderTime返回nil。
  • 2)player.playerTime(forNodeTime :)lastRenderTime轉換為相對于播放器開始時間的時間。 如果播放器沒有播放,那么playerTime將返回nil。
  • 3)sampleTime是音頻文件中的一些音頻采樣的時間。

現(xiàn)在進行UI更新。 將以下內容添加到updateUI()

// 1
currentPosition = currentFrame + skipFrame
currentPosition = max(currentPosition, 0)
currentPosition = min(currentPosition, audioLengthSamples)

// 2
progressBar.progress = Float(currentPosition) / Float(audioLengthSamples)
let time = Float(currentPosition) / audioSampleRate
countUpLabel.text = formatted(time: time)
countDownLabel.text = formatted(time: audioLengthSeconds - time)

// 3
if currentPosition >= audioLengthSamples {
  player.stop()
  updater?.isPaused = true
  playPauseButton.isSelected = false
  disconnectVolumeTap()
}

下面我們一步一步的看:

  • 1)屬性skipFrame是添加到currentFrame或從currentFrame中減去的偏移量,最初設置為零。 確保currentPosition不超出文件范圍。
  • 2)將progressBar.progress更新為audioFile中的currentPosition。 通過將currentPosition除以audioFilesampleRate來計算時間。 將countUpLabelcountDownLabel文本更新為audioFile中的當前時間。
  • 3)如果currentPosition位于文件末尾,則:
    • 停止播放器。
    • 暫停計時器。
    • 重置playPauseButton選擇狀態(tài)。
    • 斷開音量tap。

Build并運行,然后點擊playPauseButton。 再次,您將聽到聲音,但這次progressBar和計時器標簽提供以前缺少的狀態(tài)信息。


Implement the VU Meter - 實現(xiàn)VU Meter

現(xiàn)在是時候添加VU Meter功能了。 這是一個UIView定位在暫停圖標的欄之間。 視圖的高度由播放音頻的平均功率決定。 這是您進行某些音頻處理的第一次機會。

您將計算1k音頻樣本緩沖區(qū)的平均功率。 確定音頻樣本緩沖器的平均功率的常用方法是計算樣本的均方根(RMS)。

平均功率是以分貝表示的一系列音頻樣本數(shù)據(jù)的平均值。 還有峰值功率,這是一系列樣本數(shù)據(jù)中的最大值。

connectVolumeTap()下面添加以下helper方法:

func scaledPower(power: Float) -> Float {
  // 1
  guard power.isFinite else { return 0.0 }

  // 2
  if power < minDb {
    return 0.0
  } else if power >= 1.0 {
    return 1.0
  } else {
    // 3
    return (fabs(minDb) - fabs(power)) / fabs(minDb)
  }
}

scaledPower(power :)將負功率分貝值轉換為正值,以適應調整上面的volumeMeterHeight.constant值。 這是它的作用:

  • 1)power.isFinite檢查以確保功率是有效值 - 即,不是NaN - 如果不是則返回0.0。
  • 2)這將我們的vuMeterdynamic range設置為80db。 對于低于-80.0的任何值,返回0.0。 iOS上的分貝值范圍為-160db,接近靜音,為0db,最大功率。 minDb設置為-80.0,動態(tài)范圍為80db。 您可以更改此值以查看它如何影響vuMeter。
  • 3)計算0.0到1.0之間的縮放值。

現(xiàn)在,將以下內容添加到connectVolumeTap()

// 1
let format = engine.mainMixerNode.outputFormat(forBus: 0)
// 2
engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, when in
  // 3
  guard 
    let channelData = buffer.floatChannelData,
    let updater = self.updater 
    else {
      return
  }

  let channelDataValue = channelData.pointee
  // 4
  let channelDataValueArray = stride(from: 0, 
                                     to: Int(buffer.frameLength),
                                     by: buffer.stride).map{ channelDataValue[$0] }
  // 5
  let rms = sqrt(channelDataValueArray.map{ $0 * $0 }.reduce(0, +) / Float(buffer.frameLength))
  // 6
  let avgPower = 20 * log10(rms)
  // 7
  let meterLevel = self.scaledPower(power: avgPower)

  DispatchQueue.main.async {
    self.volumeMeterHeight.constant = !updater.isPaused ? 
           CGFloat(min((meterLevel * self.pauseImageHeight), self.pauseImageHeight)) : 0.0
  }
}

這里進行細分說明:

  • 1)獲取mainMixerNode輸出的數(shù)據(jù)格式。
  • 2)installTap(onBus:0,bufferSize:1024,format:format)使您可以訪問mainMixerNode輸出總線上的音頻數(shù)據(jù)。您請求1024字節(jié)的緩沖區(qū)大小,但不保證請求的大小,特別是如果您請求的緩沖區(qū)太小或太大。 Apple的文檔沒有說明這些限制是什么。完成block接收AVAudioPCMBufferAVAudioTime作為參數(shù)。您可以檢查buffer.frameLength以確定實際的緩沖區(qū)大小。 when提供緩沖區(qū)的捕獲時間。
  • 3)buffer.floatChannelData為您提供了指向每個樣本數(shù)據(jù)的指針數(shù)組。 channelDataValueUnsafeMutablePointer <Float>的數(shù)組
  • 4)從UnsafeMutablePointer <Float>數(shù)組轉換為Float數(shù)組會使以后的計算更容易。為此,請使用stride(from:to:by :)channelDataValue中創(chuàng)建索引數(shù)組。然后map{channelDataValue [$ 0]}以訪問和存儲channelDataValueArray中的數(shù)據(jù)值。
  • 5)計算RMS涉及映射/縮減/除法操作。首先,映射操作對數(shù)組中的所有值進行平方,reduce操作求和。將平方和除以緩沖區(qū)大小,然后取平方根,生成緩沖區(qū)中音頻樣本數(shù)據(jù)的RMS。這應該是介于0.0和1.0之間的值,但可能存在一些邊緣情況,它是負值。
  • 6)RMS轉換為分貝(Acoustic Decibel reference)。這應該是-160和0之間的值,但如果rms為負,則該值為NaN
  • 7)將分貝縮放為適合您的vuMeter的值。

最后,將以下內容添加到disconnectVolumeTap()

engine.mainMixerNode.removeTap(onBus: 0)
volumeMeterHeight.constant = 0

AVAudioEngine每個總線只允許一次點擊。 在不使用時將其刪除是一種很好的做法。

Build并運行,然后點擊playPauseButtonvuMeter現(xiàn)在處于活動狀態(tài),提供音頻數(shù)據(jù)的平均功率反饋。


Implementing Skip - 實現(xiàn)Skip

是時候實現(xiàn)跳過前進和后退按鈕了。skipForwardButton在音頻文件中向前跳10秒,skipBackwardButton跳回10秒。

添加以下內容到seek(to:)

guard 
  let audioFile = audioFile,
  let updater = updater 
  else {
    return
}

// 1
skipFrame = currentPosition + AVAudioFramePosition(time * audioSampleRate)
skipFrame = max(skipFrame, 0)
skipFrame = min(skipFrame, audioLengthSamples)
currentPosition = skipFrame

// 2
player.stop()

if currentPosition < audioLengthSamples {
  updateUI()
  needsFileScheduled = false

  // 3
  player.scheduleSegment(audioFile, 
                         startingFrame: skipFrame, 
                         frameCount: AVAudioFrameCount(audioLengthSamples - skipFrame), 
                         at: nil) { [weak self] in
    self?.needsFileScheduled = true
  }

  // 4
  if !updater.isPaused {
    player.play()
  }
}

這是進行詳細分解:

  • 1)通過乘以audioSampleRate將時間(以秒為單位)轉換為幀位置,并將其添加到currentPosition。然后,確保skipFrame不在文件開頭之前,也不超過文件末尾。
  • 2)player.stop()不僅停止播放,還清除所有先前調度的事件。調用updateUI()將UI設置為新的currentPosition值。
  • 3)player.scheduleSegment(_:startingFrame:frameCount:at :)調度從audioFileskipFrame位置開始播放。 frameCount是要播放的幀數(shù)。您想要播放到文件末尾,因此將其設置為audioLengthSamples - skipFrame。最后,at:nil指定立即開始播放,而不是在將來的某個時間開始播放。
  • 4)如果在調用skip之前播放器正在播放,則調用player.play()以恢復播放。 updater.isPaused可以方便地確定這一點,因為只有先前暫停了播放器才會生效。

Build并運行,然后點擊playPauseButton。點擊skipBackwardButton并使用skipForwardButton跳過前進和后退。觀察progressBar和計數(shù)標簽的變化。


Implementing Rate Change - 實現(xiàn)播放速率的改變

最后要實現(xiàn)的是改變播放速度。 如今,以超過1倍的速度收聽播客是一項受歡迎的功能。

setupAudio()中,替換以下內容:

engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: audioFormat)

以及:

engine.attach(player)
engine.attach(rateEffect)
engine.connect(player, to: rateEffect, format: audioFormat)
engine.connect(rateEffect, to: engine.mainMixerNode, format: audioFormat)

這會將rateEffectAVAudioUnitTimePitch節(jié)點)連接到音頻圖并將其連接起來。 此節(jié)點類型是效果節(jié)點,具體來說,它可以改變播放速率和音頻音高。

didChangeRateValue() action處理對rateSlider的更改。 它計算rateSliderValues數(shù)組的索引并設置rateValue,它設置rateEffect.raterateSlider的值范圍為0.5x到3.0x

Build并運行,然后點擊playPauseButton。 調整rateSlider就可以聽一下效果聲音了。

參考文章

后記

本篇主要講述了AVAudioEngine之詳細說明和一個簡單示例,感興趣的給個贊或者關注~~~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容