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
}

這會調度播放整個audioFile骄酗。 at:是您希望音頻播放的未來時間(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并運行,然后點擊playPauseButton嗅绰。 vuMeter現(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)系作者
  • 序言:七十年代末等缀,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子娇昙,更是在濱河造成了極大的恐慌尺迂,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,331評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件冒掌,死亡現(xiàn)場離奇詭異噪裕,居然都是意外死亡,警方通過查閱死者的電腦和手機股毫,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,372評論 3 398
  • 文/潘曉璐 我一進店門膳音,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人铃诬,你說我怎么就攤上這事祭陷。” “怎么了趣席?”我有些...
    開封第一講書人閱讀 167,755評論 0 360
  • 文/不壞的土叔 我叫張陵兵志,是天一觀的道長。 經常有香客問我宣肚,道長想罕,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,528評論 1 296
  • 正文 為了忘掉前任霉涨,我火速辦了婚禮按价,結果婚禮上惭适,老公的妹妹穿的比我還像新娘。我一直安慰自己俘枫,他們只是感情好,可當我...
    茶點故事閱讀 68,526評論 6 397
  • 文/花漫 我一把揭開白布逮走。 她就那樣靜靜地躺著鸠蚪,像睡著了一般。 火紅的嫁衣襯著肌膚如雪师溅。 梳的紋絲不亂的頭發(fā)上茅信,一...
    開封第一講書人閱讀 52,166評論 1 308
  • 那天,我揣著相機與錄音墓臭,去河邊找鬼蘸鲸。 笑死,一個胖子當著我的面吹牛窿锉,可吹牛的內容都是我干的酌摇。 我是一名探鬼主播,決...
    沈念sama閱讀 40,768評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼嗡载,長吁一口氣:“原來是場噩夢啊……” “哼窑多!你這毒婦竟也來了?” 一聲冷哼從身側響起洼滚,我...
    開封第一講書人閱讀 39,664評論 0 276
  • 序言:老撾萬榮一對情侶失蹤埂息,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后遥巴,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體千康,經...
    沈念sama閱讀 46,205評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,290評論 3 340
  • 正文 我和宋清朗相戀三年铲掐,在試婚紗的時候發(fā)現(xiàn)自己被綠了拾弃。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,435評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡摆霉,死狀恐怖砸彬,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情斯入,我是刑警寧澤砂碉,帶...
    沈念sama閱讀 36,126評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站刻两,受9級特大地震影響增蹭,放射性物質發(fā)生泄漏。R本人自食惡果不足惜磅摹,卻給世界環(huán)境...
    茶點故事閱讀 41,804評論 3 333
  • 文/蒙蒙 一滋迈、第九天 我趴在偏房一處隱蔽的房頂上張望霎奢。 院中可真熱鬧,春花似錦饼灿、人聲如沸幕侠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,276評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽晤硕。三九已至,卻和暖如春庇忌,著一層夾襖步出監(jiān)牢的瞬間舞箍,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,393評論 1 272
  • 我被黑心中介騙來泰國打工皆疹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留疏橄,地道東北人。 一個月前我還...
    沈念sama閱讀 48,818評論 3 376
  • 正文 我出身青樓略就,卻偏偏與公主長得像捎迫,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子表牢,可洞房花燭夜當晚...
    茶點故事閱讀 45,442評論 2 359

推薦閱讀更多精彩內容