読者です 読者をやめる 読者になる 読者になる

いものやま。

雑多な知識の寄せ集め

iOSのゲームでBGMやSEを流す方法について。

SoloXmasではBGMやSEにも対応したので、その方法について。

AVAudioPlayer

iOSで音楽を流す場合、AVFoundationのAVAudioPlayerクラスを使うといい。

import AVFoundation

// 音声ファイルのURLからAVAudioPlayerを生成
if let player = try? AVAudioPlayer(contentsOfURL: url) {
  // 音声ファイルを再生
  player.play()

  // 音声ファイルを永遠にループして再生する場合
  player.numberOfLoops = -1
  player.play()

  // 音声ファイルの再生を停止する場合
  player.stop()
}

ちなみに、AVAudioPlayer#currentTimeプロパティを設定すれば、再生開始の位置を変えたりも出来る。

あと、複数のAVAudioPlayerのインスタンスを用意して再生すれば、複数の音声ファイルを同時に流すことも出来る。

iTunesで再生中の音楽を停止させない方法

ただ、ちょっと困ったこととして、単純に上のようにしてしまうと、アプリを起動したときにiTunesで再生中の音楽も停止してしまうという問題が。
BGMを流しながらゲームをするのもいいけど、iTunesで好みの音楽を流しながらゲームをしたいというときもある。

その場合、どうすればいいのかというと、AVAudioSessionでカテゴリの設定をしてやるといい。

具体的には、次のような感じ。

_ = try? AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryAmbient)

これでiTunesの音楽の状態に関係なく音楽が流れるようになるし、iTunesで再生中の音楽も止まらなくなる。

AVAudioPlayer#prepareToPlay()に関して

ところで、AVAudioPlayerにはprepareToPlay()というメソッドが用意されている。
AVAudioPlayerの使い方をググると、AVAudioPlayer#play()を実行する前に、一度このメソッドを実行しておかないといけないかのような記述をしているところが多いように思う。

実のところ、AVAudioPlayer#prepareToPlay()をあらかじめ呼んでおかないといけないなんてことはない。

AVAudioPlayerのクラスリファレンスを読むと、play()のところに

Calling this method implicitly calls the prepareToPlay method if the audio player is not already prepared to play.
(意訳:このメソッドを呼び出したとき、もしプレイヤーの再生の準備が出来ていなければ、prepareToPlayメソッドが暗黙的に呼ばれる)

とも書いてある。

じゃあ、明示的に呼び出す必要がないのかと言えばそうでもなくて、prepareToPlay()のところには

Calling this method preloads buffers and acquires the audio hardware needed for playback, which minimizes the lag between calling the play method and the start of sound output.
(意訳:このメソッドを呼び出すと、音楽データをバッファに読み出して、再生に必要なハードウェア資源の獲得が行われる。これによって、playメソッドの呼び出しと音楽が実際に鳴り始めるまでのラグを最小化できる)

とも書かれているので、出来れば実際に再生する前に呼び出しておいた方がいい。

ただし、実際に試してみたところ、prepareToPlay()は同期呼び出しのようで、つまり、prepareToPlay()を呼び出した場合、オーディオの再生準備が整う(か失敗する)まで、制御が呼び出し元に戻ってこないみたい。
このせいで、単純に呼び出すのだと、単にplay()を呼び出したときに生じるラグと同等の時間がprepareToPlay()の呼び出しにかかることになり、メインスレッドで実行した日には、だいぶ待たされてしまうことになる。
なので、Swiftでの並列プログラミングについて調べてみた。(その1) - いものやま。のオペレーションキューを使って、並列に処理をした方がいい。

SoundManager

ここまでの内容を踏まえて、複数の音声ファイルを管理するのに便利なSoundManagerクラスを作ってみた。

//==============================
// SoloXmas
//------------------------------
// SoundManager.swift
//==============================

import AVFoundation

class SoundManager: NSObject {
  enum Config: Int {
    case On = 0
    case Off
    
