この記事はゆめみ Advent Calendar 2017 の 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
で音を再生する大まかな手順としては:
-
AVFoundation
をimport
します(当然ですが) -
AVAudioEngine
を作ります -
AVAudioPlayerNode
を作ります - 今作った
AVAudioEngine
に、今作ったAVAudioPlayerNode
をアタッチします - オーディオファイルを
AVAudioFile
としてインスタンス作ります - イントロ部用の
AVAudioPCMBuffer
とループ部用のAVAudioPCMBuffer
をそれぞれ作って、先ほど作ったAVAudioFile
からイントロ部とループ部をそれぞれに読み込みます - 3 で作った
AVAudioPlayerNode
にイントロ部用とループ部用のAVAudioPCMBuffer
を両方スケジューリングします - 2 で作った
AVAudioEngine
のミキサーノードに 3 で作ったAVAudioPlayerNode
を繋げます - 2 で作った
AVAudioEngine
をスタートさせます - 3 で作った
AVAudioPlayerNode
をプレイさせます
以上です。ではステップごと解説します
1. AVFoundation
を import
します
言うまでもありません。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
という UInt32
の typealias
があります。が、フレーム場所の計算は AVAudioFramePosition
という Int64
の typealias
があります。なぜ違う型の 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
を両方スケジューリングします
AVAudioPlayerNode
に AVAudioPCMBuffer
をスケジューリングするときはいくつかオプションがあります。.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
を繋げます
AVAudioEngine
に AVAudioPlayerNode
を繋げるときに、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 です。
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 を宜しくお願いします。