AVFoundation でループポイント指定のシームレスループ

この記事は[ゆめみ Advent Calendar 2017](https://qiita.com/advent-calendar/2017/yumemi) の 1 日目の記事です。
また、本記事は実は下書きのまま 1 年近く寝かされた記事ですので、ネタとしては現職のではなく、前職で扱っていたものです。

# 前書き
iOS 8.0 では AVFoundation に大きな進化を迎えました。`AVAudioEngine` の誕生です。

それまでにオーディオ再生を扱いたい場合は、`AVFoundation` でかなり高レベルな `AVAudioPlayer` を使うか、`AudioToolbox` や `CoreAudio` などでかなり低レベルないろんなツールの組み合わせを使わなければなりませんでした。程よい「中レベル」なものはありませんでした。

普段のアプリ作りなら `AVAudioPlayer` だけでも全然問題ありません、大体のことは `AVAudioPlayer` だけで乗り切れます。ところがゲームアプリの場合はちょっと困ったことがあります。シームレスループ再生です。

「何をおっしゃる！`AVAudioPlayer` でも `numberOfLoops` を `-1` に設定しておけばループ再生できるじゃないか！」と思うかもしれません、が、ゲームの場合はそう単純に行かないのです。なぜならばゲームの BGM は多くの場合、「曲全体」でループしているわけではありません、最初の一部のイントロ除いた部分だけの再生が多かったです。

`AVAudioEngine` が登場する前に、この問題をどう解決していたかというと、頑張って読みづらい `AudioToolbox` とかをいじってどうにか低レベルのコードでループポイント指定してあげて再生させるか、難しいなら `AVAudioPlayer` でどうにかカバーするしかありませんでした。`AVAudioPlayer` 使う場合、BGM のイントロ部とループ部を別々のファイルに分けて、イントロ部の再生が終わったらループ部のを読み込んでループ再生にさせるか、`AVAudioPlayer` のデリゲート作って `func audioPlayerDidFinishPlaying(AVAudioPlayer, successfully: Bool)` を実装し、この中でいちいち再生開始位置を指定して再生し直すくらいしか方法ありませんでした。前者の場合はループ部の再生はシームレスになるが、ファイルを分割する手間が生じるのと、`AVAudioPlayer` は `audioPlayerDidStartPlaying` 的なデリゲーションがないので `play()` した後にタイマーでループ部の再生を始める必要がありますが、イントロの再生タイミングが保証されないので場合によっては感知できるほどループ部の再生開始タイミングがずれます。また、後者の場合、ファイルを分割する手間は生じませんが、`audioPlayerDidFinishPlaying` が呼ばれる時点で再生のギャップが生じます。

ところが、`AVAudioEngine` の登場により、この問題がようやく比較的に簡単に解決できるようになりました。`AVAudioEngine` は `AudioToolbox` などと比べるとそこまで低レベルではないが、`AVAudioPlayer` と比べるとだいぶ自由に音をいじることができるのです。というわけで早速やってみます。

# 使い方
`AVAudioEngine` で音を再生する大まかな手順としては：

1. `AVFoundation` を `import` します（当然ですが）
2. `AVAudioEngine` を作ります
3. `AVAudioPlayerNode` を作ります
4. 今作った `AVAudioEngine` に、今作った `AVAudioPlayerNode` をアタッチします
5. オーディオファイルを `AVAudioFile` としてインスタンス作ります
6. イントロ部用の `AVAudioPCMBuffer` とループ部用の `AVAudioPCMBuffer` をそれぞれ作って、先ほど作った `AVAudioFile` からイントロ部とループ部をそれぞれに読み込みます
7. 3 で作った `AVAudioPlayerNode` にイントロ部用とループ部用の `AVAudioPCMBuffer` を両方スケジューリングします
8. 2 で作った `AVAudioEngine` のミキサーノードに 3 で作った `AVAudioPlayerNode` を繋げます
9. 2 で作った `AVAudioEngine` をスタートさせます
10. 3 で作った `AVAudioPlayerNode` をプレイさせます

以上です。ではステップごと解説します

## 1. `AVFoundation` を `import` します

言うまでもありません。`import AVFoundation` だけです。

## 2. `AVAudioEngine` を作ります

これはそのまま作っちゃえば OK です

```swift
let engine = AVAudioEngine()
```

## 3. `AVAudioPlayerNode` を作ります

これもそのまま作っちゃえば OK です

```swift
let node = AVAudioPlayerNode()
```

## 4. 今作った `AVAudioEngine` に、今作った `AVAudioPlayerNode` をアタッチします

これもそのままアタッチしちゃえば OK です

```swift
engine.attach(node)
```

## 5. オーディオファイルを `AVAudioFile` としてインスタンス作ります

まずオーディオファイルを探さないといけないのですが、一応 `AVAudioPlayer` で再生できるファイルであれば多分大丈夫なはずです。よって、mp3 や m4a などお好きなフォーマットでファイルを作っても問題ないです。ただし `AVAudioFile` のイニシャライザーはフェイラブルイニシャライザなのでご注意ください；またイニシャライザの引数としてはファイルへの `URL` が必要ですので、先に `URL` を取得しておきましょう。例えば iOS アプリでバンドル内に入れてるファイルや Playground の Resource フォルダーに入れてるファイルなら大体 `Bundle.main.url(forResource: fileName, withExtension: fileExtension)` で取れるはずです

```swift
let file = try AVAudioFile(forReading: <#fileURL#>)
```

## 6. イントロ部用の `AVAudioPCMBuffer` とループ部用の `AVAudioPCMBuffer` をそれぞれ作って、先ほど作った `AVAudioFile` からイントロ部とループ部をそれぞれに読み込みます

これはまずループする部分の先頭の場所を知る必要があります。場所はフレーム数で指定する必要があります、そして作曲者さんからもらえるファイルなら大体はループポイントをフレーム数で教えてくれます。フレーム数がわからないのなら一応サンプリングレートとループする場所の時間で割り出すことも可能ですが精度はそんなに高くないです（ただ大体の人の耳を騙すぶんには多分問題ないです）。サンプリングレートと時間で割り出す場合の計算は、例えばサンプリングレートが 48kHz で、大体 2.4 秒あたりのところからループ部に入る場合、ループ部の先頭のフレーム数はおおよそ 48,000 * 2.4 = 115,200 となります。48kHz はすなわち 1 秒に 48,000 フレームがあると言うことです

この数字を知ったら、ファイルの合計フレーム数からイントロ部のフレーム数とループ部のフレーム数を割り出すことができます。ファイルの合計フレーム数は先ほどで作ったファイルから `file.length` で取得できます。そしてイントロ部のフレーム数はすなわちループ部の開始場所なので数字は同じです。そして合計フレーム数からこのイントロ部のフレーム数を引けばループ部のフレーム数です。

ここで注意する必要があるのは、フレーム**数**の計算は `AVAudioFrameCount` という `UInt32` の `typealias` があります。が、フレーム**場所**の計算は `AVAudioFramePosition` という `Int64` の `typealias` があります。なぜ違う型の `typealias` になっているのかは筆者はわかりません、ご存知の方教えていただければ幸いです。

フレーム数の計算ができましたら、次は `AVAudioPCMBuffer` を作ります。イントロ部とループ部二つありますのでそれぞれ別々に作ります。作ったらファイルからそれぞれにバッファーを読み込みます

```swift
let loopStart: AVAudioFramePosition = 1000 //ループ開始場所のフレーム数、これは作者さんからもらうか自力で頑張って計算するものです

let totalFrameCount = AVAudioFrameCount(file.length)
let introFrameCount = AVAudioFrameCount(loopStart)
let loopFrameCount = totalFrameCount - introFrameCount

let introFrameBuffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: introFrameCount) //イントロ部のバッファー
let loopFrameBuffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: loopFrameCount) //ループ部のバッファー

try file.read(into: introFrameBuffer, frameCount: introFrameCount) //読み込んだファイルをイントロ部のバッファーにイントロ部のフレーム数だけ読み込む
try file.read(into: loopFrameBuffer, frameCount: loopFrameCount) //読み込んだファイルをさっきの続きからループ部のフレーム数だけ読み込む
```

## 7. 3 で作った `AVAudioPlayerNode` にイントロ部用とループ部用の `AVAudioPCMBuffer` を両方スケジューリングします

`AVAudioPlayerNode` に `AVAudioPCMBuffer` をスケジューリングするときはいくつかオプションがあります。`.loops` は再生時にこのバッファーをループ再生させます。`.interrupts` はノードに今再生中のバッファーを中断してバッファーを今からスケジューリングします（なければ再生中のバッファーが全て再生し終わった段階でスケジューリングしたバッファーを再生します）。`.interruptsAtLoop` は今再生中のバッファーがループ中でしたら再生中のバッファーが終わったら次のループに入らずにこのバッファーを再生させます（ループ中でなければ再生中のバッファーの再生が終わったらこのバッファーに入ります）。またこのオプションは `OptionSet` なので組み合わせて使うこともできます。我々が欲しいのはイントロ部のバッファーを一回だけ再生し終わったらループ部をループ再生したいので、イントロ部は `.interrupts`（もしくは `[]` でも OK）、ループ部は `.loops` で順番にスケジューリングします

```swift
// 順番でバッファーを順番で再生していくだけなので、スタートタイムの指定は必要なく、`at: nil` で渡せば OK です
node.scheduleBuffer(introFrameBuffer, at: nil, options: .interrupts)
node.scheduleBuffer(loopFrameBuffer, at: nil, options: .loops)
```

## 8. 2 で作った `AVAudioEngine` のミキサーノードに 3 で作った `AVAudioPlayerNode` を繋げます

`AVAudioEngine` に `AVAudioPlayerNode` を繋げるときに、`AVAudioEngine` のノードとフォーマットを選ぶ必要があります。ここで我々は音声の再生をしたいので、繋げるのは `outputNode` もしくは `mainMixerNode` になります。また、フォーマットは読み込みされたファイルから `processingFormat` を渡してあげれば OK です。

```swift
engine.connect(node, to: engine.mainMixerNode, format: file.processingFormat)
```

## 9. 2 で作った `AVAudioEngine` をスタートさせます

ここまで下準備が終わりましたので、エンジンを起動させます

```swift
try engine.start()
```

## 10. 3 で作った `AVAudioPlayerNode` をプレイさせます

エンジンの起動ができたので、ノードをプレイさせれば、繋がっているエンジンがよしなりに色々処理してくれて再生ができます

```swift
node.play()
```

## Playground で使ってみる

全部のソースコードをまとめるとこんな感じになります。Playground で試すのを前提に作っているので、至る所で `!` 使ってますが、実際の製品コードには適切にエラーハンドリングが必要になるかと思います；後は音声ファイルを Playground の Resource フォルダーの直下に入れて、ファイルの呼び出しをファイル名に合わせて直せば（例えばファイル名が `audio.mp3` なら、ファイルの `URL` 取得を `Bundle.main.url(forResource: "audio", withExtension: "mp3")!` にすれば）OK です。

```swift:Playground.swift
import AVFoundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

let engine = AVAudioEngine()
let node = AVAudioPlayerNode()
engine.attach(node)

let loopStart: AVAudioFramePosition = <#loopStartPosition#>

let url = Bundle.main.url(forResource: <#fileName#>, withExtension: <#fileExtension#>)!
let file = try! AVAudioFile(forReading: url)

let totalFrameCount = AVAudioFrameCount(file.length)
let introFrameCount = AVAudioFrameCount(loopStart)
let loopFrameCount = totalFrameCount - introFrameCount

let introFrameBuffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: introFrameCount)
let loopFrameBuffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: loopFrameCount)

try! file.read(into: introFrameBuffer, frameCount: introFrameCount)
try! file.read(into: loopFrameBuffer, frameCount: loopFrameCount)

node.scheduleBuffer(introFrameBuffer, at: nil, options: .interrupts)
node.scheduleBuffer(loopFrameBuffer, at: nil, options: .loops)

engine.connect(node, to: engine.mainMixerNode, format: file.processingFormat)

try! engine.start()
node.play()
```

# 後書き

実はこれを利用したシームレス再生用のプレイヤーを作りました。[Hifumi](https://github.com/el-hoshino/Hifumi) です。名前は当然 [NEW GAME! のひふみ先輩](http://newgame-anime.com/assets/character/c4.png)からとっています。

使い方はとても簡単。[フレームワークに付属の Playground](https://github.com/el-hoshino/Hifumi/blob/master/Hifumi/Playground.playground/Contents.swift) を読めばわかりますが、Hifumi をプロジェクトに落としてきて、ファイルの読み込みとループポイントを指定してあげて再生すれば OK です。

```swift
import Hifumi

let url = Bundle.main.url(forResource: <#fileName#>, withExtension: <#fileExtension#>)!
let player = try! HifumiPlayer(url: url, playMode: .loop(range: <#loopStart#>...))
player.play()
```

ちなみに、元々はゲーム用途を想定していたので、`playMode` は `.once` と `.loop` がありますが、`.once` は一回だけしか再生しない、つまり音声と SE 向けのモードです；`loop` は `.loop(range: <#Range#>)` で渡しますが、`<#Range#>` はいろんなレンジをサポートしています。`123 ..< 456` のような `Range` も使えますし、Swift 4 から導入された `123...` や `..<456` のような PartialRange も使えます。さらに全曲ループしたい場合は `.loop(.all)` も使えます。

というわけで、シームレス再生用のプレイヤー [Hifumi](https://github.com/el-hoshino/Hifumi) を宜しくお願いします。