    private static let key = "com.ouka-do.SoloXmas.SoundConfig"
  }
  
  private static var initialized = false
  private static let manager = SoundManager()
  
  static func getInstance() -> SoundManager {
    if !SoundManager.initialized {
      // don't stop itunes music
      _ = try? AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryAmbient)
      
      let userDefaults = NSUserDefaults.standardUserDefaults()
      userDefaults.registerDefaults([Config.key: Config.On.rawValue])
      
      SoundManager.initialized = true
    }
    return SoundManager.manager
  }
  
  var config: Config {
    get {
      let userDefaults = NSUserDefaults.standardUserDefaults()
      return Config(rawValue: userDefaults.integerForKey(Config.key))!
    }
    
    set {
      let userDefaults = NSUserDefaults.standardUserDefaults()
      userDefaults.setInteger(newValue.rawValue, forKey: Config.key)
      userDefaults.synchronize()
    }
  }
  
  private var players: [String: AVAudioPlayer]
  private let operationQueue: NSOperationQueue
  
  private override init() {
    self.players = [String: AVAudioPlayer]()
    self.operationQueue = NSOperationQueue()
    
    super.init()
  }
  
  func addSoundForURL(url: NSURL, forKey key: String) {
    let player = try? AVAudioPlayer(contentsOfURL: url)
    self.players[key] = player
    self.operationQueue.addOperationWithBlock {
      self.players[key]?.prepareToPlay()
    }
  }
  
  func playSoundForKey(key: String, loop: Bool = false) {
    if let player = self.players[key] {
      if self.config == .On {
        if loop {
          player.numberOfLoops = -1
        } else {
          player.numberOfLoops = 0
        }
        player.play()
      }
    }
  }
  
  func stopSoundForKey(key: String) {
    if let player = self.players[key] {
      player.stop()
    }
  }
  
  func rewindSoundForKey(key: String) {
    if let player = self.players[key] {
      player.currentTime = 0.0
    }
  }
  
  func stopAllSounds() {
    for (_, player) in self.players {
      player.stop()
    }
  }
  
  func rewindAllSounds() {
    for (_, player) in self.players {
      player.currentTime = 0.0
    }
  }
}

NSUserDefaultsを使って、音声のON/OFFの設定も保持するようにしてある。
(NSUserDefaultsについては、変種オセロのスタート画面を作ってみた。(その4) - いものやま。を参照)

使い方は、以下のような感じ:

// ViewController.swift

class ViewController: UIViewController {
  // 省略
  
  override func viewDidLoad() {
    // 省略

    // あらかじめロードしておく
    // (バックグラウンドで並列処理される)
    let soundManager = SoundManager.getInstance()
    soundManager.addSoundForURL(NSBundle.mainBundle().URLForResource("enigmatic", withExtension: "mp3")!, forKey: "BGM")
    soundManager.addSoundForURL(NSBundle.mainBundle().URLForResource("anime2", withExtension: "mp3")!, forKey: "Success")
    soundManager.addSoundForURL(NSBundle.mainBundle().URLForResource("shock", withExtension: "mp3")!, forKey: "Failure")
    
    // 省略
  }

  // 省略
}
// GameScene.swift

class GameScene: SKScene {
  // 省略

  override func didMoveToView(view: SKView) {
    // 省略

    // ビューに追加されたら、設定に応じてBGMを再生する
    let soundManager = SoundManager.getInstance()
    if soundManager.config == .On {
          soundManager.playSoundForKey("BGM", loop: true)
    }

    // 省略
  }

  override func willMoveFromView(view: SKView) {
    // 省略
    
    // ビューから取り除かれるとき、
    // 音を停止させて先頭に巻き戻す
    let soundManager = SoundManager.getInstance()
    soundManager.stopAllSounds()
    soundManager.rewindAllSounds()
    
    // 省略
  }

  // 省略
}

なお、実際には単に上のようにすると、シーンの遷移のときにちょっとした問題も出てくるんだけど・・・
それについてはまた今度で。

今日はここまで!