この記事はゆめみ Advent Calendar 2017 の 1 日目の記事です。
また、本記事は実は下書きのまま 1 年近く寝かされた記事ですので、ネタとしては現職のではなく、前職で扱っていたものです。

前書き

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

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

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

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

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

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

使い方

AVAudioEngine で音を再生する大まかな手順としては:

  1. AVFoundationimport します(当然ですが)
  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. AVFoundationimport します

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

2. AVAudioEngine を作ります

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

let engine = AVAudioEngine()

3. AVAudioPlayerNode を作ります

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

let node = AVAudioPlayerNode()

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

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

engine.attach(node)

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

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

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 という UInt32typealias があります。が、フレーム場所の計算は AVAudioFramePosition という Int64typealias があります。なぜ違う型の typealias になっているのかは筆者はわかりません、ご存知の方教えていただければ幸いです。

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

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 を両方スケジューリングします

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

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

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

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

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

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

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

try engine.start()

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

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

node.play()

Playground で使ってみる

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

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 です。名前は当然 NEW GAME! のひふみ先輩からとっています。

使い方はとても簡単。フレームワークに付属の Playground を読めばわかりますが、Hifumi をプロジェクトに落としてきて、ファイルの読み込みとループポイントを指定してあげて再生すれば OK です。

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 を宜しくお願いします。