AVAudioEngine Tutorial for iOS: Getting Started
iOS の音声処理を理解するために https://www.kodeco.com/21672160-avaudioengine-tutorial-for-ios-getting-started をやりつつメモしてみる
iOS Audio Frameworks は色々な要素で構成されていて、例えば以下のようなものがある
- CoreAudio, AudioToobox
- 低レベルな C frameworks
- AVFoundation
- Objective-C/Swift framework
- AVAudioSession, AVAudioEngine, AVAudioBuffer, AVAudioPlayer, AVAudioPlayerNode, AVAudioFormat, AVAudioRecorder, AVAudioFile など
- AVFoundation の一部である AVAudioEngine
- 接続された audio nodes のグループを定義する class
- 今回のプロジェクトでは
AVAudioPlayerNode
とAVAudioUnitTimePitch
を追加する
file が音声 file であれば、その url から AVAudioFile
を作り出すことができる。
let file = try AVAudioFile(forReading: fileURL)
AVAudioFile
は processingFormat: AVAudioFormat
という property を持っており、そこから音声ファイルのいくつかの metadata を取り出すことができる。
let format = file.processingFormat
audioLengthSamples = file.length
audioSampleRate = format.sampleRate
audioLengthSeconds = Double(audioLengthSamples) / audioSampleRate
AVAudioNode
は connect する前に必ず attach する必要がある。
engine.attach(player)
engine.attach(timeEffect)
engine.connect(
player,
to: timeEffect,
format: format)
engine.connect(
timeEffect,
to: engine.mainMixerNode,
format: format)
AudioEngine は mainMixerNode
というものを提供しており、これはデフォルトでは output node engine に connect している。(通常は iOS Device speaker)
prepare()
を呼び出せば、必要なリソースを事前に割り当てることができる。
engine.prepare()
また、Device に audio を再生する準備ができたことを伝えるために
try engine.start()
を呼ぶ必要がある。
player.scheduleFile(file, at: nil) {
self.needsFileScheduled = true
}
scheduleFile(_:at:)
で audio file の再生のスケジュールを行うことができる。
at
は AVAudioTime
を指定することができて、未来の時間に再生したい場合はここに未来の時間を指定する。
今回のように nil
を指定した場合は、即座に再生が始まる。
audio file の再生が完了したら、completion block が呼ばれるため、そこに処理を記述すれば audio file 再生完了後の処理を記述できる。
audio の scheduling のための function は他にもいくつか用意されている
-
scheduleBuffer(_:completionHandler:)
- audio data を preload した buffer を提供する
-
scheduleSegment(_:startingFrame:frameCount:at:completionHandler:)
-
schedule(_:at:)
と似ている。異なるのは、「どの audio frame から再生するのか」「どのくらいの frame を再生するのか」を指定すること
-
player.pause()
で音声を停止できるし、player.play()
で音声を再生できる。
次に audio file がどれだけ再生されているかを可視化する方法を見ていく。
可視化のために CADisplayLink
というものを使う。
CADisplayLink
は display の refresh rate に同期して動作する timer object である。
displayLink = CADisplayLink(target: self, selector: #selector(updateDisplay))
displayLink?.add(to: .current, forMode: .default)
displayLink?.isPaused = true
上記では displayLink
を default run lopp に add した後で、CADisplayLink
をまだ開始しないようにするために isPaused
を true にしている。
合わせて、再生ボタンを押した時に呼ばれる playOrPause
で displayLink.isPaused
を切り替える。
func playOrPause() {
isPlaying.toggle()
if player.isPlaying {
displayLink?.isPaused = true
disconnectVolumeTap()
player.pause()
} else {
displayLink?.isPaused = false
connectVolumeTap()
if needsFileScheduled {
scheduleAudioFile()
}
player.play()
}
}
音楽の再生・停止に合わせて displayLink.isPaused
の値も変更している。
さらに先ほど呼んでいた updateDisplay
というロジックは以下のように実装される。
// ここでは `currentPosition` が不正な値にならないように調整している
currentPosition = currentFrame + seekFrame
currentPosition = max(currentPosition, 0)
currentPosition = min(currentPosition, audioLengthSamples)
// `currentPosition` が `audioLengthSamples` 以上、つまり
// 音声の再生が完了しているということなので、それに応じた処理を行っている
if currentPosition >= audioLengthSamples {
player.stop()
seekFrame = 0
currentPosition = 0
isPlaying = false
displayLink?.isPaused = true
disconnectVolumeTap()
}
// 実際に表示される progress の値を計算する
playerProgress = Double(currentPosition) / Double(audioLengthSamples)
let time = Double(currentPosition) / audioSampleRate
playerTime = PlayerTime(
elapsedTime: time,
remainingTime: audioLengthSeconds - time
)
これで音声の再生・停止に progress が追従するようになる。
VU Meters というのは、音声の音量に応じてバウンスするグラフィックを描写することによって live audio 感を示すものである。
AVAudioEngine
では以下のようにすれば AVAudioFormat
を取得することができる。
let format = engine.mainMixerNode.outputFormat(forBus: 0)
forBus
で必要としているのは AVAudioNodeBus
というものらしく、これは audio node 上の bus の index を示すらしい。
bus というのは調べたところによると、「さまざまな音声信号が乗り入れるライン」を指しているらしい (https://dtm.conceptmol.com/バス(bus)/)
installTap(onBus:bufferSize:format:block:)
を利用すれば、mainMixerNode
の output bus 上の audio data にアクセスすることができるようになる。
engine.mainMixerNode.installTap(
onBus: 0,
bufferSize: 1024,
format: format
) { buffer, _ in
// ...
}
ここに指定する bufferSize
については、Apple のドキュメントにもどんな制限があるかは記載されておらず、特に大きすぎたり小さすぎたりするものを指定した場合、そのリクエストされた bufferSize
は保証されないものとなる。
block
は AVAudioPCMBuffer
と AVAudioTime
を利用できる。
ここでは実際の buffer size を定めるために buffer.frameLength
をチェックすることができる。
buffer.floatChannelData
でそれぞれの sample data への pointer の配列を得ることができる。
guard let channelData = buffer.floatChannelData else {
return
}
let channelDataValue = channelData.pointee
計算のしやすさのために UnsafeMutablePointer<Float>
array を Float
array に変換するためには、例えば以下のようなことができる。
let channelDataValueArray = stride(
from: 0,
to: Int(buffer.frameLength),
by: buffer.stride)
.map { channelDataValue[$0] }
engine.mainMixerNode.removeTap(onBus: 0)
で特定の bus の audio tap を削除することができる。
秒数のような時間を audio frame position に変換するためには audioSampleRate
をかける必要がある。
let offset = AVAudioFramePosition(time * audioSampleRate)
player.scheduleSegment(_:startingFrame:frameCount:at:)
は audio file の seekFrame
の位置から再生するようにスケジュールしてくれる。
ファイルの最後まで再生したい場合は audioLengthSamples - seekFrame
にする。
at
に nil
を指定すれば、将来のある時点ではなく、すぐに再生することを意味できる。
let frameCount = AVAudioFrameCount(audioLengthSamples - seekFrame)
// 3
player.scheduleSegment(
audioFile,
startingFrame: seekFrame,
frameCount: frameCount,
at: nil
) {
self.needsFileScheduled = true
}
audio の rate (1x speeds) を変更したい場合は、AVAudioUnitTimePitch
の rate
を変更すれば良い。
let selectedRate = allPlaybackRates[playbackRateIndex]
timeEffect.rate = Float(selectedRate.value)
pitch を変更したい場合は、同じく AVAudioUnitTimePitch
の pitch
を変更すれば良い。
let selectedPitch = allPlaybackPitches[playbackPitchIndex]
timeEffect.pitch = 1200 * Float(selectedPitch.value)
AVAudioUnitTimePitch.pitchによると、その値は cents で測られるらしい。
octave は 1200 cents と等しい。